diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index f8bd148df..5edf260f2 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -81,6 +81,12 @@
"description": "Bootstrap and run a multi-agent AI development team with named roles (Producer, Dev Team, QA). Sprint planning, brainstorm prompts with distinct agent voices, cross-chat context survival, and parallel team workflows. Based on a proven template that shipped a 30-game app in 5 days with zero human-written code.",
"version": "1.0.0"
},
+ {
+ "name": "apng-studio",
+ "source": "extensions/apng-studio",
+ "description": "Interactive GitHub Copilot app 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 your phone by QR, and export an animated .png.",
+ "version": "1.0.0"
+ },
{
"name": "arcade-canvas",
"source": "extensions/arcade-canvas",
diff --git a/extensions/apng-studio/.github/plugin/plugin.json b/extensions/apng-studio/.github/plugin/plugin.json
new file mode 100644
index 000000000..6c46943cb
--- /dev/null
+++ b/extensions/apng-studio/.github/plugin/plugin.json
@@ -0,0 +1,19 @@
+{
+ "name": "apng-studio",
+ "description": "Interactive GitHub Copilot app 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 your phone by QR, and export an animated .png.",
+ "version": "1.0.0",
+ "author": {
+ "name": "Andrea Griffiths",
+ "url": "https://github.com/AndreaGriffiths11"
+ },
+ "keywords": [
+ "animated-png",
+ "apng",
+ "copilot-canvas",
+ "frame-animation",
+ "image-export",
+ "interactive-canvas"
+ ],
+ "logo": "assets/preview.png",
+ "extensions": "."
+}
diff --git a/extensions/apng-studio/.gitignore b/extensions/apng-studio/.gitignore
new file mode 100644
index 000000000..5db7b3030
--- /dev/null
+++ b/extensions/apng-studio/.gitignore
@@ -0,0 +1,11 @@
+# Runtime user data — per-project frames and exports the extension writes
+# under its own directory at runtime. This is local state, not source.
+artifacts/
+
+# Exported animations
+*.apng
+
+# Node / editor / OS cruft
+node_modules/
+.DS_Store
+*.log
diff --git a/extensions/apng-studio/LICENSE b/extensions/apng-studio/LICENSE
new file mode 100644
index 000000000..da487c02b
--- /dev/null
+++ b/extensions/apng-studio/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 octobooth-1
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/extensions/apng-studio/README.md b/extensions/apng-studio/README.md
new file mode 100644
index 000000000..27a71767b
--- /dev/null
+++ b/extensions/apng-studio/README.md
@@ -0,0 +1,97 @@
+# APNG Studio
+
+An interactive [GitHub Copilot app](https://github.com/features/ai/github-app) **canvas extension** for building [Animated PNG (APNG)](https://wiki.mozilla.org/APNG_Specification) files from frames — draw or upload frames, tune the full practical APNG spec surface, preview live, and export an animated `.png`.
+
+The canvas renders in a side panel; the agent can also drive it through callable actions.
+
+
+
+
+
APNG Studio in action — an animated PNG built with the canvas itself.
+
+## Background
+
+APNG Studio started as a hallway conversation. During a demo shift at the WeAreDevelopers Congress I got talking with [Jeff](https://github.com/GekkeBoyJeff) about APNG (animated PNG) versus GIF: APNG keeps real alpha and full color where GIF can't. We wanted an easy way to actually build one, so we made this small canvas wrapper for creating APNGs.
+
+## Features
+
+- **Frames** — upload images or draw them on a built‑in canvas (pen/eraser, fill, onion‑skin, "start from last frame"). Reorder, duplicate, and delete frames.
+- **Per‑frame timing** — set the delay as an exact `numerator / denominator` fraction with a live `= N ms · N fps` readout.
+- **Per‑frame compositing** — `dispose_op` (None / Background / Previous) and `blend_op` (Source / Over) dropdowns, straight from the APNG spec.
+- **Apply to all** — set every frame's delay (ms), snap to an exact frame rate (fps), or apply dispose + blend in one click.
+- **Loop count** — `0` = infinite, or a fixed number of plays.
+- **Hidden first frame** — mark frame 1 as a static fallback: shown by non‑APNG viewers, excluded from the animation loop (encoded as a default image with no leading `fcTL`, `num_frames = N‑1`).
+- **Live preview** — a real animated PNG is assembled on every change and served from `/preview.png`; a **Reload** button re‑syncs state and rebuilds the preview.
+- **Send to phone** — a **Send to phone** button opens a QR code; scan it with your phone camera (same Wi‑Fi) to open the live animation in your phone's browser and save it. Served read‑only from a short‑lived, token‑gated LAN endpoint that shuts itself down after 10 minutes.
+- **Export** — writes a valid animated `.png` (APNG) to disk and returns its path. The `.png` extension keeps the file byte‑compatible with every PNG viewer: APNG‑aware ones (browsers, macOS Quick Look) animate it, others show the first frame as a static fallback.
+
+## Install
+
+### From GitHub Copilot (recommended)
+
+Ask Copilot to install the committed extension URL:
+
+```text
+Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/apng-studio
+```
+
+You can also copy the folder into one of these locations:
+
+- **User** — `~/.copilot/extensions/apng-studio/`, available in every project.
+- **Project** — `.github/extensions/apng-studio/` inside a repo, committed and shared with your team.
+
+Reload extensions in the app, then open the `apng-studio` canvas.
+
+### Manual
+
+Copy the source files into one of the extension directories above, keeping the layout:
+
+```
+apng-studio/
+├── extension.mjs # entry point (required name)
+├── apng.mjs # APNG codec + RGBA→PNG encoder
+├── qr.mjs # dependency-free QR encoder (Send to phone)
+└── web/ # canvas iframe renderer
+ ├── index.html
+ ├── app.js
+ └── styles.css
+```
+
+Then reload extensions. The `@github/copilot-sdk` import is resolved by the host — **do not** add a `package.json` or `node_modules` for it.
+
+## Open the canvas
+
+Once installed, open the **APNG Studio** canvas from Copilot. Optional open input:
+
+| field | type | description |
+| ----------- | ------ | ---------------------------------------------- |
+| `projectId` | string | Animation project id (defaults to `default`). |
+| `name` | string | Optional display name for the animation. |
+
+Each project's frames persist on disk under `artifacts//`, so they survive reloads and are shared between every open panel and the agent actions. That folder is local user data and is **git‑ignored**.
+
+## Agent actions
+
+The extension exposes these callable actions on the `apng-studio` canvas:
+
+| action | what it does |
+| ----------------- | ------------------------------------------------------------------------------------------------------- |
+| `get_state` | Return project settings + per‑frame timing/compositing and total duration. |
+| `set_settings` | Update `width`/`height` (only with 0 frames), `loops`, and `hiddenFirst`. |
+| `add_color_frame` | Append a solid‑color frame; accepts `delayNum`/`delayDen`/`disposeOp`/`blendOp`. |
+| `set_frame` | Update one frame (`frameId`) or all (`all: true`): timing via `delayMs`/`fps`/`delayNum`+`delayDen`, plus `disposeOp`/`blendOp`. |
+| `clear_frames` | Remove every frame. |
+| `export` | Assemble and write the animated `.png` (APNG) to disk; returns the absolute path. |
+
+All actions accept an optional `projectId` to target a specific animation.
+
+## How it works
+
+- **`extension.mjs`** — one loopback HTTP server per open canvas instance serves the renderer, JSON state, per‑frame PNGs, the live `/preview.png`, and mutation endpoints. Server‑Sent Events (`/events`) push a `changed` signal so every open panel and the preview stay in sync. **Send to phone** spins up a separate, read‑only LAN server that serves only a landing page and the preview image, gated by a short‑lived random token and torn down on expiry.
+- **`apng.mjs`** — assembles the APNG chunk stream (`IHDR` / `acTL` / `fcTL` / `IDAT` / `fdAT` / `IEND`) with contiguous sequence numbers, plus a minimal RGBA→PNG encoder.
+- **`qr.mjs`** — a small, dependency‑free QR encoder (byte mode, error‑correction level M) used to render the **Send to phone** code. The QR is drawn into a PNG with the `apng.mjs` encoder.
+- **`web/`** — the iframe UI. It talks to its server over plain HTTP; there is no privileged host bridge.
+
+## License
+
+[MIT](./LICENSE)
diff --git a/extensions/apng-studio/apng.mjs b/extensions/apng-studio/apng.mjs
new file mode 100644
index 000000000..64fffc5be
--- /dev/null
+++ b/extensions/apng-studio/apng.mjs
@@ -0,0 +1,310 @@
+// APNG codec helpers (Node-side).
+//
+// - assembleApng(): repackages a list of already-encoded PNG frames into a
+// single Animated PNG, following https://wiki.mozilla.org/APNG_Specification.
+// Because every frame is already a valid PNG sharing one IHDR, we only need
+// to lift each frame's IDAT stream into the default image (frame 0) or into
+// `fdAT` chunks (later frames), wrapped by `acTL`/`fcTL` control chunks with
+// freshly computed CRC-32s. No re-compression required.
+// - encodeRgbaPng(): minimal RGBA8 PNG encoder used for agent-generated frames.
+
+import { deflateSync } from "node:zlib";
+
+const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
+
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[n] = c >>> 0;
+ }
+ return table;
+})();
+
+function crc32(bytes) {
+ let c = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) {
+ c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ }
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+function concat(chunks) {
+ let total = 0;
+ for (const c of chunks) total += c.length;
+ const out = new Uint8Array(total);
+ let off = 0;
+ for (const c of chunks) {
+ out.set(c, off);
+ off += c.length;
+ }
+ return out;
+}
+
+function readU32(bytes, off) {
+ return (
+ ((bytes[off] << 24) |
+ (bytes[off + 1] << 16) |
+ (bytes[off + 2] << 8) |
+ bytes[off + 3]) >>>
+ 0
+ );
+}
+
+// Build a PNG chunk: [length][type][data][crc], CRC over type+data.
+function chunk(type, data) {
+ const len = data.length;
+ const out = new Uint8Array(12 + len);
+ const view = new DataView(out.buffer);
+ view.setUint32(0, len);
+ out[4] = type.charCodeAt(0);
+ out[5] = type.charCodeAt(1);
+ out[6] = type.charCodeAt(2);
+ out[7] = type.charCodeAt(3);
+ out.set(data, 8);
+ view.setUint32(8 + len, crc32(out.subarray(4, 8 + len)));
+ return out;
+}
+
+// Parse the pieces of a PNG we care about: its IHDR data and the concatenated
+// IDAT stream. Ancillary/color chunks are intentionally dropped — every frame
+// shares a uniform RGBA8 IHDR so they are not needed.
+function parsePng(bytes) {
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
+ if (bytes[i] !== PNG_SIGNATURE[i]) {
+ throw new Error("Not a PNG (bad signature)");
+ }
+ }
+ let off = 8;
+ let ihdrData = null;
+ let width = 0;
+ let height = 0;
+ const idatParts = [];
+ while (off + 8 <= bytes.length) {
+ const len = readU32(bytes, off);
+ const type = String.fromCharCode(
+ bytes[off + 4],
+ bytes[off + 5],
+ bytes[off + 6],
+ bytes[off + 7]
+ );
+ const dataStart = off + 8;
+ const data = bytes.subarray(dataStart, dataStart + len);
+ if (type === "IHDR") {
+ ihdrData = data.slice();
+ width = readU32(data, 0);
+ height = readU32(data, 4);
+ } else if (type === "IDAT") {
+ idatParts.push(data.slice());
+ } else if (type === "IEND") {
+ break;
+ }
+ off = dataStart + len + 4; // skip data + CRC
+ }
+ if (!ihdrData) throw new Error("PNG missing IHDR");
+ if (idatParts.length === 0) throw new Error("PNG missing IDAT");
+ return { ihdrData, width, height, idat: concat(idatParts) };
+}
+
+function acTLChunk(numFrames, numPlays) {
+ const data = new Uint8Array(8);
+ const view = new DataView(data.buffer);
+ view.setUint32(0, numFrames >>> 0);
+ view.setUint32(4, numPlays >>> 0);
+ return chunk("acTL", data);
+}
+
+// dispose_op: 0 = NONE (leave as-is), 1 = BACKGROUND (clear region to transparent
+// black), 2 = PREVIOUS (revert region to what it was before this frame).
+// blend_op: 0 = SOURCE (overwrite region, alpha included), 1 = OVER (alpha-blend
+// this frame over the current canvas contents).
+function fcTLChunk(sequence, width, height, params) {
+ const {
+ delayNum = 100,
+ delayDen = 1000,
+ disposeOp = 0,
+ blendOp = 0,
+ xOffset = 0,
+ yOffset = 0,
+ } = params || {};
+ const data = new Uint8Array(26);
+ const view = new DataView(data.buffer);
+ view.setUint32(0, sequence >>> 0); // sequence_number
+ view.setUint32(4, width >>> 0); // width
+ view.setUint32(8, height >>> 0); // height
+ view.setUint32(12, xOffset >>> 0); // x_offset
+ view.setUint32(16, yOffset >>> 0); // y_offset
+ view.setUint16(20, delayNum & 0xffff); // delay_num
+ view.setUint16(22, delayDen & 0xffff); // delay_den
+ data[24] = disposeOp & 0xff; // dispose_op
+ data[25] = blendOp & 0xff; // blend_op
+ return chunk("fcTL", data);
+}
+
+function fdATChunk(sequence, idat) {
+ const data = new Uint8Array(4 + idat.length);
+ new DataView(data.buffer).setUint32(0, sequence >>> 0);
+ data.set(idat, 4);
+ return chunk("fdAT", data);
+}
+
+const clampU16 = (n, dflt = 0) => {
+ const v = Math.round(Number(n));
+ return Number.isFinite(v) ? Math.max(0, Math.min(0xffff, v)) : dflt;
+};
+const clampDen = (n) => {
+ const v = Math.round(Number(n));
+ return Number.isFinite(v) && v >= 1 ? Math.min(0xffff, v) : 1000;
+};
+const clampOp = (n, hi) => {
+ const v = Math.round(Number(n));
+ return Number.isFinite(v) ? Math.max(0, Math.min(hi, v)) : 0;
+};
+
+// Normalize a caller-supplied frame descriptor into the exact fcTL fields.
+// Timing accepts either delayNum/delayDen (exact) or a delayMs shorthand
+// (treated as delayNum ms over a 1000 denominator).
+function frameParams(f) {
+ let delayNum;
+ let delayDen;
+ if (f.delayNum != null) {
+ delayNum = clampU16(f.delayNum, 100);
+ delayDen = clampDen(f.delayDen);
+ } else {
+ delayNum = clampU16(f.delayMs, 100);
+ delayDen = 1000;
+ }
+ return {
+ delayNum,
+ delayDen,
+ disposeOp: clampOp(f.disposeOp, 2),
+ blendOp: clampOp(f.blendOp, 1),
+ };
+}
+
+// The APNG spec forbids APNG_DISPOSE_OP_PREVIOUS on the first fcTL (decoders
+// must treat it as BACKGROUND). Normalize it for whichever frame is composited
+// first so our output is well-defined instead of relying on decoder leniency.
+function firstFrameParams(f) {
+ const p = frameParams(f);
+ if (p.disposeOp === 2) p.disposeOp = 1;
+ return p;
+}
+
+/**
+ * Assemble an APNG from a list of PNG frames.
+ *
+ * @param {Array<{png: Uint8Array, delayMs?: number, delayNum?: number, delayDen?: number, disposeOp?: number, blendOp?: number}>} frames
+ * @param {{loops?: number, hiddenFirst?: boolean}} [options]
+ * loops: 0 = infinite. hiddenFirst: when true (and >=2 frames) the first frame
+ * becomes the static default image shown by non-APNG viewers and is NOT part
+ * of the animation; frames 2..N make up the loop.
+ * @returns {Uint8Array} APNG bytes.
+ */
+export function assembleApng(frames, options = {}) {
+ if (!Array.isArray(frames) || frames.length === 0) {
+ throw new Error("assembleApng requires at least one frame");
+ }
+ const loops = Math.max(0, Math.round(Number(options.loops) || 0));
+ const hiddenFirst = !!options.hiddenFirst && frames.length >= 2;
+ const parsed = frames.map((f) => parsePng(f.png));
+
+ const width = parsed[0].width;
+ const height = parsed[0].height;
+ for (const p of parsed) {
+ if (p.width !== width || p.height !== height) {
+ throw new Error(
+ `All frames must share dimensions (${width}x${height}); found ${p.width}x${p.height}`
+ );
+ }
+ }
+
+ const parts = [PNG_SIGNATURE, chunk("IHDR", parsed[0].ihdrData)];
+ const numFrames = hiddenFirst ? parsed.length - 1 : parsed.length;
+ parts.push(acTLChunk(numFrames, loops));
+
+ let seq = 0;
+ if (hiddenFirst) {
+ // Default image = frame 0, with no fcTL, so it is not animated.
+ parts.push(chunk("IDAT", parsed[0].idat));
+ for (let i = 1; i < parsed.length; i++) {
+ const params = i === 1 ? firstFrameParams(frames[i]) : frameParams(frames[i]);
+ parts.push(fcTLChunk(seq++, width, height, params));
+ parts.push(fdATChunk(seq++, parsed[i].idat));
+ }
+ } else {
+ // Frame 0 = default image AND first animation frame: fcTL then IDAT.
+ parts.push(fcTLChunk(seq++, width, height, firstFrameParams(frames[0])));
+ parts.push(chunk("IDAT", parsed[0].idat));
+ // Remaining frames: fcTL then fdAT (each carries a sequence number).
+ for (let i = 1; i < parsed.length; i++) {
+ parts.push(fcTLChunk(seq++, width, height, frameParams(frames[i])));
+ parts.push(fdATChunk(seq++, parsed[i].idat));
+ }
+ }
+
+ parts.push(chunk("IEND", new Uint8Array(0)));
+ return concat(parts);
+}
+
+/**
+ * Encode an 8-bit RGBA pixel buffer into a (non-animated) PNG.
+ *
+ * @param {number} width
+ * @param {number} height
+ * @param {Uint8Array} rgba length must be width*height*4
+ * @returns {Uint8Array} PNG bytes.
+ */
+export function encodeRgbaPng(width, height, rgba) {
+ if (rgba.length !== width * height * 4) {
+ throw new Error("rgba length does not match width*height*4");
+ }
+ const ihdr = new Uint8Array(13);
+ const view = new DataView(ihdr.buffer);
+ view.setUint32(0, width >>> 0);
+ view.setUint32(4, height >>> 0);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // color type: RGBA
+ ihdr[10] = 0; // compression
+ ihdr[11] = 0; // filter method
+ ihdr[12] = 0; // interlace
+
+ // Filtered raw scanlines: one leading filter byte (0 = None) per row.
+ const stride = width * 4;
+ const raw = new Uint8Array((stride + 1) * height);
+ for (let y = 0; y < height; y++) {
+ const src = y * stride;
+ const dst = y * (stride + 1);
+ raw[dst] = 0;
+ raw.set(rgba.subarray(src, src + stride), dst + 1);
+ }
+ const compressed = deflateSync(raw, { level: 9 });
+
+ return concat([
+ PNG_SIGNATURE,
+ chunk("IHDR", ihdr),
+ chunk("IDAT", new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.length)),
+ chunk("IEND", new Uint8Array(0)),
+ ]);
+}
+
+/**
+ * Convenience: encode a solid-color frame as a PNG.
+ * @param {number} width
+ * @param {number} height
+ * @param {{r:number,g:number,b:number,a:number}} color 0-255 components
+ */
+export function solidColorPng(width, height, color) {
+ const rgba = new Uint8Array(width * height * 4);
+ const { r, g, b, a } = color;
+ for (let i = 0; i < rgba.length; i += 4) {
+ rgba[i] = r;
+ rgba[i + 1] = g;
+ rgba[i + 2] = b;
+ rgba[i + 3] = a;
+ }
+ return encodeRgbaPng(width, height, rgba);
+}
diff --git a/extensions/apng-studio/assets/demo.png b/extensions/apng-studio/assets/demo.png
new file mode 100644
index 000000000..e36882c82
Binary files /dev/null and b/extensions/apng-studio/assets/demo.png differ
diff --git a/extensions/apng-studio/assets/preview.png b/extensions/apng-studio/assets/preview.png
new file mode 100644
index 000000000..78cf5755c
Binary files /dev/null and b/extensions/apng-studio/assets/preview.png differ
diff --git a/extensions/apng-studio/copilot-extension.json b/extensions/apng-studio/copilot-extension.json
new file mode 100644
index 000000000..5841b689c
--- /dev/null
+++ b/extensions/apng-studio/copilot-extension.json
@@ -0,0 +1,4 @@
+{
+ "name": "apng-studio",
+ "version": 1
+}
diff --git a/extensions/apng-studio/extension.mjs b/extensions/apng-studio/extension.mjs
new file mode 100644
index 000000000..5dc4bb4ac
--- /dev/null
+++ b/extensions/apng-studio/extension.mjs
@@ -0,0 +1,1234 @@
+// Extension: apng-studio
+// Interactive studio to create Animated PNG (APNG) files from frames.
+//
+// Architecture:
+// • Server-owned state: frames live on disk under artifacts// 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 load–mutate–save 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 }
+
+// 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 `
+
+APNG Studio
+
+
Tap and hold the image to save it. This link expires shortly.
+`;
+}
+
+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.");
diff --git a/extensions/apng-studio/qr.mjs b/extensions/apng-studio/qr.mjs
new file mode 100644
index 000000000..e90e0703b
--- /dev/null
+++ b/extensions/apng-studio/qr.mjs
@@ -0,0 +1,438 @@
+// Minimal, dependency-free QR Code encoder (byte mode, EC level M, versions
+// 1-10). Enough to encode a short LAN URL for the "Send to phone" feature.
+//
+// Returns a square matrix of 0/1 modules. Rendering to PNG is done by the
+// caller via the RGBA->PNG encoder in apng.mjs, so this file has no I/O.
+//
+// Reference: ISO/IEC 18004. Verified module-for-module against the python
+// `qrcode` reference encoder (see eng verification) for forced masks 0-7.
+
+// ---- Galois field GF(256), primitive polynomial 0x11d --------------------
+const EXP = new Uint8Array(512);
+const LOG = new Uint8Array(256);
+(() => {
+ let x = 1;
+ for (let i = 0; i < 255; i++) {
+ EXP[i] = x;
+ LOG[x] = i;
+ x <<= 1;
+ if (x & 0x100) x ^= 0x11d;
+ }
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
+})();
+
+const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
+
+// Reed-Solomon generator polynomial of the given degree.
+function rsGenerator(degree) {
+ let poly = [1];
+ for (let i = 0; i < degree; i++) {
+ const next = new Array(poly.length + 1).fill(0);
+ for (let j = 0; j < poly.length; j++) {
+ next[j] ^= gfMul(poly[j], EXP[i]);
+ next[j + 1] ^= poly[j];
+ }
+ poly = next;
+ }
+ return poly;
+}
+
+function rsEncode(data, ecLen) {
+ const gen = rsGenerator(ecLen); // constant-first; gen[ecLen] is the leading 1
+ const res = new Array(ecLen).fill(0);
+ for (const byte of data) {
+ const factor = byte ^ res[0];
+ res.shift();
+ res.push(0);
+ // Use the non-leading generator coefficients in descending-degree order.
+ for (let i = 0; i < ecLen; i++) res[i] ^= gfMul(gen[ecLen - 1 - i], factor);
+ }
+ return res;
+}
+
+// ---- Version tables (EC level M) ----------------------------------------
+// [ecPerBlock, [[blockCount, dataCodewordsPerBlock], ...]]
+const EC_BLOCKS_M = {
+ 1: [10, [[1, 16]]],
+ 2: [16, [[1, 28]]],
+ 3: [26, [[1, 44]]],
+ 4: [18, [[2, 32]]],
+ 5: [24, [[2, 43]]],
+ 6: [16, [[4, 27]]],
+ 7: [18, [[4, 31]]],
+ 8: [22, [[2, 38], [2, 39]]],
+ 9: [22, [[3, 36], [2, 37]]],
+ 10: [26, [[4, 43], [1, 44]]],
+};
+
+const ALIGN_POS = {
+ 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
+ 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
+};
+
+const totalDataCodewords = (v) =>
+ EC_BLOCKS_M[v][1].reduce((sum, [count, dc]) => sum + count * dc, 0);
+
+const charCountBits = (v) => (v <= 9 ? 8 : 16);
+
+function chooseVersion(dataLen) {
+ for (let v = 1; v <= 10; v++) {
+ const capacityBits = totalDataCodewords(v) * 8;
+ const needed = 4 + charCountBits(v) + dataLen * 8;
+ if (needed <= capacityBits) return v;
+ }
+ throw new Error("Data too long for QR versions 1-10 (byte mode, EC M)");
+}
+
+// ---- Bit buffer ----------------------------------------------------------
+class BitBuffer {
+ constructor() {
+ this.bits = [];
+ }
+ put(value, length) {
+ for (let i = length - 1; i >= 0; i--) this.bits.push((value >>> i) & 1);
+ }
+ get length() {
+ return this.bits.length;
+ }
+}
+
+function buildCodewords(bytes, version) {
+ const buf = new BitBuffer();
+ buf.put(0b0100, 4); // byte mode
+ buf.put(bytes.length, charCountBits(version));
+ for (const b of bytes) buf.put(b, 8);
+
+ const capacityBits = totalDataCodewords(version) * 8;
+ // Terminator (up to 4 zero bits).
+ const term = Math.min(4, capacityBits - buf.length);
+ buf.put(0, term);
+ // Pad to a byte boundary.
+ while (buf.length % 8 !== 0) buf.bits.push(0);
+ // Pad bytes.
+ const padBytes = [0xec, 0x11];
+ let pi = 0;
+ while (buf.length < capacityBits) {
+ buf.put(padBytes[pi++ % 2], 8);
+ }
+
+ // Pack bits into data codewords.
+ const data = [];
+ for (let i = 0; i < buf.length; i += 8) {
+ let byte = 0;
+ for (let j = 0; j < 8; j++) byte = (byte << 1) | buf.bits[i + j];
+ data.push(byte);
+ }
+
+ // Split into blocks, compute EC, then interleave.
+ const [ecPerBlock, groups] = EC_BLOCKS_M[version];
+ const dataBlocks = [];
+ const ecBlocks = [];
+ let offset = 0;
+ for (const [count, dcPerBlock] of groups) {
+ for (let b = 0; b < count; b++) {
+ const block = data.slice(offset, offset + dcPerBlock);
+ offset += dcPerBlock;
+ dataBlocks.push(block);
+ ecBlocks.push(rsEncode(block, ecPerBlock));
+ }
+ }
+
+ const result = [];
+ const maxData = Math.max(...dataBlocks.map((b) => b.length));
+ for (let i = 0; i < maxData; i++) {
+ for (const block of dataBlocks) if (i < block.length) result.push(block[i]);
+ }
+ for (let i = 0; i < ecPerBlock; i++) {
+ for (const block of ecBlocks) result.push(block[i]);
+ }
+ return result;
+}
+
+// ---- Matrix construction -------------------------------------------------
+function makeBaseMatrix(size) {
+ const m = Array.from({ length: size }, () => new Array(size).fill(null));
+ return m;
+}
+
+function placeFinder(m, r, c) {
+ for (let i = -1; i <= 7; i++) {
+ for (let j = -1; j <= 7; j++) {
+ const rr = r + i;
+ const cc = c + j;
+ if (rr < 0 || cc < 0 || rr >= m.length || cc >= m.length) continue;
+ const inRing =
+ i >= 0 && i <= 6 && j >= 0 && j <= 6 &&
+ (i === 0 || i === 6 || j === 0 || j === 6);
+ const inCore = i >= 2 && i <= 4 && j >= 2 && j <= 4;
+ m[rr][cc] = inRing || inCore ? 1 : 0;
+ }
+ }
+}
+
+function placeAlignment(m, version) {
+ const pos = ALIGN_POS[version];
+ for (const r of pos) {
+ for (const c of pos) {
+ // Skip the three finder corners.
+ if ((r === 6 && c === 6) || (r === 6 && c === m.length - 7) || (r === m.length - 7 && c === 6)) continue;
+ if (m[r][c] !== null) continue;
+ for (let i = -2; i <= 2; i++) {
+ for (let j = -2; j <= 2; j++) {
+ const ring = Math.max(Math.abs(i), Math.abs(j));
+ m[r + i][c + j] = ring === 1 ? 0 : 1;
+ }
+ }
+ }
+ }
+}
+
+function reserveFormat(m) {
+ const size = m.length;
+ // Marks format/version areas as reserved (use a sentinel we overwrite later).
+ // Handled implicitly: we set them during placement by skipping null-only.
+ return size;
+}
+
+const FORMAT_MASK = 0x5412;
+
+function bchFormat(data5) {
+ let d = data5 << 10;
+ const g = 0b10100110111;
+ for (let i = 4; i >= 0; i--) {
+ if ((d >> (i + 10)) & 1) d ^= g << i;
+ }
+ return ((data5 << 10) | d) ^ FORMAT_MASK;
+}
+
+function bchVersion(version) {
+ let d = version << 12;
+ const g = 0b1111100100101;
+ for (let i = 5; i >= 0; i--) {
+ if ((d >> (i + 12)) & 1) d ^= g << i;
+ }
+ return (version << 12) | d;
+}
+
+const MASKS = [
+ (r, c) => (r + c) % 2 === 0,
+ (r, c) => r % 2 === 0,
+ (r, c) => c % 3 === 0,
+ (r, c) => (r + c) % 3 === 0,
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
+ (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
+ (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
+ (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
+];
+
+function isFunctionModule(reserved, r, c) {
+ return reserved[r][c];
+}
+
+function buildReserved(size, version) {
+ const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
+ const mark = (r, c) => {
+ if (r >= 0 && c >= 0 && r < size && c < size) reserved[r][c] = true;
+ };
+ // Finders + separators.
+ for (const [br, bc] of [[0, 0], [0, size - 7], [size - 7, 0]]) {
+ for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) mark(br + i, bc + j);
+ }
+ // Timing.
+ for (let i = 0; i < size; i++) {
+ mark(6, i);
+ mark(i, 6);
+ }
+ // Alignment.
+ const pos = ALIGN_POS[version];
+ for (const r of pos) for (const c of pos) {
+ if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) continue;
+ for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) mark(r + i, c + j);
+ }
+ // Format info areas.
+ for (let i = 0; i < 9; i++) {
+ mark(8, i);
+ mark(i, 8);
+ }
+ for (let i = 0; i < 8; i++) {
+ mark(8, size - 1 - i);
+ mark(size - 1 - i, 8);
+ }
+ mark(size - 8, 8); // dark module
+ // Version info (v >= 7).
+ if (version >= 7) {
+ for (let i = 0; i < 6; i++) for (let j = 0; j < 3; j++) {
+ mark(i, size - 11 + j);
+ mark(size - 11 + j, i);
+ }
+ }
+ return reserved;
+}
+
+function placeTiming(m) {
+ const size = m.length;
+ for (let i = 0; i < size; i++) {
+ if (m[6][i] === null) m[6][i] = i % 2 === 0 ? 1 : 0;
+ if (m[i][6] === null) m[i][6] = i % 2 === 0 ? 1 : 0;
+ }
+}
+
+function placeData(m, reserved, codewords) {
+ const size = m.length;
+ const bits = [];
+ for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1);
+ let idx = 0;
+ let upward = true;
+ for (let col = size - 1; col > 0; col -= 2) {
+ if (col === 6) col--; // skip vertical timing column
+ for (let n = 0; n < size; n++) {
+ const row = upward ? size - 1 - n : n;
+ for (let k = 0; k < 2; k++) {
+ const c = col - k;
+ if (reserved[row][c]) continue;
+ m[row][c] = idx < bits.length ? bits[idx++] : 0;
+ }
+ }
+ upward = !upward;
+ }
+}
+
+function applyMask(m, reserved, maskFn) {
+ const out = m.map((row) => row.slice());
+ for (let r = 0; r < m.length; r++) {
+ for (let c = 0; c < m.length; c++) {
+ if (reserved[r][c]) continue;
+ if (maskFn(r, c)) out[r][c] ^= 1;
+ }
+ }
+ return out;
+}
+
+function placeFormatBits(m, maskIndex) {
+ const size = m.length;
+ // EC level M = 0b00. Format data = (ecBits << 3) | maskIndex.
+ const format = bchFormat((0b00 << 3) | maskIndex);
+ const bits = [];
+ for (let i = 14; i >= 0; i--) bits.push((format >> i) & 1);
+ // Around top-left finder.
+ const coords1 = [
+ [8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8],
+ [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8],
+ ];
+ coords1.forEach(([r, c], i) => (m[r][c] = bits[i]));
+ // Split across top-right and bottom-left.
+ const coords2 = [
+ [size - 1, 8], [size - 2, 8], [size - 3, 8], [size - 4, 8],
+ [size - 5, 8], [size - 6, 8], [size - 7, 8],
+ [8, size - 8], [8, size - 7], [8, size - 6], [8, size - 5],
+ [8, size - 4], [8, size - 3], [8, size - 2], [8, size - 1],
+ ];
+ coords2.forEach(([r, c], i) => (m[r][c] = bits[i]));
+ m[size - 8][8] = 1; // dark module
+}
+
+function placeVersionBits(m, version) {
+ if (version < 7) return;
+ const size = m.length;
+ const v = bchVersion(version);
+ const bits = [];
+ for (let i = 0; i <= 17; i++) bits.push((v >> i) & 1); // least-significant bit first
+ let idx = 0;
+ for (let i = 0; i < 6; i++) {
+ for (let j = 0; j < 3; j++) {
+ const b = bits[idx++];
+ m[i][size - 11 + j] = b;
+ m[size - 11 + j][i] = b;
+ }
+ }
+}
+
+// Penalty scoring for mask selection (ISO 18004 rules 1-4).
+function penalty(m) {
+ const size = m.length;
+ let score = 0;
+ // Rule 1: runs of 5+ same-color in rows/cols.
+ for (let r = 0; r < size; r++) {
+ for (const line of [m[r], m.map((row) => row[r])]) {
+ let run = 1;
+ for (let c = 1; c < size; c++) {
+ if (line[c] === line[c - 1]) {
+ run++;
+ if (run === 5) score += 3;
+ else if (run > 5) score += 1;
+ } else run = 1;
+ }
+ }
+ }
+ // Rule 2: 2x2 blocks.
+ for (let r = 0; r < size - 1; r++) {
+ for (let c = 0; c < size - 1; c++) {
+ const v = m[r][c];
+ if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
+ }
+ }
+ // Rule 3: finder-like patterns.
+ const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
+ const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
+ const matchAt = (line, i, pat) => pat.every((p, k) => line[i + k] === p);
+ for (let r = 0; r < size; r++) {
+ const rowLine = m[r];
+ const colLine = m.map((row) => row[r]);
+ for (let c = 0; c <= size - 11; c++) {
+ if (matchAt(rowLine, c, pat1) || matchAt(rowLine, c, pat2)) score += 40;
+ if (matchAt(colLine, c, pat1) || matchAt(colLine, c, pat2)) score += 40;
+ }
+ }
+ // Rule 4: dark/light balance.
+ let dark = 0;
+ for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) dark += m[r][c];
+ const percent = (dark * 100) / (size * size);
+ const prev = Math.floor(percent / 5) * 5;
+ const next = prev + 5;
+ score += Math.min(Math.abs(prev - 50), Math.abs(next - 50)) / 5 * 10;
+ return score;
+}
+
+/**
+ * Encode a string into a QR matrix (array of rows of 0/1).
+ * @param {string} text
+ * @param {{forceMask?: number}} [opts] forceMask selects a specific mask (for tests).
+ * @returns {{matrix: number[][], version: number, size: number, mask: number}}
+ */
+export function encodeQr(text, opts = {}) {
+ const bytes = Array.from(new TextEncoder().encode(text));
+ const version = chooseVersion(bytes.length);
+ const size = version * 4 + 17;
+ const codewords = buildCodewords(bytes, version);
+ const reserved = buildReserved(size, version);
+
+ const base = makeBaseMatrix(size);
+ placeFinder(base, 0, 0);
+ placeFinder(base, 0, size - 7);
+ placeFinder(base, size - 7, 0);
+ placeAlignment(base, version);
+ placeTiming(base);
+ placeVersionBits(base, version);
+ placeData(base, reserved, codewords);
+
+ let chosen = opts.forceMask;
+ let bestMatrix = null;
+ if (chosen == null) {
+ let bestScore = Infinity;
+ for (let mi = 0; mi < 8; mi++) {
+ const masked = applyMask(base, reserved, MASKS[mi]);
+ placeFormatBits(masked, mi);
+ const s = penalty(masked);
+ if (s < bestScore) {
+ bestScore = s;
+ chosen = mi;
+ bestMatrix = masked;
+ }
+ }
+ } else {
+ bestMatrix = applyMask(base, reserved, MASKS[chosen]);
+ placeFormatBits(bestMatrix, chosen);
+ }
+
+ return { matrix: bestMatrix, version, size, mask: chosen };
+}
diff --git a/extensions/apng-studio/web/app.js b/extensions/apng-studio/web/app.js
new file mode 100644
index 000000000..7ace7abc5
--- /dev/null
+++ b/extensions/apng-studio/web/app.js
@@ -0,0 +1,669 @@
+"use strict";
+
+// ---- tiny helpers -------------------------------------------------------
+const $ = (id) => document.getElementById(id);
+const nonce = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+
+// The server mints a per-session access token and passes it in this iframe's
+// URL. Attach it to every data request so other local origins that guess the
+// port cannot read state or drive mutations.
+const ACCESS_KEY = new URLSearchParams(location.search).get("k") || "";
+function withKey(path) {
+ const u = new URL(path, location.origin);
+ if (ACCESS_KEY) u.searchParams.set("k", ACCESS_KEY);
+ return u.pathname + u.search;
+}
+
+async function api(path, body) {
+ const res = await fetch(withKey(path), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body || {}),
+ });
+ if (!res.ok) throw new Error((await res.text()) || res.statusText);
+ const text = await res.text();
+ return text ? JSON.parse(text) : {};
+}
+
+function toast(msg, ms = 2600) {
+ const t = $("toast");
+ t.textContent = msg;
+ t.hidden = false;
+ clearTimeout(toast._t);
+ toast._t = setTimeout(() => (t.hidden = true), ms);
+}
+
+// Surface any otherwise-unhandled async-handler failure to the user instead of
+// letting it become a silent unhandled rejection with no feedback.
+if (typeof window !== "undefined") {
+ window.addEventListener("unhandledrejection", (e) => {
+ toast("Error: " + (e.reason?.message || e.reason || "something went wrong"));
+ });
+}
+
+function clampSize(w, h, max = 2048) {
+ w = Math.max(1, Math.round(w));
+ h = Math.max(1, Math.round(h));
+ const longer = Math.max(w, h);
+ if (longer > max) {
+ const s = max / longer;
+ w = Math.max(1, Math.round(w * s));
+ h = Math.max(1, Math.round(h * s));
+ }
+ return { w, h };
+}
+
+// ---- state --------------------------------------------------------------
+let state = { name: "animation", width: 256, height: 256, loops: 0, hiddenFirst: false, frames: [] };
+let assetNonce = nonce();
+
+function defaultDelay() {
+ const v = parseInt($("in-delay-all").value, 10);
+ return Number.isFinite(v) && v >= 0 ? v : 120;
+}
+
+async function refreshState() {
+ const res = await fetch(withKey("/state"));
+ if (!res.ok) throw new Error((await res.text()) || res.statusText);
+ state = await res.json();
+ assetNonce = nonce();
+ render();
+}
+
+// ---- rendering ----------------------------------------------------------
+function render() {
+ renderPreview();
+ renderMeta();
+ renderSettings();
+ renderFrames();
+ syncDrawStage();
+}
+
+function renderPreview() {
+ const img = $("preview");
+ const empty = $("empty-preview");
+ const has = state.frames.length > 0;
+ empty.hidden = has;
+ if (has) {
+ img.src = withKey(`/preview.png?n=${assetNonce}`);
+ img.hidden = false;
+ img.classList.toggle("pixelated", Math.max(state.width, state.height) < 96);
+ } else {
+ img.hidden = true;
+ }
+}
+
+function renderMeta() {
+ const n = state.frames.length;
+ const hidden = state.hiddenFirst && n >= 2;
+ const animated = hidden ? state.frames.slice(1) : state.frames;
+ const totalMs = Math.round(
+ animated.reduce((a, f) => a + (f.delayNum || 0) / (f.delayDen || 1000), 0) * 1000
+ );
+ $("meta-frames").textContent = `${n} frame${n === 1 ? "" : "s"}${hidden ? " · 1 static" : ""}`;
+ $("meta-duration").textContent = `${(totalMs / 1000).toFixed(1)}s`;
+ $("meta-loops").textContent = state.loops === 0 ? "loops ∞" : `loops ${state.loops}`;
+ const busy = n === 0;
+ $("btn-export").disabled = busy;
+ $("btn-download").disabled = busy;
+ $("btn-restart").disabled = busy;
+ $("btn-share").disabled = busy;
+}
+
+function renderSettings() {
+ const n = state.frames.length;
+ const hasFrames = n > 0;
+ const w = $("in-width"),
+ h = $("in-height"),
+ l = $("in-loops");
+ if (document.activeElement !== w) w.value = state.width;
+ if (document.activeElement !== h) h.value = state.height;
+ if (document.activeElement !== l) l.value = state.loops;
+ w.disabled = hasFrames;
+ h.disabled = hasFrames;
+ $("dim-hint").style.display = hasFrames ? "block" : "none";
+
+ const hf = $("in-hidden-first");
+ hf.checked = !!state.hiddenFirst;
+ hf.disabled = n < 2;
+ $("hidden-first-label").classList.toggle("disabled", n < 2);
+ $("hidden-first-hint").hidden = !(state.hiddenFirst && n >= 2);
+
+ for (const id of ["btn-delay-all", "btn-fps", "btn-ops-all"]) $(id).disabled = !hasFrames;
+}
+
+function renderFrames() {
+ const strip = $("frame-strip");
+ const empty = $("empty-frames");
+ strip.innerHTML = "";
+ empty.hidden = state.frames.length > 0;
+ const hidden = state.hiddenFirst && state.frames.length >= 2;
+ state.frames.forEach((f, i) => {
+ const isStatic = hidden && i === 0;
+ const card = document.createElement("div");
+ card.className = "frame-card" + (isStatic ? " is-static" : "");
+
+ const thumbWrap = document.createElement("div");
+ thumbWrap.className = "checker frame-thumb-wrap";
+ const thumb = document.createElement("img");
+ thumb.className = "frame-thumb";
+ thumb.alt = `Frame ${i + 1}`;
+ thumb.src = withKey(`/frame?id=${encodeURIComponent(f.id)}&n=${assetNonce}`);
+ thumbWrap.appendChild(thumb);
+ if (isStatic) {
+ const badge = document.createElement("span");
+ badge.className = "static-badge";
+ badge.textContent = "STATIC";
+ thumbWrap.appendChild(badge);
+ }
+
+ const body = document.createElement("div");
+ body.className = "frame-body";
+
+ const idx = document.createElement("div");
+ idx.className = "frame-index";
+ idx.textContent = isStatic ? `#${i + 1} · fallback` : `#${i + 1}`;
+
+ // Inputs (declared first so the shared commit closure can read them).
+ const numIn = numberInput(f.delayNum, 0, 65535, "Delay numerator");
+ const denIn = numberInput(f.delayDen, 1, 65535, "Delay denominator");
+ const disposeSel = selectEl(
+ [[0, "None"], [1, "Background"], [2, "Previous"]],
+ f.disposeOp,
+ "Dispose op — what to do with the canvas after this frame"
+ );
+ const blendSel = selectEl(
+ [[0, "Source"], [1, "Over"]],
+ f.blendOp,
+ "Blend op — how this frame is drawn onto the canvas"
+ );
+ const msHint = document.createElement("span");
+ msHint.className = "ms-hint muted";
+
+ const updateHint = () => {
+ const num = parseInt(numIn.value, 10) || 0;
+ const den = parseInt(denIn.value, 10) || 1000;
+ const ms = Math.round((num / den) * 1000);
+ const fps = num > 0 ? den / num : 0;
+ const fpsTxt = fps ? ` · ${Number.isInteger(fps) ? fps : fps.toFixed(1)} fps` : "";
+ msHint.textContent = `= ${ms} ms${fpsTxt}`;
+ };
+ updateHint();
+ const commit = () =>
+ api("/frames/props", {
+ id: f.id,
+ delayNum: parseInt(numIn.value, 10) || 0,
+ delayDen: parseInt(denIn.value, 10) || 1000,
+ disposeOp: parseInt(disposeSel.value, 10) || 0,
+ blendOp: parseInt(blendSel.value, 10) || 0,
+ }).catch((e) => toast("Error: " + e.message));
+
+ numIn.addEventListener("input", updateHint);
+ denIn.addEventListener("input", updateHint);
+ numIn.addEventListener("change", commit);
+ denIn.addEventListener("change", commit);
+ disposeSel.addEventListener("change", commit);
+ blendSel.addEventListener("change", commit);
+
+ const delayRow = document.createElement("div");
+ delayRow.className = "frame-field";
+ delayRow.append(fieldLabel("delay"), numIn, slash(), denIn, msHint);
+
+ const opsRow = document.createElement("div");
+ opsRow.className = "frame-field";
+ opsRow.append(fieldLabel("dispose"), disposeSel);
+ const blendRow = document.createElement("div");
+ blendRow.className = "frame-field";
+ blendRow.append(fieldLabel("blend"), blendSel);
+
+ const actions = document.createElement("div");
+ actions.className = "frame-actions";
+ actions.append(
+ iconBtn("◀", "Move left", i === 0, () => api("/frames/move", { id: f.id, delta: -1 })),
+ iconBtn("▶", "Move right", i === state.frames.length - 1, () =>
+ api("/frames/move", { id: f.id, delta: 1 })
+ ),
+ iconBtn("⧉", "Duplicate", false, () => api("/frames/duplicate", { id: f.id })),
+ iconBtn("✕", "Delete", false, () => api("/frames/delete", { id: f.id }), true)
+ );
+
+ if (isStatic) {
+ [numIn, denIn, disposeSel, blendSel].forEach((el) => (el.disabled = true));
+ msHint.textContent = "not animated";
+ }
+
+ body.append(idx, delayRow, opsRow, blendRow, actions);
+ card.append(thumbWrap, body);
+ strip.appendChild(card);
+ });
+}
+
+function numberInput(value, min, max, title) {
+ const el = document.createElement("input");
+ el.type = "number";
+ el.className = "num-in";
+ el.min = String(min);
+ el.max = String(max);
+ el.step = "1";
+ el.value = value;
+ el.title = title;
+ return el;
+}
+
+function selectEl(options, value, title) {
+ const s = document.createElement("select");
+ s.className = "frame-select";
+ s.title = title;
+ for (const [val, text] of options) {
+ const o = document.createElement("option");
+ o.value = String(val);
+ o.textContent = text;
+ s.appendChild(o);
+ }
+ s.value = String(value);
+ return s;
+}
+
+function fieldLabel(text) {
+ const s = document.createElement("span");
+ s.className = "frame-label muted";
+ s.textContent = text;
+ return s;
+}
+
+function slash() {
+ const s = document.createElement("span");
+ s.className = "slash muted";
+ s.textContent = "/";
+ return s;
+}
+
+function iconBtn(label, title, disabled, onClick, danger) {
+ const b = document.createElement("button");
+ b.className = "icon-btn" + (danger ? " danger" : "");
+ b.textContent = label;
+ b.title = title;
+ b.setAttribute("aria-label", title);
+ b.disabled = !!disabled;
+ b.addEventListener("click", async () => {
+ try {
+ await onClick();
+ } catch (e) {
+ toast("Error: " + e.message);
+ }
+ });
+ return b;
+}
+
+// ---- settings handlers --------------------------------------------------
+async function commitLoops() {
+ await api("/settings", { loops: parseInt($("in-loops").value, 10) || 0 });
+}
+async function commitDim() {
+ if (state.frames.length > 0) return;
+ const w = parseInt($("in-width").value, 10);
+ const h = parseInt($("in-height").value, 10);
+ const { w: cw, h: ch } = clampSize(w || state.width, h || state.height);
+ await api("/settings", { width: cw, height: ch });
+}
+function debounce(fn, ms) {
+ let t = null;
+ return () => {
+ clearTimeout(t);
+ t = setTimeout(fn, ms);
+ };
+}
+// Commit on `input` (fires for stepper buttons and arrow keys in every engine,
+// including the host WebKit view where `change` is unreliable for steppers) as
+// well as `change` (final blur/Enter). The `input` path is debounced so holding
+// an arrow or typing a value doesn't spam the server.
+const commitDimSoon = debounce(commitDim, 300);
+const commitLoopsSoon = debounce(commitLoops, 300);
+for (const id of ["in-width", "in-height"]) {
+ $(id).addEventListener("input", commitDimSoon);
+ $(id).addEventListener("change", commitDim);
+}
+$("in-loops").addEventListener("input", commitLoopsSoon);
+$("in-loops").addEventListener("change", commitLoops);
+$("in-hidden-first").addEventListener("change", async (e) => {
+ await api("/settings", { hiddenFirst: e.target.checked });
+});
+$("btn-delay-all").addEventListener("click", async () => {
+ const ms = defaultDelay();
+ await api("/frames/props-all", { delayMs: ms });
+ toast(`All delays set to ${ms} ms`);
+});
+$("btn-fps").addEventListener("click", async () => {
+ const fps = Math.max(1, Math.min(120, parseInt($("in-fps").value, 10) || 12));
+ await api("/frames/props-all", { fps });
+ toast(`All frames set to ${fps} fps`);
+});
+$("btn-ops-all").addEventListener("click", async () => {
+ await api("/frames/props-all", {
+ disposeOp: parseInt($("in-dispose-all").value, 10) || 0,
+ blendOp: parseInt($("in-blend-all").value, 10) || 0,
+ });
+ toast("Applied dispose & blend to all frames");
+});
+$("btn-clear").addEventListener("click", async () => {
+ if (!state.frames.length) return;
+ if (!confirm("Remove all frames?")) return;
+ await api("/frames/clear", {});
+});
+
+// ---- export / download --------------------------------------------------
+$("btn-export").addEventListener("click", async () => {
+ try {
+ const r = await api("/export", {});
+ $("saved-path").hidden = false;
+ $("saved-path").textContent = "Saved: " + r.path;
+ toast("Exported " + r.name);
+ } catch (e) {
+ toast("Export failed: " + e.message);
+ }
+});
+$("btn-download").addEventListener("click", async () => {
+ const res = await fetch(withKey(`/preview.png?n=${nonce()}`));
+ const blob = await res.blob();
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(blob);
+ a.download = (state.name || "animation").replace(/[^\w.-]+/g, "_") + ".png";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 4000);
+});
+$("btn-restart").addEventListener("click", () => {
+ // Re-assigning an identical src is a no-op, so bump the nonce to force a
+ // fresh fetch and restart the animation from the first frame.
+ assetNonce = nonce();
+ renderPreview();
+});
+
+// ---- send to phone ------------------------------------------------------
+let shareCountdown = null;
+
+$("btn-share").addEventListener("click", async () => {
+ const btn = $("btn-share");
+ btn.disabled = true;
+ try {
+ const info = await api("/share/start", {});
+ openSharePanel(info);
+ } catch (e) {
+ toast("Couldn't start sharing: " + e.message);
+ } finally {
+ btn.disabled = state.frames.length === 0;
+ }
+});
+
+$("btn-share-stop").addEventListener("click", stopSharing);
+
+function openSharePanel(info) {
+ $("share-panel").hidden = false;
+ // Cache-bust the QR so a new token's code is fetched each time.
+ $("share-qr-img").src = withKey(`/share/qr.png?ts=${Date.now()}`);
+ const link = $("share-url");
+ link.href = info.url;
+ link.textContent = info.url.replace(/^https?:\/\//, "");
+ startShareCountdown(info.expiresAt);
+}
+
+function startShareCountdown(expiresAt) {
+ clearInterval(shareCountdown);
+ const tick = () => {
+ const ms = expiresAt - Date.now();
+ if (ms <= 0) {
+ clearInterval(shareCountdown);
+ $("share-panel").hidden = true;
+ toast("Phone link expired");
+ api("/share/stop", {}).catch(() => {});
+ return;
+ }
+ const m = Math.floor(ms / 60000);
+ const s = Math.floor((ms % 60000) / 1000);
+ $("share-expiry").textContent = `Expires in ${m}:${String(s).padStart(2, "0")}`;
+ };
+ tick();
+ shareCountdown = setInterval(tick, 1000);
+}
+
+async function stopSharing() {
+ clearInterval(shareCountdown);
+ $("share-panel").hidden = true;
+ try {
+ await api("/share/stop", {});
+ } catch (_) {
+ /* server may have already expired the share */
+ }
+}
+
+// ---- upload -------------------------------------------------------------
+$("btn-upload").addEventListener("click", () => $("file-input").click());
+$("file-input").addEventListener("change", async (e) => {
+ const files = [...e.target.files];
+ e.target.value = "";
+ if (files.length) await addFiles(files);
+});
+
+async function addFiles(files) {
+ try {
+ toast(`Adding ${files.length} image${files.length === 1 ? "" : "s"}…`);
+ if (state.frames.length === 0) {
+ const first = await createImageBitmap(files[0]);
+ const { w, h } = clampSize(first.width, first.height);
+ first.close?.();
+ await api("/settings", { width: w, height: h });
+ await refreshState();
+ }
+ for (const f of files) await addImageFile(f);
+ toast("Frames added");
+ } catch (e) {
+ toast("Upload error: " + e.message);
+ }
+}
+
+async function addImageFile(file) {
+ const bmp = await createImageBitmap(file);
+ const c = document.createElement("canvas");
+ c.width = state.width;
+ c.height = state.height;
+ try {
+ drawContain(c.getContext("2d"), bmp, state.width, state.height);
+ } finally {
+ // Release the decoded bitmap right after the synchronous draw so a large
+ // batch doesn't retain native image memory until GC.
+ bmp.close?.();
+ }
+ const blob = await new Promise((r) => c.toBlob(r, "image/png"));
+ await postFrame(blob, defaultDelay());
+}
+
+function drawContain(ctx, img, W, H) {
+ const s = Math.min(W / img.width, H / img.height);
+ const dw = img.width * s;
+ const dh = img.height * s;
+ ctx.clearRect(0, 0, W, H);
+ ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
+}
+
+async function postFrame(blob, delayMs) {
+ const res = await fetch(withKey(`/frames?delayMs=${delayMs || 0}`), { method: "POST", body: blob });
+ if (!res.ok) throw new Error(await res.text());
+}
+
+// ---- drawing ------------------------------------------------------------
+const drawCanvas = $("draw-canvas");
+const dctx = drawCanvas.getContext("2d");
+let drawTool = "pen";
+let drawing = false;
+let lastPt = null;
+
+$("btn-toggle-draw").addEventListener("click", () => {
+ const panel = $("draw-panel");
+ panel.hidden = !panel.hidden;
+ if (!panel.hidden) initDrawCanvas();
+});
+$("btn-cancel-draw").addEventListener("click", () => ($("draw-panel").hidden = true));
+$("tool-pen").addEventListener("click", () => setTool("pen"));
+$("tool-eraser").addEventListener("click", () => setTool("eraser"));
+function setTool(t) {
+ drawTool = t;
+ $("tool-pen").classList.toggle("active", t === "pen");
+ $("tool-eraser").classList.toggle("active", t === "eraser");
+ $("tool-pen").setAttribute("aria-pressed", String(t === "pen"));
+ $("tool-eraser").setAttribute("aria-pressed", String(t === "eraser"));
+}
+$("btn-clear-draw").addEventListener("click", () => {
+ dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
+});
+$("btn-fill").addEventListener("click", () => {
+ dctx.save();
+ dctx.globalCompositeOperation = "source-over";
+ dctx.fillStyle = $("draw-color").value;
+ dctx.fillRect(0, 0, drawCanvas.width, drawCanvas.height);
+ dctx.restore();
+});
+$("opt-onion").addEventListener("change", syncOnion);
+$("opt-from-last").addEventListener("change", initDrawCanvas);
+
+function syncDrawStage() {
+ // Size the drawing surface to the project dimensions and scale for display.
+ if (drawCanvas.width !== state.width || drawCanvas.height !== state.height) {
+ drawCanvas.width = state.width;
+ drawCanvas.height = state.height;
+ }
+ const longer = Math.max(state.width, state.height) || 1;
+ const scale = Math.min(360, Math.max(200, longer)) / longer;
+ const dispW = Math.max(1, Math.round(state.width * scale));
+ // Drive the display size from the width plus the intrinsic aspect ratio and
+ // let height follow, so a narrow side panel (CSS max-width) shrinks the
+ // surface proportionally instead of stretching a fixed height.
+ const ratio = `${state.width} / ${state.height}`;
+ drawCanvas.style.width = dispW + "px";
+ drawCanvas.style.height = "auto";
+ drawCanvas.style.aspectRatio = ratio;
+ drawCanvas.style.imageRendering = scale > 1.4 ? "pixelated" : "auto";
+ const onion = $("onion-img");
+ onion.style.width = dispW + "px";
+ onion.style.height = "auto";
+ onion.style.aspectRatio = ratio;
+ if (!$("draw-panel").hidden) syncOnion();
+}
+
+function lastFrame() {
+ return state.frames.length ? state.frames[state.frames.length - 1] : null;
+}
+
+function syncOnion() {
+ const onion = $("onion-img");
+ const lf = lastFrame();
+ if ($("opt-onion").checked && lf) {
+ onion.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`);
+ onion.hidden = false;
+ } else {
+ onion.hidden = true;
+ }
+}
+
+function initDrawCanvas() {
+ syncDrawStage();
+ dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
+ const lf = lastFrame();
+ if ($("opt-from-last").checked && lf) {
+ const img = new Image();
+ img.onload = () => dctx.drawImage(img, 0, 0, drawCanvas.width, drawCanvas.height);
+ img.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`);
+ }
+ syncOnion();
+}
+
+function canvasPoint(e) {
+ const rect = drawCanvas.getBoundingClientRect();
+ return {
+ x: (e.clientX - rect.left) * (drawCanvas.width / rect.width),
+ y: (e.clientY - rect.top) * (drawCanvas.height / rect.height),
+ };
+}
+function strokeTo(pt) {
+ dctx.globalCompositeOperation = drawTool === "eraser" ? "destination-out" : "source-over";
+ dctx.strokeStyle = $("draw-color").value;
+ dctx.fillStyle = $("draw-color").value;
+ dctx.lineWidth = parseInt($("draw-size").value, 10) || 6;
+ dctx.lineCap = "round";
+ dctx.lineJoin = "round";
+ if (lastPt) {
+ dctx.beginPath();
+ dctx.moveTo(lastPt.x, lastPt.y);
+ dctx.lineTo(pt.x, pt.y);
+ dctx.stroke();
+ } else {
+ dctx.beginPath();
+ dctx.arc(pt.x, pt.y, dctx.lineWidth / 2, 0, Math.PI * 2);
+ dctx.fill();
+ }
+ lastPt = pt;
+}
+drawCanvas.addEventListener("pointerdown", (e) => {
+ drawing = true;
+ lastPt = null;
+ drawCanvas.setPointerCapture(e.pointerId);
+ strokeTo(canvasPoint(e));
+});
+drawCanvas.addEventListener("pointermove", (e) => {
+ if (drawing) strokeTo(canvasPoint(e));
+});
+function endStroke() {
+ drawing = false;
+ lastPt = null;
+}
+drawCanvas.addEventListener("pointerup", endStroke);
+drawCanvas.addEventListener("pointerleave", endStroke);
+drawCanvas.addEventListener("pointercancel", endStroke);
+
+$("btn-add-drawing").addEventListener("click", async () => {
+ const blob = await new Promise((r) => drawCanvas.toBlob(r, "image/png"));
+ await postFrame(blob, defaultDelay());
+ toast("Frame added");
+ if ($("opt-from-last").checked) {
+ // Pull the just-added frame into state before re-seeding the canvas, so
+ // "start from last frame" copies it instead of the previous frame (or a
+ // blank canvas on the first add), which the async SSE update may not
+ // have delivered yet.
+ await refreshState();
+ initDrawCanvas();
+ }
+});
+
+// ---- live updates -------------------------------------------------------
+let eventSource = null;
+function connectEvents() {
+ try {
+ if (eventSource) eventSource.close();
+ eventSource = new EventSource(withKey("/events"));
+ eventSource.onmessage = () => refreshState();
+ } catch (_) {
+ eventSource = null; /* SSE unavailable; manual reload still works */
+ }
+}
+
+$("btn-reload").addEventListener("click", async () => {
+ const btn = $("btn-reload");
+ const glyph = $("reload-glyph");
+ btn.disabled = true;
+ glyph.classList.add("spinning");
+ try {
+ // Recover a dropped live-update stream, then pull the latest state and
+ // rebuild the preview + thumbnails against a fresh cache-busting nonce.
+ if (!eventSource || eventSource.readyState === 2) connectEvents();
+ await refreshState();
+ toast("Reloaded");
+ } catch (e) {
+ toast("Reload failed: " + e.message);
+ } finally {
+ glyph.classList.remove("spinning");
+ btn.disabled = false;
+ }
+});
+
+connectEvents();
+refreshState();
diff --git a/extensions/apng-studio/web/index.html b/extensions/apng-studio/web/index.html
new file mode 100644
index 000000000..2576082ae
--- /dev/null
+++ b/extensions/apng-studio/web/index.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+ APNG Studio
+
+
+
+
+
+
APNG Studio
+
Assemble an animated PNG from frames.
+
+
+
+ image/apng
+
+
+
+
+
+
+
+
+ No frames yet
+ Upload images or draw a frame below.
+
+
+
+ 0 frames
+ ·
+ 0.0s
+ ·
+ loops ∞
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan with your phone camera
+
Your phone must be on the same Wi‑Fi as this computer.
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+ First frame
+
+
+
+
Canvas size can only change while there are no frames.
+
+ Frame 1 is shown by viewers without APNG support and is not part of the loop.
+
+
+
+
Apply to all frames
+
+
+
+
+ ms
+
+
+
+
+
+ fps
+
+
+
+
+
+
+
+
+
+
+
+ What do dispose & blend do?
+
+
Blend · Source replaces the canvas region with this frame (alpha included). Over composites this frame on top of what's already there — use it with transparency to build an image up frame by frame.
+
Dispose · None keeps the frame on screen for the next one. Background clears it to transparent first. Previous restores whatever was there before this frame.