Files
awesome-copilot/extensions/apng-studio/extension.mjs
T
Andrea Liliana Griffiths 0916fe444d Add APNG Studio canvas extension (#2272)
* Add APNG Studio canvas extension

APNG Studio is an interactive GitHub Copilot canvas extension for building
Animated PNG (APNG) files from frames: draw or upload frames, tune per-frame
timing and compositing, preview live, send the result to a phone by QR, and
export an animated .png.

Adds extensions/apng-studio/ with the required .github/plugin/plugin.json and
assets/preview.png, and regenerates .github/plugin/marketplace.json.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for APNG Studio

Security and robustness (extension.mjs):
- Reject "." and ".." project ids so canvas/action input cannot resolve
  outside artifacts/.
- Bound request bodies (1 MiB JSON, 40 MiB frame upload); return 413 when
  exceeded.
- Reject malformed JSON with 400 instead of coercing to {}, so a truncated
  body cannot trigger destructive routes such as /frames/clear.
- Scope shares per project so one canvas cannot rotate or stop another's
  share; the LAN server is shared and torn down once no shares remain.
- get_state treats hiddenFirst as active only with >=2 frames, matching the
  encoder, so a single-frame project reports the correct duration.

Accessibility (web/):
- Expose Pen/Eraser state with aria-pressed and keep it in sync in setTool.
- Mark the toast as an aria-live status region.

Packaging:
- Add extensions/apng-studio/.gitignore with artifacts/ so the documented
  runtime-data exclusion holds for this extension.
- Update the README install section to reference the committed awesome-copilot
  extension path.

Regenerated .github/plugin/marketplace.json.

* Use "GitHub Copilot app" wording

Update the plugin description (and regenerated marketplace entry) to refer to
the host as the GitHub Copilot app.

* Address deeper review for APNG Studio

Security:
- Require a per-server access token on every loopback data/mutation request
  (minted per canvas server, carried in the iframe URL, attached to every
  renderer request). Static assets stay public. Blocks a local process or
  cross-origin page from reading state or driving mutations.

Concurrency and durability:
- Serialize the load-mutate-save cycle per project; dedupe concurrent first
  loads; write project.json via temp file + rename.

Lifecycle:
- Track SSE clients per instance and end them before server.close().
- Stop a project's LAN share only when no other panel references it.

Correctness:
- Report exact numerator/denominator total duration; handle pointercancel.

QR and accessibility:
- Place QR version bits least-significant-bit first (versions 7-10).
- Associate delay/fps/dispose/blend labels with their inputs via for=.

* Address deeper concurrency and validation review for APNG Studio

- Run assemble() under the project lock; add_color_frame renders and appends
  within one lock.
- Validate uploaded frames (PNG chunk scan, size + dimension checks) before
  writing; first frame stores real dimensions; CanvasError maps to 400.
- Validate move delta; clear frames in memory before deleting files.
- Route the open-handler rename through applySettings.
- Deduplicate LAN share startup; clean up if the canvas closes mid-start.
- End SSE streams before server.close(); skip dead streams in broadcast.
- Exact fractional duration; drawing surface stays >= 1px; refresh state before
  re-seeding "start from last frame".

* Address review: PNG format validation, share bind, limits, a11y

- Validate uploaded frames are 8-bit RGBA non-interlaced PNGs (standard
  compression/filter); reject formats the codec can't encode.
- Cap projects at 600 frames (add + duplicate).
- Bind the LAN share server to the private address, not 0.0.0.0; require a
  private address and rebind when it changes while idle; build the URL from the
  bound address.
- Align width/height inputs and clampSize to the 2048 maximum.
- Size the drawing surface by width + aspect ratio for narrow panels.
- set_frame with an unknown frameId returns a validation error.

* Address review: encoded-byte budget, timing modes, id keys, load errors

- 256 MiB aggregate encoded-byte budget (add + duplicate); per-frame size
  tracked and backfilled on load, so a frame count alone no longer bounds
  assembly memory.
- Frame timing is one exclusive mode (delayMs | fps | delayNum/delayDen);
  combinations are rejected instead of producing hybrid delays.
- Collision-resistant project storage keys: safe ids are used verbatim (existing
  projects still load) and only unsafe ids get a hash suffix, so "foo/bar" and
  "foo?bar" can't share a directory.
- Load only treats a missing file as a new project; other read/parse errors
  surface instead of silently overwriting real data.
- Share server binds to a real LAN interface (skips virtual/VPN/container,
  prefers physical NICs and common ranges).
- Generated export names get millisecond + random suffix to avoid same-second
  overwrite.
- PNG validator accepts the encoder's Uint8Array so agent solid-color frames
  aren't rejected.

* Address review: crash-safe frame writes, server startup, APNG first frame

- Write the PNG before mutating project metadata on add/duplicate, and delete
  after persisting, so an interrupted disk op can't dangle a reference or
  advance unsaved state.
- Evict a project from the in-memory cache if its save fails, so a transient
  write error can't desync the live session from disk.
- Reject on the loopback server's listen error and drop a half-initialized
  instance so startup failures clean up and can retry.
- Normalize APNG dispose_op PREVIOUS to BACKGROUND on the first animated frame
  (spec requirement).
- Release decoded ImageBitmaps after use so a large upload batch doesn't retain
  native image memory.
- Surface otherwise-silent async UI failures via a toast; check the state
  response is ok before parsing it.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-13 09:53:55 +10:00

1235 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Extension: apng-studio
// Interactive studio to create Animated PNG (APNG) files from frames.
//
// Architecture:
// • Server-owned state: frames live on disk under artifacts/<projectId>/ so
// they survive extension reloads and are shared between the interactive
// iframe UI and the agent-callable actions.
// • One loopback HTTP server per open canvas instance serves the renderer
// (web/), JSON state, per-frame PNGs, a live `/preview.png`, and mutation
// endpoints. Server-Sent Events push "changed" so every open panel and the
// preview stay in sync.
// • APNG assembly + a small RGBA→PNG encoder live in ./apng.mjs.
import { createServer } from "node:http";
import { fileURLToPath } from "node:url";
import { join, extname } from "node:path";
import { promises as fs } from "node:fs";
import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
import { networkInterfaces } from "node:os";
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
import { assembleApng, solidColorPng, encodeRgbaPng } from "./apng.mjs";
import { encodeQr } from "./qr.mjs";
const EXT_DIR = fileURLToPath(new URL(".", import.meta.url));
const WEB_DIR = join(EXT_DIR, "web");
const ARTIFACTS_DIR = join(EXT_DIR, "artifacts");
const EXPORTS_DIR = join(ARTIFACTS_DIR, "exports");
const DEFAULT_PROJECT = "default";
const MAX_FRAMES = 600; // count cap so a project can't accumulate unbounded frames
const MAX_TOTAL_BYTES = 256 << 20; // 256 MiB of encoded frames — bounds assembly memory
let session;
// ---- helpers ------------------------------------------------------------
const clampInt = (n, lo, hi, dflt) => {
const v = Math.round(Number(n));
return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt;
};
const clampDim = (n) => clampInt(n, 1, 2048, 256);
const clampLoops = (n) => clampInt(n, 0, 65535, 0);
const clampDen = (n) => clampInt(n, 1, 65535, 1000);
const clampDispose = (n, dflt = 0) => clampInt(n, 0, 2, dflt);
const clampBlend = (n, dflt = 0) => clampInt(n, 0, 1, dflt);
const frameMs = (f) => Math.round((f.delayNum / f.delayDen) * 1000);
const usedBytes = (meta) => meta.frames.reduce((a, f) => a + (f.bytes || 0), 0);
// Normalize a stored/incoming frame record to the full field set, migrating the
// legacy { id, delayMs } shape to explicit delay numerator/denominator plus the
// per-frame compositing ops.
function normalizeFrame(f) {
const num = f.delayNum != null ? f.delayNum : f.delayMs;
return {
id: String(f.id),
delayNum: clampInt(num, 0, 65535, 100),
delayDen: clampDen(f.delayDen),
disposeOp: clampDispose(f.disposeOp),
blendOp: clampBlend(f.blendOp),
bytes: Number.isFinite(f.bytes) && f.bytes > 0 ? Math.floor(f.bytes) : 0,
};
}
// Map a project id to a filesystem-safe key. Ids that are already safe (including
// the legacy "default" and GUID-style ids) are used verbatim, so directories stay
// stable across restarts/upgrades and re-sanitizing is a no-op. Only ids that
// aren't filesystem-safe get a hash suffix — keyed on the raw id — so two distinct
// unsafe ids ("foo/bar" vs "foo?bar") can never share a directory, lock, or entry.
const SAFE_ID = /^[\w.-]{1,64}$/;
const sanitizeId = (s) => {
const raw = String(s ?? "") || DEFAULT_PROJECT;
if (raw !== "." && raw !== ".." && SAFE_ID.test(raw)) return raw;
const prefix = raw.replace(/[^\w.-]+/g, "_").slice(0, 40) || "p";
return `${prefix}-${createHash("sha256").update(raw).digest("hex").slice(0, 12)}`;
};
// Frame ids are internal monotonic counters, so a lightweight path-safe cleaner is
// enough (and keeps frame filenames readable).
const sanitizeFrameId = (s) => String(s).replace(/[^\w.-]+/g, "_").slice(0, 64) || "0";
const sanitizeName = (s) => String(s || "animation").replace(/[^\w.-]+/g, "_").slice(0, 80) || "animation";
const ensureDir = (d) => fs.mkdir(d, { recursive: true });
function log(message, level = "info") {
try {
session?.log(message, { level });
} catch {
/* logging is best-effort */
}
}
// ---- project store (disk-backed, shared across instances) ---------------
const projects = new Map(); // projectId -> meta
const loadingProjects = new Map(); // projectId -> in-flight load Promise
const projectLocks = new Map(); // projectId -> tail of the mutation queue
const projectDir = (id) => join(ARTIFACTS_DIR, sanitizeId(id));
const framePath = (id, fid) => join(projectDir(id), `frame-${sanitizeFrameId(fid)}.png`);
// Serialize the full loadmutatesave cycle for a project so concurrent panels
// and agent actions cannot interleave (e.g. allocate the same counter value or
// persist stale snapshots out of order).
function withProjectLock(id, fn) {
const pid = sanitizeId(id);
const prev = projectLocks.get(pid) || Promise.resolve();
const next = prev.then(() => fn());
// Keep the chain going even if this task rejects; don't leak the rejection.
projectLocks.set(pid, next.then(() => {}, () => {}));
return next;
}
async function loadProject(id) {
const pid = sanitizeId(id);
if (projects.has(pid)) return projects.get(pid);
// Dedupe concurrent first-time loads so every caller shares one meta object.
if (loadingProjects.has(pid)) return loadingProjects.get(pid);
const p = (async () => {
let meta = null;
try {
meta = JSON.parse(await fs.readFile(join(projectDir(pid), "project.json"), "utf8"));
} catch (err) {
// Only a missing file means "new project". Any other read/parse failure
// (I/O error, corrupt JSON) must surface rather than masquerade as an
// empty project, or the next save would overwrite real data and leave
// the frame files orphaned.
if (!err || err.code !== "ENOENT") {
throw new CanvasError("project_unreadable", `Could not read project "${pid}": ${err?.message || err}`);
}
}
if (!meta || typeof meta !== "object") {
meta = { id: pid, name: pid, width: 256, height: 256, loops: 0, hiddenFirst: false, counter: 0, frames: [] };
}
meta.id = pid;
meta.frames = (Array.isArray(meta.frames) ? meta.frames : []).map(normalizeFrame);
// Backfill encoded sizes for frames persisted before byte-tracking so the
// aggregate budget reflects real disk usage after an upgrade.
for (const f of meta.frames) {
if (!f.bytes) {
try {
f.bytes = (await fs.stat(framePath(pid, f.id))).size;
} catch {
/* frame file gone: leave 0 */
}
}
}
meta.counter = Number.isFinite(meta.counter) ? meta.counter : meta.frames.length;
meta.width = clampDim(meta.width);
meta.height = clampDim(meta.height);
meta.loops = clampLoops(meta.loops);
meta.hiddenFirst = !!meta.hiddenFirst;
projects.set(pid, meta);
return meta;
})();
loadingProjects.set(pid, p);
try {
return await p;
} finally {
loadingProjects.delete(pid);
}
}
async function saveProject(meta) {
const dir = projectDir(meta.id);
const target = join(dir, "project.json");
const tmp = join(dir, `.project.${randomBytes(6).toString("hex")}.tmp`);
try {
await ensureDir(dir);
// Write to a temp file then rename, so an interrupted write can never leave
// a truncated project.json for the next load to misread as empty.
await fs.writeFile(tmp, JSON.stringify(meta, null, 2));
await fs.rename(tmp, target);
} catch (err) {
// A failed persist must not leave the in-memory cache diverged from disk:
// evict it so the next access reloads authoritative state instead of the
// unsaved mutation, and remove any leftover temp file.
projects.delete(sanitizeId(meta.id));
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
function publicState(meta) {
return {
id: meta.id,
name: meta.name,
width: meta.width,
height: meta.height,
loops: meta.loops,
hiddenFirst: meta.hiddenFirst,
frames: meta.frames.map((f) => ({
id: f.id,
delayNum: f.delayNum,
delayDen: f.delayDen,
delayMs: frameMs(f),
disposeOp: f.disposeOp,
blendOp: f.blendOp,
})),
};
}
// ---- mutations ----------------------------------------------------------
// Validate a PNG buffer and return its dimensions. Walks the chunk stream to
// confirm it is structurally a PNG (IHDR first with length 13 and non-zero
// dimensions, at least one IDAT, and IEND) so a malformed upload can't be
// stored and then blow up APNG assembly later.
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
function pngSize(buffer) {
const bad = () => new CanvasError("bad_frame", "Frame is not a valid PNG image.");
// The internal encoder returns plain Uint8Arrays while HTTP uploads arrive as
// Buffers; view any Uint8Array as a Buffer (no copy) so both paths validate.
if (buffer instanceof Uint8Array && !Buffer.isBuffer(buffer)) {
buffer = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.length);
}
if (!Buffer.isBuffer(buffer) || buffer.length < 8) throw bad();
for (let i = 0; i < 8; i++) {
if (buffer[i] !== PNG_SIGNATURE[i]) throw bad();
}
let off = 8;
let width = 0;
let height = 0;
let sawIHDR = false;
let sawIDAT = false;
let sawIEND = false;
while (off + 8 <= buffer.length) {
const len = buffer.readUInt32BE(off);
const type = buffer.toString("latin1", off + 4, off + 8);
if (off + 12 + len > buffer.length) throw bad(); // length + type + data + CRC
if (!sawIHDR) {
if (type !== "IHDR" || len !== 13) throw bad();
width = buffer.readUInt32BE(off + 8);
height = buffer.readUInt32BE(off + 8 + 4);
const bitDepth = buffer[off + 8 + 8];
const colorType = buffer[off + 8 + 9];
const compression = buffer[off + 8 + 10];
const filterMethod = buffer[off + 8 + 11];
const interlace = buffer[off + 8 + 12];
// The codec only handles 8-bit truecolor-with-alpha, non-interlaced
// PNGs with the standard compression/filter methods (which the
// renderer and the RGBA encoder always produce). Reject anything
// else rather than store a frame assembleApng would mis-encode.
if (bitDepth !== 8 || colorType !== 6 || compression !== 0 || filterMethod !== 0 || interlace !== 0) {
throw new CanvasError(
"bad_frame",
"Frame must be an 8-bit RGBA (non-interlaced) PNG."
);
}
sawIHDR = true;
} else if (type === "IDAT") {
sawIDAT = true;
} else if (type === "IEND") {
sawIEND = true;
break;
}
off += 12 + len;
}
if (!sawIHDR || !sawIDAT || !sawIEND || width < 1 || height < 1) throw bad();
return { width, height };
}
// Lockless core: assumes the caller holds the project lock and passes the loaded
// meta. Writes the frame file and appends the record (does not save/broadcast).
async function addFrameToMeta(meta, buffer, opts = {}) {
if (meta.frames.length >= MAX_FRAMES) {
throw new CanvasError("too_many_frames", `An animation can have at most ${MAX_FRAMES} frames.`);
}
if (usedBytes(meta) + buffer.length > MAX_TOTAL_BYTES) {
throw new CanvasError("project_too_large", `Frames would exceed the ${MAX_TOTAL_BYTES >> 20} MiB total limit.`);
}
// Resolve (and validate) timing before writing anything so a rejected timing
// combination can't leave an orphaned frame file / advanced counter behind.
const timing = resolveTiming(opts, null) || { delayNum: 120, delayDen: 1000 };
const { width, height } = pngSize(buffer);
if (width > 2048 || height > 2048) {
throw new CanvasError("frame_too_large", `Frame is ${width}×${height}; the maximum is 2048×2048.`);
}
if (meta.frames.length > 0 && (width !== meta.width || height !== meta.height)) {
throw new CanvasError(
"size_mismatch",
`Frame is ${width}×${height}, but the animation is ${meta.width}×${meta.height}. All frames must share dimensions.`
);
}
// Write the file BEFORE mutating meta, so a failed write leaves the cached
// project untouched (nothing persisted, nothing to evict, counter intact).
const fid = String(meta.counter);
await ensureDir(projectDir(meta.id));
await fs.writeFile(framePath(meta.id, fid), buffer);
if (meta.frames.length === 0) {
// The first frame defines the canvas size; store the real dimensions so
// metadata and the on-disk PNG always agree.
meta.width = width;
meta.height = height;
}
meta.counter++;
meta.frames.push(
normalizeFrame({
id: fid,
delayNum: timing.delayNum,
delayDen: timing.delayDen,
disposeOp: opts.disposeOp,
blendOp: opts.blendOp,
bytes: buffer.length,
})
);
return fid;
}
async function addFrameBuffer(id, buffer, opts = {}) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const fid = await addFrameToMeta(meta, buffer, opts);
await saveProject(meta);
broadcast(meta.id);
return fid;
});
}
async function moveFrame(id, fid, delta) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const i = meta.frames.findIndex((f) => f.id === String(fid));
const step = Math.sign(Number(delta));
if (!Number.isFinite(step) || step === 0) return;
const j = i + step;
if (i < 0 || j < 0 || j >= meta.frames.length) return;
[meta.frames[i], meta.frames[j]] = [meta.frames[j], meta.frames[i]];
await saveProject(meta);
broadcast(meta.id);
});
}
async function deleteFrame(id, fid) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const i = meta.frames.findIndex((f) => f.id === String(fid));
if (i < 0) return;
meta.frames.splice(i, 1);
// Persist the removal before deleting the file (as clearFrames does) so an
// interrupted delete can't leave project.json referencing a missing PNG.
await saveProject(meta);
broadcast(meta.id);
await fs.rm(framePath(meta.id, fid), { force: true });
});
}
async function duplicateFrame(id, fid) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const i = meta.frames.findIndex((f) => f.id === String(fid));
if (i < 0) return;
if (meta.frames.length >= MAX_FRAMES) {
throw new CanvasError("too_many_frames", `An animation can have at most ${MAX_FRAMES} frames.`);
}
const src = meta.frames[i];
const srcBytes = src.bytes || (await fs.stat(framePath(meta.id, fid))).size;
if (usedBytes(meta) + srcBytes > MAX_TOTAL_BYTES) {
throw new CanvasError("project_too_large", `Frames would exceed the ${MAX_TOTAL_BYTES >> 20} MiB total limit.`);
}
const nid = String(meta.counter);
// Copy the file before advancing the counter / inserting the record, so a
// failed copy leaves the cached project unchanged.
await fs.copyFile(framePath(meta.id, fid), framePath(meta.id, nid));
meta.counter++;
meta.frames.splice(i + 1, 0, normalizeFrame({ ...src, id: nid, bytes: srcBytes }));
await saveProject(meta);
broadcast(meta.id);
});
}
// Frame timing may be given exactly one way: fps, delayMs, or delayNum/delayDen.
// Combining modes (e.g. delayMs with delayDen, or fps with delayNum) used to be
// applied field-by-field and silently produced hybrid delays, so a mixed request
// is rejected here; the chosen mode resolves omitted parts against `base`.
function resolveTiming(props, base) {
const hasFps = props.fps != null;
const hasMs = props.delayMs != null;
const hasFrac = props.delayNum != null || props.delayDen != null;
if ([hasFps, hasMs, hasFrac].filter(Boolean).length > 1) {
throw new CanvasError(
"timing_conflict",
"Set frame timing one way only: delayMs, or fps, or delayNum/delayDen — not a combination."
);
}
if (hasFps) return { delayNum: 1, delayDen: clampInt(props.fps, 1, 65535, base?.delayDen ?? 1000) };
if (hasMs) return { delayNum: clampInt(props.delayMs, 0, 65535, base?.delayNum ?? 120), delayDen: 1000 };
if (hasFrac) {
return {
delayNum: props.delayNum != null ? clampInt(props.delayNum, 0, 65535, base?.delayNum ?? 120) : base?.delayNum ?? 120,
delayDen: props.delayDen != null ? clampDen(props.delayDen) : base?.delayDen ?? 1000,
};
}
return null;
}
// Apply a partial set of frame properties. Timing (if any) is resolved as a single
// mutually-exclusive mode; only provided fields change.
function applyFrameProps(f, props) {
const t = resolveTiming(props, f);
if (t) {
f.delayNum = t.delayNum;
f.delayDen = t.delayDen;
}
if (props.disposeOp != null) f.disposeOp = clampDispose(props.disposeOp, f.disposeOp);
if (props.blendOp != null) f.blendOp = clampBlend(props.blendOp, f.blendOp);
}
async function setFrameProps(id, fid, props) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const f = meta.frames.find((x) => x.id === String(fid));
if (!f) throw new CanvasError("frame_not_found", `No frame with id "${fid}".`);
applyFrameProps(f, props || {});
await saveProject(meta);
broadcast(meta.id);
});
}
async function setFramePropsAll(id, props) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
for (const f of meta.frames) applyFrameProps(f, props || {});
await saveProject(meta);
broadcast(meta.id);
});
}
async function clearFrames(id) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
// Clear the in-memory list and persist it before deleting files, so a
// concurrent reader never sees a frame id whose PNG is already gone.
const ids = meta.frames.map((f) => f.id);
meta.frames = [];
await saveProject(meta);
broadcast(meta.id);
await Promise.all(ids.map((fid) => fs.rm(framePath(meta.id, fid), { force: true })));
});
}
async function applySettings(id, { width, height, loops, name, hiddenFirst }) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
if (meta.frames.length === 0) {
if (width != null) meta.width = clampDim(width);
if (height != null) meta.height = clampDim(height);
}
if (loops != null) meta.loops = clampLoops(loops);
if (typeof hiddenFirst === "boolean") meta.hiddenFirst = hiddenFirst;
if (typeof name === "string" && name.trim()) meta.name = name.trim().slice(0, 80);
await saveProject(meta);
broadcast(meta.id);
return meta;
});
}
// Assemble the APNG from a loaded project. Assumes the caller holds the project
// lock so frame files can't be deleted mid-read.
async function assembleFromMeta(meta) {
if (meta.frames.length === 0) return null;
const frames = [];
for (const f of meta.frames) {
frames.push({
png: await fs.readFile(framePath(meta.id, f.id)),
delayNum: f.delayNum,
delayDen: f.delayDen,
disposeOp: f.disposeOp,
blendOp: f.blendOp,
});
}
return assembleApng(frames, { loops: meta.loops, hiddenFirst: meta.hiddenFirst });
}
// Serialize assembly with mutations so a concurrent clear/delete can't remove a
// frame file while it is being read (which would otherwise 500 a preview,
// phone request, or export).
async function assemble(id) {
return withProjectLock(id, async () => assembleFromMeta(await loadProject(id)));
}
async function exportApng(id, filename) {
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const bytes = await assembleFromMeta(meta);
if (!bytes) throw new CanvasError("no_frames", "Nothing to export — add at least one frame first.");
await ensureDir(EXPORTS_DIR);
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23);
// Generated names include milliseconds + a random suffix so two exports in
// the same second don't collide and silently overwrite each other; an
// explicit caller filename keeps its (intentional) overwrite behavior.
const base = filename
? sanitizeName(filename.replace(/\.(a?png)$/i, ""))
: `${sanitizeName(meta.name)}-${stamp}-${randomBytes(3).toString("hex")}`;
const outPath = join(EXPORTS_DIR, `${base}.png`);
await fs.writeFile(outPath, bytes);
return { path: outPath, name: `${base}.png`, bytes: bytes.length };
});
}
// ---- colors (for agent-generated frames) --------------------------------
const NAMED_COLORS = {
black: "000000", white: "ffffff", red: "ff0000", green: "00c853", lime: "00ff00",
blue: "2962ff", yellow: "ffeb3b", cyan: "00e5ff", magenta: "ff00ff", orange: "ff9100",
purple: "9c27b0", pink: "ff4081", gray: "808080", grey: "808080", teal: "009688",
transparent: "00000000",
};
function parseColor(input) {
if (input && typeof input === "object") {
const c = (v) => clampInt(v, 0, 255, 0);
return { r: c(input.r), g: c(input.g), b: c(input.b), a: input.a == null ? 255 : c(input.a) };
}
let s = String(input ?? "").trim().toLowerCase();
if (NAMED_COLORS[s]) s = NAMED_COLORS[s];
let hex = s.replace(/^#/, "");
if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/.test(hex)) {
throw new CanvasError("bad_color", `Invalid color: ${input}. Use a hex value (#ff8800) or a name like "blue".`);
}
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255,
};
}
// ---- HTTP server + SSE --------------------------------------------------
const servers = new Map(); // instanceId -> { instanceId, server, url, projectId, token, sse:Set<res> }
// Resolve which project an action targets: an explicit projectId wins, else the
// project bound to the invoking canvas instance, else the default project.
function resolveProjectId(ctx) {
if (ctx?.input?.projectId) return sanitizeId(ctx.input.projectId);
const entry = servers.get(ctx?.instanceId);
if (entry) return entry.projectId;
return DEFAULT_PROJECT;
}
// Constant-time compare for the per-server access token.
function tokenMatches(provided, expected) {
if (typeof provided !== "string" || provided.length !== expected.length) return false;
try {
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
} catch {
return false;
}
}
// Push a "changed" event to every open panel of a project (across instances).
function broadcast(projectId) {
const pid = sanitizeId(projectId);
for (const entry of servers.values()) {
if (entry.projectId !== pid) continue;
for (const res of entry.sse) {
// Drop responses that have already ended/reset rather than writing
// to a dead stream.
if (res.writableEnded || res.destroyed) {
entry.sse.delete(res);
continue;
}
try {
res.write(`data: changed\n\n`);
} catch {
entry.sse.delete(res);
}
}
}
}
const CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
};
function send(res, status, type, body, extraHeaders) {
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", ...(extraHeaders || {}) });
res.end(body);
}
// Error carrying an explicit HTTP status (e.g. 400 bad JSON, 413 too large).
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const MAX_JSON_BYTES = 1 << 20; // 1 MiB — mutation payloads are tiny
const MAX_UPLOAD_BYTES = 40 << 20; // 40 MiB — a single decoded frame PNG
function readBody(req, maxBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let over = false;
req.on("data", (c) => {
if (over) return; // past the limit: drain without buffering
total += c.length;
if (total > maxBytes) {
over = true;
reject(new HttpError(413, "Request body too large."));
return;
}
chunks.push(c);
});
req.on("end", () => {
if (!over) resolve(Buffer.concat(chunks));
});
req.on("error", reject);
});
}
async function readJson(req) {
const buf = await readBody(req, MAX_JSON_BYTES);
if (!buf.length) return {};
try {
return JSON.parse(buf.toString("utf8"));
} catch {
throw new HttpError(400, "Invalid JSON body.");
}
}
async function handleRequest(entry, req, res) {
const projectId = entry.projectId;
const url = new URL(req.url, "http://localhost");
const path = url.pathname;
const method = req.method || "GET";
try {
// Static renderer assets are public (they carry no project data). The
// iframe is loaded with the token in its URL; app.js then reads it and
// attaches it to every data request below.
if (method === "GET" && (path === "/" || path === "/index.html")) {
return send(res, 200, CONTENT_TYPES[".html"], await fs.readFile(join(WEB_DIR, "index.html")));
}
if (method === "GET" && (path === "/app.js" || path === "/styles.css")) {
const file = path.slice(1);
return send(res, 200, CONTENT_TYPES[extname(file)] || "text/plain", await fs.readFile(join(WEB_DIR, file)));
}
if (path === "/favicon.ico") return send(res, 204, "text/plain", "");
// Everything below reads or mutates project data. Require the per-server
// token so another local process or a cross-origin page that guesses the
// port cannot read state or drive mutations (e.g. /frames/clear).
if (!tokenMatches(url.searchParams.get("k") || "", entry.token)) {
return send(res, 403, "text/plain", "Forbidden");
}
// State.
if (method === "GET" && path === "/state") {
const meta = await loadProject(projectId);
return send(res, 200, "application/json", JSON.stringify(publicState(meta)));
}
// Server-Sent Events. Track the client on this instance so its canvas
// can end just its own streams on close without disturbing other panels.
if (method === "GET" && path === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store",
Connection: "keep-alive",
});
res.write(": connected\n\n");
entry.sse.add(res);
const drop = () => entry.sse.delete(res);
req.on("close", drop);
res.on("close", drop);
res.on("error", drop);
return;
}
// A single frame PNG (for thumbnails / onion skin / draw base).
if (method === "GET" && path === "/frame") {
const fid = url.searchParams.get("id");
try {
const buf = await fs.readFile(framePath(projectId, fid));
return send(res, 200, "image/png", buf);
} catch {
return send(res, 404, "text/plain", "frame not found");
}
}
// Live-assembled APNG preview (served as image/png — APNG is byte-compatible
// with PNG, so browsers animate it and default viewers still open it).
if (method === "GET" && path === "/preview.png") {
const bytes = await assemble(projectId);
if (!bytes) return send(res, 204, "image/png", "");
return send(res, 200, "image/png", Buffer.from(bytes));
}
// Add a frame (raw PNG body).
if (method === "POST" && path === "/frames") {
const buf = await readBody(req, MAX_UPLOAD_BYTES);
if (!buf.length) return send(res, 400, "text/plain", "empty body");
const delayMs = url.searchParams.get("delayMs");
const id = await addFrameBuffer(projectId, buf, { delayMs });
return send(res, 200, "application/json", JSON.stringify({ id }));
}
// JSON mutation endpoints.
if (method === "POST") {
const body = await readJson(req);
switch (path) {
case "/frames/move":
await moveFrame(projectId, body.id, body.delta);
return send(res, 200, "application/json", "{}");
case "/frames/delete":
await deleteFrame(projectId, body.id);
return send(res, 200, "application/json", "{}");
case "/frames/duplicate":
await duplicateFrame(projectId, body.id);
return send(res, 200, "application/json", "{}");
case "/frames/props":
await setFrameProps(projectId, body.id, body);
return send(res, 200, "application/json", "{}");
case "/frames/props-all":
await setFramePropsAll(projectId, body);
return send(res, 200, "application/json", "{}");
case "/frames/clear":
await clearFrames(projectId);
return send(res, 200, "application/json", "{}");
case "/settings":
await applySettings(projectId, body);
return send(res, 200, "application/json", "{}");
case "/export": {
const out = await exportApng(projectId, body.filename);
log(`APNG exported: ${out.path}`);
return send(res, 200, "application/json", JSON.stringify(out));
}
}
}
// ---- "Send to phone" control plane (loopback only) --------------
if (method === "POST" && path === "/share/start") {
try {
const info = await startShare(projectId);
return send(res, 200, "application/json", JSON.stringify(info));
} catch (err) {
const status = err instanceof CanvasError ? 400 : 500;
return send(res, status, "text/plain", err.message || "Could not start sharing.");
}
}
if (method === "POST" && path === "/share/stop") {
stopShare(projectId);
return send(res, 200, "application/json", "{}");
}
if (method === "GET" && path === "/share/qr.png") {
const s = shares.get(projectId);
if (!s || Date.now() > s.expiresAt) {
return send(res, 409, "text/plain", "no active share");
}
return send(res, 200, "image/png", Buffer.from(renderQrPng(shareUrlFor(projectId))));
}
return send(res, 404, "text/plain", "not found");
} catch (err) {
if (err instanceof HttpError) return send(res, err.status, "text/plain", err.message);
// CanvasError is a user-facing validation error (bad frame, size
// mismatch, nothing to export), not a server fault.
if (err instanceof CanvasError) return send(res, 400, "text/plain", err.message);
return send(res, 500, "text/plain", String(err && err.message ? err.message : err));
}
}
async function startServer(entry) {
entry.token = randomBytes(16).toString("hex");
entry.sse = new Set();
const server = createServer((req, res) => handleRequest(entry, req, res));
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve();
});
});
const { port } = server.address();
entry.server = server;
// The token travels in the iframe URL; app.js reads it and attaches it to
// every data request so other local origins cannot reach project data.
entry.url = `http://127.0.0.1:${port}/?k=${entry.token}`;
return entry;
}
// ---- "Send to phone" share server ---------------------------------------
// A separate, read-only HTTP server bound to the LAN so a phone can fetch the
// live animation. It exposes ONLY a landing page and the preview image, gated
// by a short-lived random token, and it shuts itself down when the token
// expires. Mutation endpoints stay on the loopback server and are never
// reachable from the network.
const SHARE_TTL_MS = 10 * 60 * 1000;
let shareServer = null; // single LAN-bound HTTP server, created on demand
let shareServerStarting = null; // in-flight startup promise (dedupe concurrent starts)
let shareServerBindIp = null; // the private IPv4 the server is actually bound to
const shares = new Map(); // projectId -> { token, expiresAt, timer }
// Best-guess private (RFC1918) LAN IPv4 to bind the share server to, or null.
// Interfaces that are typically virtual (VPN/VM/container: utun, ipsec, tun/tap,
// bridge, vmnet, docker, veth, wg, awdl…) are skipped, and real NICs (en*, eth*,
// wl*) on common home/office ranges are preferred. This is a heuristic — on an
// unusual multi-homed host it can still pick the wrong interface.
function lanIPv4() {
const isPrivate = (ip) =>
/^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
const virtual = /^(utun|ipsec|ppp|tun|tap|awdl|llw|bridge|vmnet|vboxnet|docker|veth|wg|zt)/i;
const physical = /^(en|eth|wl|wlan)/i;
const cands = [];
for (const [name, addrs] of Object.entries(networkInterfaces())) {
for (const a of addrs || []) {
if (a.family !== "IPv4" || a.internal) continue;
if (!isPrivate(a.address)) continue;
if (virtual.test(name)) continue;
let score = 0;
if (physical.test(name)) score -= 100;
if (/^192\.168\./.test(a.address)) score -= 10;
else if (/^10\./.test(a.address)) score -= 5; // 172.16/12 is often Docker; least preferred
cands.push({ ip: a.address, score });
}
}
cands.sort((a, b) => a.score - b.score);
return cands.length ? cands[0].ip : null;
}
function shareUrlFor(projectId) {
const s = shares.get(projectId);
const { port } = shareServer.address();
// Use the address the server is actually bound to, not a freshly resolved
// one, so the link always points where the listener is really accepting.
return `http://${shareServerBindIp}:${port}/s?t=${s.token}`;
}
// Resolve an active share from its token (constant-time compare), so tokens from
// one project's panel can never address another project's share.
function shareForToken(token) {
if (typeof token !== "string" || !token) return null;
const tokenBuf = Buffer.from(token);
for (const [projectId, s] of shares) {
if (s.token.length !== token.length) continue;
let ok = false;
try {
ok = timingSafeEqual(tokenBuf, Buffer.from(s.token));
} catch {
ok = false;
}
if (ok) return { projectId, share: s };
}
return null;
}
function shareLandingHtml(token) {
const src = `/s/preview.png?t=${encodeURIComponent(token)}`;
// Self-contained page: no external assets, checkerboard behind the image.
return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APNG Studio</title>
<style>
:root { color-scheme: light dark; }
body { margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:#0d1117; color:#e6edf3; padding:24px; box-sizing:border-box; }
h1 { font-size:17px; font-weight:600; margin:0; }
.frame { padding:14px; border-radius:12px;
background-color:#fff;
background-image:linear-gradient(45deg,#d9dbe0 25%,transparent 25%),linear-gradient(-45deg,#d9dbe0 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#d9dbe0 75%),linear-gradient(-45deg,transparent 75%,#d9dbe0 75%);
background-size:16px 16px; background-position:0 0,0 8px,8px -8px,-8px 0; }
img { display:block; max-width:min(88vw,480px); height:auto; image-rendering:auto; }
a { color:#4493f8; font-size:14px; text-decoration:none; }
p { margin:0; font-size:13px; opacity:.7; }
</style></head><body>
<h1>APNG Studio</h1>
<div class="frame"><img src="${src}" alt="Animated PNG preview" /></div>
<a href="${src}" download="animation.png">Save to your device</a>
<p>Tap and hold the image to save it. This link expires shortly.</p>
</body></html>`;
}
async function shareRequest(req, res) {
try {
const url = new URL(req.url, "http://localhost");
const token = url.searchParams.get("t") || "";
const match = shareForToken(token);
if (!match) return send(res, 403, "text/plain", "Invalid or expired link.");
if (Date.now() > match.share.expiresAt) {
stopShare(match.projectId);
return send(res, 410, "text/plain", "This link has expired.");
}
if (req.method === "GET" && (url.pathname === "/s" || url.pathname === "/s/")) {
return send(res, 200, CONTENT_TYPES[".html"], shareLandingHtml(token));
}
if (req.method === "GET" && url.pathname === "/s/preview.png") {
const bytes = await assemble(match.projectId);
if (!bytes) return send(res, 204, "image/png", "");
return send(res, 200, "image/png", Buffer.from(bytes));
}
return send(res, 404, "text/plain", "not found");
} catch (err) {
if (err instanceof HttpError) return send(res, err.status, "text/plain", err.message);
return send(res, 500, "text/plain", String(err && err.message ? err.message : err));
}
}
// Start (or reuse) the single LAN share server. Concurrent callers share one
// in-flight startup promise so two /share/start requests can't each bind a
// separate listener and leak one.
async function ensureShareServer(bindIp) {
if (shareServer) {
// Reuse the running server, unless the LAN address changed and nothing
// is currently being shared — then rebind to the new private address.
if (shareServerBindIp === bindIp || shares.size > 0) return shareServer;
const old = shareServer;
shareServer = null;
shareServerStarting = null;
shareServerBindIp = null;
try {
old.close();
} catch {
/* already closing */
}
}
if (!shareServerStarting) {
shareServerStarting = (async () => {
const server = createServer(shareRequest);
await new Promise((resolve, reject) => {
server.once("error", reject);
// Bind only to the private LAN address, not 0.0.0.0, so the
// listener is never exposed on public/VPN interfaces.
server.listen(0, bindIp, resolve);
});
shareServer = server;
shareServerBindIp = bindIp;
return server;
})().catch((err) => {
shareServerStarting = null;
throw err;
});
}
return shareServerStarting;
}
async function startShare(projectId) {
const ip = lanIPv4();
if (!ip) {
throw new CanvasError(
"no_network",
"No local Wi-Fi/LAN address found. Connect to a local network to share to your phone."
);
}
await ensureShareServer(ip);
// The canvas may have closed while the server was binding. If no open panel
// still references this project, don't leave a share (or an idle LAN server)
// behind.
const stillOpen = [...servers.values()].some((e) => e.projectId === sanitizeId(projectId));
if (!stillOpen) {
if (shares.size === 0 && shareServer) {
const server = shareServer;
shareServer = null;
shareServerStarting = null;
shareServerBindIp = null;
try {
server.close();
} catch {
/* already closing */
}
}
throw new CanvasError("canvas_closed", "The canvas was closed before sharing started.");
}
const existing = shares.get(projectId);
if (existing?.timer) clearTimeout(existing.timer);
const token = randomBytes(16).toString("hex");
const expiresAt = Date.now() + SHARE_TTL_MS;
const timer = setTimeout(() => stopShare(projectId), SHARE_TTL_MS);
timer.unref?.();
shares.set(projectId, { token, expiresAt, timer });
return { url: shareUrlFor(projectId), expiresAt, ttlMs: SHARE_TTL_MS };
}
function stopShare(projectId) {
const s = shares.get(projectId);
if (!s) return;
if (s.timer) clearTimeout(s.timer);
shares.delete(projectId);
// Close the shared LAN server once nothing is being shared.
if (shares.size === 0 && shareServer) {
const server = shareServer;
shareServer = null;
shareServerStarting = null;
shareServerBindIp = null;
try {
server.close();
} catch {
/* already closing */
}
}
}
// Render a QR matrix into a scannable PNG using the RGBA->PNG encoder.
function renderQrPng(text, scale = 8, quiet = 4) {
const { matrix, size } = encodeQr(text);
const dim = (size + quiet * 2) * scale;
const rgba = new Uint8Array(dim * dim * 4).fill(255);
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (!matrix[r][c]) continue;
for (let y = 0; y < scale; y++) {
for (let x = 0; x < scale; x++) {
const o = (((r + quiet) * scale + y) * dim + ((c + quiet) * scale + x)) * 4;
rgba[o] = 0;
rgba[o + 1] = 0;
rgba[o + 2] = 0;
rgba[o + 3] = 255;
}
}
}
}
return encodeRgbaPng(dim, dim, rgba);
}
// ---- canvas declaration -------------------------------------------------
const openInputSchema = {
type: "object",
properties: {
projectId: { type: "string", description: "Identifier for the animation project (defaults to 'default')." },
name: { type: "string", description: "Optional display name for the animation." },
},
additionalProperties: false,
};
session = await joinSession({
canvases: [
createCanvas({
id: "apng-studio",
displayName: "APNG Studio",
description:
"Build an Animated PNG (APNG) from frames: upload or draw frames, set per-frame delays and loop count, preview live, and export an animated .png file.",
inputSchema: openInputSchema,
actions: [
{
name: "get_state",
description: "Return the current project's dimensions, loop count, frame count and per-frame delays.",
inputSchema: {
type: "object",
properties: { projectId: { type: "string" } },
additionalProperties: false,
},
handler: async (ctx) => {
const meta = await loadProject(resolveProjectId(ctx));
// hiddenFirst only takes effect with >=2 frames (matches the
// encoder in apng.mjs), so a lone frame still counts as animated.
const hidden = meta.hiddenFirst && meta.frames.length >= 2;
const animated = hidden ? meta.frames.slice(1) : meta.frames;
// Sum exact numerator/denominator fractions, then convert
// once, so the total matches the encoded timing rather than
// accumulating per-frame rounding.
const totalMs = Math.round(
animated.reduce((a, f) => a + f.delayNum / f.delayDen, 0) * 1000
);
return {
...publicState(meta),
frameCount: meta.frames.length,
totalDurationMs: totalMs,
exportsDir: EXPORTS_DIR,
};
},
},
{
name: "set_settings",
description:
"Update project settings. width/height only apply when there are no frames yet. loops: 0 = infinite. hiddenFirst: make frame 1 a static fallback that is not part of the animation.",
inputSchema: {
type: "object",
properties: {
projectId: { type: "string" },
width: { type: "integer", minimum: 1, maximum: 2048 },
height: { type: "integer", minimum: 1, maximum: 2048 },
loops: { type: "integer", minimum: 0, maximum: 65535 },
hiddenFirst: { type: "boolean", description: "Frame 1 becomes a static, non-animated fallback image." },
name: { type: "string" },
},
additionalProperties: false,
},
handler: async (ctx) => {
const { projectId, ...settings } = ctx.input || {};
const meta = await applySettings(resolveProjectId(ctx), settings);
return publicState(meta);
},
},
{
name: "add_color_frame",
description:
"Append a solid-color frame at the project's dimensions. Useful for building simple animations programmatically. Color accepts a hex value (#ff8800) or a name like 'blue'.",
inputSchema: {
type: "object",
properties: {
projectId: { type: "string" },
color: { type: "string", description: "Hex (#rrggbb / #rrggbbaa) or a color name." },
delayMs: { type: "integer", minimum: 0, maximum: 65535, description: "Frame delay in ms. Use one timing mode only." },
delayNum: { type: "integer", minimum: 0, maximum: 65535, description: "Delay numerator; pair with delayDen. Use one timing mode only." },
delayDen: { type: "integer", minimum: 1, maximum: 65535, description: "Delay denominator (default 1000). Use one timing mode only." },
disposeOp: { type: "integer", minimum: 0, maximum: 2, description: "0=None, 1=Background, 2=Previous." },
blendOp: { type: "integer", minimum: 0, maximum: 1, description: "0=Source, 1=Over." },
},
required: ["color"],
additionalProperties: false,
},
handler: async (ctx) => {
const id = resolveProjectId(ctx);
const color = parseColor(ctx.input?.color);
const { color: _c, projectId: _p, ...opts } = ctx.input || {};
if (opts.delayMs == null && opts.delayNum == null && opts.delayDen == null && opts.fps == null) opts.delayMs = 120;
// Read dimensions, render, and append under one lock so a
// concurrent set_settings can't change the size between
// rendering the PNG and recording the frame.
return withProjectLock(id, async () => {
const meta = await loadProject(id);
const png = solidColorPng(meta.width, meta.height, color);
const fid = await addFrameToMeta(meta, png, opts);
await saveProject(meta);
broadcast(meta.id);
return { frameId: fid, frameCount: meta.frames.length };
});
},
},
{
name: "set_frame",
description:
"Change timing/compositing for one frame (by frameId) or every frame (all: true). Timing (choose exactly one mode): delayMs, or fps (exact frame rate), or delayNum/delayDen — combining modes is rejected. Compositing: disposeOp (0=None,1=Background,2=Previous), blendOp (0=Source,1=Over).",
inputSchema: {
type: "object",
properties: {
projectId: { type: "string" },
frameId: { type: "string", description: "Target frame id. Omit and set all:true to apply to every frame." },
all: { type: "boolean", description: "Apply to all frames instead of a single frameId." },
delayMs: { type: "integer", minimum: 0, maximum: 65535 },
fps: { type: "integer", minimum: 1, maximum: 1000, description: "Exact frame rate; sets delay to 1/fps s." },
delayNum: { type: "integer", minimum: 0, maximum: 65535 },
delayDen: { type: "integer", minimum: 1, maximum: 65535 },
disposeOp: { type: "integer", minimum: 0, maximum: 2 },
blendOp: { type: "integer", minimum: 0, maximum: 1 },
},
additionalProperties: false,
},
handler: async (ctx) => {
const id = resolveProjectId(ctx);
const { projectId: _p, frameId, all, ...props } = ctx.input || {};
if (all) {
await setFramePropsAll(id, props);
} else if (frameId != null) {
await setFrameProps(id, frameId, props);
} else {
throw new CanvasError("no_target", "Provide a frameId, or set all:true to apply to every frame.");
}
return publicState(await loadProject(id));
},
},
{
name: "clear_frames",
description: "Remove all frames from the project.",
inputSchema: {
type: "object",
properties: { projectId: { type: "string" } },
additionalProperties: false,
},
handler: async (ctx) => {
await clearFrames(resolveProjectId(ctx));
return { ok: true };
},
},
{
name: "export",
description: "Assemble the frames into an animated .png (APNG) file on disk and return its absolute path.",
inputSchema: {
type: "object",
properties: {
projectId: { type: "string" },
filename: { type: "string", description: "Optional output filename (without directory)." },
},
additionalProperties: false,
},
handler: async (ctx) => {
return exportApng(resolveProjectId(ctx), ctx.input?.filename);
},
},
],
open: async (ctx) => {
const projectId = sanitizeId(ctx.input?.projectId ?? DEFAULT_PROJECT);
let meta = await loadProject(projectId);
if (ctx.input?.name && typeof ctx.input.name === "string") {
// Route the rename through the locked mutation path so it
// can't race a concurrent save or skip the panel broadcast.
meta = await applySettings(projectId, { name: ctx.input.name });
}
let entry = servers.get(ctx.instanceId);
if (!entry) {
entry = { instanceId: ctx.instanceId, projectId, sse: new Set() };
servers.set(ctx.instanceId, entry);
try {
await startServer(entry);
} catch (err) {
// Don't leave a half-open instance behind: a later open would
// skip startup and return an undefined URL, and onClose would
// dereference a missing server. Drop it so it can retry.
servers.delete(ctx.instanceId);
throw err;
}
} else {
// Re-open: repoint the existing server at the requested
// project in place so the loopback URL stays stable.
entry.projectId = projectId;
}
return {
title: `APNG Studio — ${meta.name}`,
url: entry.url,
status: `${meta.frames.length} frame${meta.frames.length === 1 ? "" : "s"}`,
};
},
onClose: async (ctx) => {
const entry = servers.get(ctx.instanceId);
if (!entry) return;
servers.delete(ctx.instanceId);
// End this instance's SSE streams first, otherwise server.close()
// waits on the open /events response and never resolves.
for (const res of entry.sse) {
try {
res.end();
} catch {
/* already closed */
}
}
entry.sse.clear();
// Only stop this project's LAN share when no other open panel
// still references the project, so closing one of two panels
// doesn't invalidate the other's phone link.
const stillOpen = [...servers.values()].some((e) => e.projectId === entry.projectId);
if (!stillOpen) stopShare(entry.projectId);
if (entry.server) await new Promise((resolve) => entry.server.close(() => resolve()));
},
}),
],
});
await ensureDir(EXPORTS_DIR);
log("APNG Studio ready.");