Appearance
Images and resource lifetimes
Images cross the SDK as opaque campaign-owned references. The browser edge loads authenticated bytes and owns decoded handles; layers describe where an image belongs without knowing storage keys or access URLs.
The public pool and browser integration are implemented. Focused native-file workflows cover upload, binding, retries and cleanup; the local runtime also has recorded intake and permitted GM/player reads. Visual and task-experience acceptance remain separate. This guide does not establish complete image support.
References and drawing
ImageAssetRef is { campaignId, assetId, sceneMap? }. It contains no object key, token, remote URL or renderer handle. For derived scene-map bytes, sceneMap is:
ts
type SceneMapContext = {
readonly tableId: string;
readonly sceneId: string;
readonly version: number; // Independent map revision, not the table command revision.
readonly previewKind: 'player' | 'gm-preview';
};Original GM artwork omits this context. Player maps and GM audience previews carry their exact context. The server still checks current membership, the active scene for a normal player read, and actual GM permission for a GM preview. A client-supplied preview kind is not an authority grant.
A projected TableView.scene.background adds pixelWidth and pixelHeight: display dimensions after encoded orientation, not necessarily the original encoded width and height. The browser maps runtime displayWidth/displayHeight to these fields using the snapshot's campaign ID.
Its optional placement: { at: Point, width, height } carries world geometry projected from the owning domain's map transform. When present, use it unchanged; do not recompute that transform from decoded or masked image dimensions. The application adapter supplies this placement for its scene-map workflow. Ordinary backgrounds without authoritative placement can use a separate display-fit policy.
The image member of DrawPrimitive has asset, world-coordinate at, width, height and optional opacity. A registered layer emits it; the renderer resolves the asset through the application's loader and pool.
ts
import type { LayerContribution } from '@talerook/sdk';
export const backgroundLayer: LayerContribution = {
id: 'example.background/image',
order: -100,
render(view) {
const background = view.scene.background;
if (!background) return [];
if (background.placement) {
return [
{
kind: 'image',
asset: background.asset,
at: background.placement.at,
width: background.placement.width,
height: background.placement.height,
},
];
}
const scale = Math.min(
view.scene.width / background.pixelWidth,
view.scene.height / background.pixelHeight,
);
const width = background.pixelWidth * scale;
const height = background.pixelHeight * scale;
return [
{
kind: 'image',
asset: background.asset,
at: {
x: (view.scene.width - width) / 2,
y: (view.scene.height - height) / 2,
},
width,
height,
},
];
},
};Register this with context.vtt.registerLayer under manifest ID example.background and capability vtt.layers. The example assumes validated positive scene/display dimensions. Fit preserves aspect ratio within existing scene bounds. Draw backgrounds below grid, interaction and pieces. Assigning an image must not move pieces or change world coordinates.
Bind through authority
These reviewed requests use the public CommandRequest bridge. Background changes include the scene captured when the action was formed:
ts
import type { CommandRequest } from '@talerook/sdk';
export const setBackground = (sceneId: string, assetId: string | null): CommandRequest => ({
type: 'scene.background.set',
payload: { sceneId, assetId },
});
export const setCover = (assetId: string | null): CommandRequest => ({
type: 'campaign.cover.set',
payload: { assetId },
});A command contribution passes one to CommandContext.dispatch. null clears the binding. The .set suffix is part of each operation name; earlier drafts without it are superseded.
The application dispatch adapter scopes requests to its current table/campaign and allowlists/validates the operation. Scene backgrounds use the existing table command ID, expected version and retry envelope, including intent: { type: 'scene.background.set', sceneId, assetId }. Capture sceneId from the action's original projected view and retain it through dispatch and retry; never replace it with a newly current scene. Covers use an independent campaign command ID and campaign version. An uncertain retry retains the original envelope; it must not create a second intent or borrow the other revision. The server rechecks current GM permission and ready campaign-owned asset membership before duplicate lookup/binding.
For example, form a background envelope from the original action's view:
ts
const capturedSceneId = view.scene.id;
const envelope = {
commandId,
expectedVersion: view.revision,
intent: { type: 'scene.background.set', sceneId: capturedSceneId, assetId },
};Native-file upload is a separate intake edge. Preserve its X-Upload-Id and original bytes, type and filename on uncertain retry. Neither a reference nor an SDK capability grants permission to read or bind an asset.
Supply the browser loader
createImageResourcePool(loader) comes from @talerook/engine. Its ImageResourceLoader port takes (asset, signal) and returns an ImageResource: nonempty local src, positive integer display dimensions and a dispose function.
The owning browser adapter authenticates each campaign-scoped content request, fetches permitted bytes, decodes them with orientation, creates its local URL/handle and releases that handle. Current GM reads are campaign-scoped; players can read only permitted currently bound background/cover assets. Projection and authorization happen before rendering.
Do not persist Blob URLs, renderer URLs, credentials, storage keys or remote image URLs. Do not substitute a hardcoded map for native intake. The browser adapter's owning source is the integration reference; this guide deliberately leaves authentication, decode and URL handling at that port.
Hold a lease while displaying
The complete example below receives that loader from an application port. The port's showUntilClosed must stop using the image immediately on cancellation and detach it before settling. Its signal ends on the owning view's identity/table/layer lifetime. The caller must await displayImage and handle its rejection.
ts
import { createImageResourcePool } from '@talerook/engine';
import type {
ImageAssetRef,
ImageResourceLease,
ImageResourceLoader,
ImageResourceView,
} from '@talerook/sdk';
export interface ImageDisplayPort {
load: ImageResourceLoader;
showUntilClosed(view: ImageResourceView, signal: AbortSignal): Promise<void>;
}
export async function displayImage(
asset: ImageAssetRef,
signal: AbortSignal,
app: ImageDisplayPort,
): Promise<void> {
const pool = createImageResourcePool(app.load);
let lease: ImageResourceLease | undefined;
const errors: unknown[] = [];
try {
lease = pool.acquire(asset, signal);
const view = await lease.ready;
if (!signal.aborted) await app.showUntilClosed(view, signal);
} catch (error) {
const cancelled = signal.aborted && error instanceof Error && error.name === 'AbortError';
if (!cancelled) errors.push(error);
} finally {
try {
await lease?.release();
} catch (error) {
errors.push(error);
}
try {
await pool.dispose();
} catch (error) {
errors.push(error);
}
}
if (errors.length) throw new AggregateError(errors, 'Image display or cleanup failed');
}This is a single-view owner. A table renderer normally shares one pool across its active image primitives and releases each lease as its primitive disappears. Do not release immediately after setting an image element's src: the view is usable only while its lease is held.
Sharing, cancellation and errors
- Leases for the same complete reference in one pool share a load and immutable
ImageResourceView. Original artwork, player maps and GM previews never share a resource; table, scene, map revision and preview kind all participate in the derived-image key. Releasing one lease does not revoke another. - The pool snapshots and freezes the reference and nested scene-map context before invoking its loader. Later caller mutation cannot retarget the request or alter its cache identity. Malformed contexts reject before loading: IDs must be nonempty, the version a safe nonnegative integer, and the preview kind one of the two declared values. Partial or unknown context fields reject.
releaseis idempotent. Last release aborts loading and reclaims the resource exactly once. Releasing a pending lease rejects itsreadywithAbortError; a late result cannot become that lease's displayed image.- An optional abort signal releases only its own lease. A loader must observe cancellation and settle: the pool cannot forcibly terminate JavaScript, and release/disposal must wait for that work before cleanup completes.
readyreports loading/validation failures. An explicit later acquire can retry. Invalid loader output is rejected and any captured disposer is reclaimed.- Await
releaseandpool.dispose; cleanup can be asynchronous and reject. Disposal aborts and awaits all owned work, including early-released loads and late cleanup. It reports accumulated cleanup failures asAggregateError, including failures already observed by a release. Do not fire and forget them.
Release leases when an asset changes, its primitive disappears, or its layer or extension unloads. An extension-owned lease can register release with context.resources.add; report unload failures through the host error path. Dispose the pool at workspace/table/identity end, and never reuse a GM pool for a player identity. The adapter owns revoking its local Blob URLs on final release.
Current limits
The native slice targets PNG, JPEG and WebP backgrounds and campaign covers. It is not token portrait support, arbitrary remote-image fetching, a complete asset library or commercial entitlement/billing implementation. Foundation capacities and file validation are server-owned.
Asset-inclusive export/restore is not implemented. Native backup currently refuses a campaign containing image assets rather than silently losing files or bindings. Focused browser evidence covers native PNG intake, assignment, clear/reassign, retry, read recovery, denied access and resource cleanup. Runtime codec checks cover all three supported formats. Broader file/browser coverage, visual quality and task acceptance retain their separate limits. No content distribution license is implied.