HomeDeveloper docsActor images: portrait and token
Examples by kind 4 min read

Actor images: portrait and token

Two complete File-picker mount functions using the actual multipart host.call operation.

Both examples use host.call("actor.image.upload", payload), a block SDK operation. File transmission is not an api.actors.update operation. The result is {url}; the server validates campaign, actor editing and image content.

Prepare the minimal package, a synthetic actor and an original PNG/JPEG/WebP. Register each function on actor.sheet: register("actor.sheet", mountPortraitUpload) or register("actor.sheet", mountTokenUpload). The context must provide actorId and canEdit; the user must be GM or have actor editing permission. The control is disabled unless canEdit is true; the server remains the authority.

Copy one complete function into main.js. Do not pass the JSON Schema file descriptor as file: browser code passes the real File from the picker. The bridge creates multipart data and metadata. Do not encode images as base64.

kind: "portrait": Upload a portrait

Use: change the actor portrait displayed by its sheet.

javascript
export function mountPortraitUpload({ root, context, host, signal }) {
  const doc = root.ownerDocument;
  const label = doc.createElement("label");
  label.textContent = "Upload a portrait";
  const input = doc.createElement("input");
  input.type = "file";
  input.accept = "image/png,image/jpeg,image/webp";
  const status = doc.createElement("output");
  status.setAttribute("aria-live", "polite");
  const preview = doc.createElement("img");
  preview.alt = "Uploaded image";
  preview.width = 128;
  preview.height = 128;
  preview.hidden = true;
  label.append(input);
  root.append(label, status, preview);
  input.disabled = context.canEdit !== true;

  input.addEventListener("change", async () => {
    const file = input.files?.[0];
    if (!file || signal.aborted) return;
    input.disabled = true;
    status.textContent = "Uploading…";
    try {
      const result = await host.call("actor.image.upload", {
        id: context.actorId,
        kind: "portrait",
        file,
      }, { signal });
      if (signal.aborted) return;
      preview.src = result.url;
      preview.hidden = false;
      status.textContent = "Image saved.";
    } catch (error) {
      if (!signal.aborted) status.textContent = "Failed: " + error.code;
    } finally {
      if (!signal.aborted) input.disabled = context.canEdit !== true;
    }
  }, { signal });
}

Expected result: the preview uses the returned authorized URL and the actor retains its new portrait image after reopening.

Validate: upload a small image, reopen the sheet and check the image. Repeat as an authorized player and a read-only user. Close during upload and verify that the disposed UI receives no updates.

kind: "token": Upload token artwork

Use: change the actor token artwork; this does not set position, scale or linking of existing placed tokens.

javascript
export function mountTokenUpload({ root, context, host, signal }) {
  const doc = root.ownerDocument;
  const label = doc.createElement("label");
  label.textContent = "Upload token artwork";
  const input = doc.createElement("input");
  input.type = "file";
  input.accept = "image/png,image/jpeg,image/webp";
  const status = doc.createElement("output");
  status.setAttribute("aria-live", "polite");
  const preview = doc.createElement("img");
  preview.alt = "Uploaded image";
  preview.width = 128;
  preview.height = 128;
  preview.hidden = true;
  label.append(input);
  root.append(label, status, preview);
  input.disabled = context.canEdit !== true;

  input.addEventListener("change", async () => {
    const file = input.files?.[0];
    if (!file || signal.aborted) return;
    input.disabled = true;
    status.textContent = "Uploading…";
    try {
      const result = await host.call("actor.image.upload", {
        id: context.actorId,
        kind: "token",
        file,
      }, { signal });
      if (signal.aborted) return;
      preview.src = result.url;
      preview.hidden = false;
      status.textContent = "Image saved.";
    } catch (error) {
      if (!signal.aborted) status.textContent = "Failed: " + error.code;
    } finally {
      if (!signal.aborted) input.disabled = context.canEdit !== true;
    }
  }, { signal });
}

Expected result: the preview uses the returned authorized URL and the actor retains its new token image after reopening.

Validate: upload a small image, reopen the sheet and check the image. Repeat as an authorized player and a read-only user. Close during upload and verify that the disposed UI receives no updates.

Limits, errors and lifecycle

The server accepts PNG, JPEG or WebP up to 10,000,000 bytes and 25,000,000 pixels. It normalizes orientation and saves PNG. The accept attribute only guides the picker; it does not replace validation.

An inaccessible actor may produce not_found or permission_denied; invalid images produce invalid_data; transport failures may produce unavailable. Disposal cancels the context: skip DOM updates after signal.aborted. A server-committed upload remains saved even when the UI closes. The listener uses {signal} and is removed on disposal. Read api.actors.get(context.actorId) again before a later edit that depends on the actor version.

How to use AI to build an image picker

text
Inspect the actor.image.upload schemas and browser-bridge.js on the target release. Adapt mountTokenUpload to display accessible progress using host.call's onProgress option. Preserve multipart File data, native permissions, signal, cleanup and textContent. Do not invent an endpoint or write a user URL directly into the actor. Include invalid-image, close-during-upload and read-only-player checks.