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. + +

+ An animated walkthrough of the APNG Studio canvas building an animation from frames +

+

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 + +

APNG Studio

+
Animated PNG preview
+Save to your device +

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 +
+
+ + +
+
+ APNG preview +
+ No frames yet + Upload images or draw a frame below. +
+
+
+ 0 frames + · + 0.0s + · + loops ∞ +
+
+ + + + +
+ + + + +
+ + +
+
Settings
+
+ + + +
+ First frame + +
+
+

Canvas size can only change while there are no frames.

+ + +
+
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.
  • +
+
+
+
+ + +
+
+ Frames + +
+
+
Frames you add appear here in order.
+
+ + +
+
Add frames
+
+ + + +
+ + + +
+ + + + + + diff --git a/extensions/apng-studio/web/styles.css b/extensions/apng-studio/web/styles.css new file mode 100644 index 000000000..9fc2a3d78 --- /dev/null +++ b/extensions/apng-studio/web/styles.css @@ -0,0 +1,620 @@ +:root { + --radius: 10px; + --radius-sm: 7px; + --gap: 12px; + --card-bg: var(--background-color-default, #ffffff); + --muted: var(--text-color-muted, #59636e); + --border: var(--border-color-default, #d1d9e0); + --fg: var(--text-color-default, #1f2328); + --accent: var(--true-color-blue, #0969da); + --danger: var(--true-color-red, #cf222e); +} + +* { + box-sizing: border-box; +} + +/* Ensure the `hidden` attribute always wins over element display rules. */ +[hidden] { + display: none !important; +} + +body { + margin: 0; + padding: 14px 14px 40px; + background: var(--background-color-default, #ffffff); + color: var(--fg); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); + -webkit-font-smoothing: antialiased; +} + +h1 { + font-family: var(--font-sans-display, var(--font-sans, sans-serif)); + font-size: var(--text-title-medium, 18px); + font-weight: var(--font-weight-semibold, 600); + line-height: 1.2; + margin: 0; +} + +.app-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--gap); + margin-bottom: 14px; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.reload-glyph { + display: inline-block; +} +.reload-glyph.spinning { + animation: apng-spin 0.6s linear infinite; +} +@keyframes apng-spin { + to { + transform: rotate(360deg); + } +} + +.muted { + color: var(--muted); +} +.tiny { + font-size: var(--text-caption, 11px); +} +.muted.tiny { + margin: 8px 0 0; +} + +.badge { + font-family: var(--font-mono, monospace); + font-size: var(--text-code-inline, 12px); + color: var(--muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 9px; + white-space: nowrap; +} + +#subtitle { + margin: 3px 0 0; + font-size: var(--text-body-small, 12px); +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 14px; +} + +.section-title { + font-weight: var(--font-weight-semibold, 600); + margin-bottom: 12px; +} + +.row { + display: flex; + align-items: center; +} +.row.gap { + gap: 8px; +} +.row.wrap { + flex-wrap: wrap; +} +.row.between, +.between { + justify-content: space-between; +} + +/* Buttons */ +.btn { + appearance: none; + border: 1px solid var(--border); + background: var(--background-color-default, #fff); + color: var(--fg); + border-radius: var(--radius-sm); + padding: 7px 12px; + font-size: var(--text-body-small, 13px); + font-weight: 500; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease; +} +.btn:hover { + background: var(--n-2-10, rgba(0, 0, 0, 0.04)); +} +.btn:active { + transform: translateY(0.5px); +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.btn-sm { + padding: 4px 9px; + font-size: var(--text-caption, 12px); +} +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--color-white, #fff); +} +.btn-primary:hover { + filter: brightness(1.06); + background: var(--accent); +} +.btn-ghost { + background: transparent; + border-color: transparent; +} +.btn-ghost:hover { + background: var(--n-2-10, rgba(0, 0, 0, 0.05)); +} +.danger { + color: var(--danger); +} + +/* Preview */ +.preview-wrap { + position: relative; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + min-height: 160px; + max-height: 320px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 10px; +} +#preview { + max-width: 100%; + max-height: 300px; + image-rendering: auto; + display: block; +} +#preview.pixelated { + image-rendering: pixelated; +} +.empty-preview { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + justify-content: center; + background: var(--background-color-default, #fff); + text-align: center; +} +.preview-meta { + display: flex; + align-items: center; + gap: 7px; + justify-content: center; + color: var(--muted); + font-size: var(--text-body-small, 12px); + margin: 10px 0 12px; +} +.preview-meta .dot { + opacity: 0.5; +} +.saved-path { + margin: 10px 0 0; + font-family: var(--font-mono, monospace); + font-size: var(--text-caption, 11px); + word-break: break-all; +} + +/* Checkerboard for transparency */ +.checker { + --checker-base: var(--background-color-default, #fff); + --checker-square: var(--border-color-default, #d9dbe0); + background-color: var(--checker-base); + background-image: + linear-gradient(45deg, var(--checker-square) 25%, transparent 25%), + linear-gradient(-45deg, var(--checker-square) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--checker-square) 75%), + linear-gradient(-45deg, transparent 75%, var(--checker-square) 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; +} + +/* Send to phone */ +.share-panel { + display: flex; + gap: var(--gap); + align-items: center; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card-bg); +} +.share-qr { + flex-shrink: 0; + padding: 8px; + border-radius: var(--radius-sm); + line-height: 0; +} +.share-qr img { + width: 132px; + height: 132px; + image-rendering: pixelated; + display: block; +} +.share-body { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.share-title { + font-weight: var(--font-weight-semibold, 600); +} +.share-url { + font-family: var(--font-mono, monospace); + font-size: var(--text-code-inline, 12px); + color: var(--accent); + word-break: break-all; + text-decoration: none; + margin-top: 2px; +} +.share-url:hover { + text-decoration: underline; +} +.share-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 6px; +} + +/* Settings */ +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--text-body-small, 12px); + color: var(--muted); +} +.field .inline { + display: flex; + gap: 6px; +} +input[type="number"], +input[type="text"] { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--background-color-default, #fff); + color: var(--fg); + font-size: var(--text-body-medium, 13px); + font-family: inherit; +} +input:focus-visible, +.btn:focus-visible, +select:focus-visible { + outline: 2px solid var(--color-focus-outline, var(--accent)); + outline-offset: 1px; +} + +/* hidden-first checkbox living inside the settings grid */ +.field .chk { + color: var(--fg); + font-size: var(--text-body-small, 12px); + padding: 4px 0; +} +.field .chk.disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Settings subsection: apply-to-all controls */ +.subsection { + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed var(--border); +} +.subsection-title { + font-size: var(--text-caption, 11px); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + font-weight: 600; + margin-bottom: 8px; +} +.apply-rows { + display: flex; + flex-direction: column; + gap: 8px; +} +.apply-rows .inline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} +.apply-rows .inline input[type="number"] { + width: 68px; +} +.apply-rows select { + padding: 5px 7px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--background-color-default, #fff); + color: var(--fg); + font-size: var(--text-body-small, 12px); + font-family: inherit; +} +.mini { + font-size: 11px; + color: var(--muted); +} +.help { + margin-top: 12px; + font-size: var(--text-body-small, 12px); +} +.help summary { + cursor: pointer; + color: var(--accent); + font-size: var(--text-caption, 11px); +} +.help ul { + margin: 8px 0 0; + padding-left: 18px; + color: var(--muted); +} +.help li { + margin-bottom: 5px; +} +.help b { + color: var(--fg); +} + +/* Frame strip */ +.frame-strip { + display: flex; + gap: 10px; + overflow-x: auto; + padding-bottom: 6px; +} +.frame-card { + flex: 0 0 auto; + width: 168px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--card-bg); +} +.frame-card.is-static { + border-color: var(--accent); + border-style: dashed; +} +.frame-thumb-wrap { + position: relative; + border-bottom: 1px solid var(--border); +} +.frame-thumb { + width: 100%; + height: 88px; + object-fit: contain; + display: block; +} +.static-badge { + position: absolute; + top: 5px; + left: 5px; + background: var(--accent); + color: var(--color-white, #fff); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 1px 5px; + border-radius: 4px; +} +.frame-body { + padding: 7px; + display: flex; + flex-direction: column; + gap: 6px; +} +.frame-index { + font-size: var(--text-caption, 11px); + color: var(--muted); + font-weight: 600; +} +.frame-field { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} +.frame-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + min-width: 46px; +} +.num-in { + width: 52px !important; + padding: 3px 4px !important; + font-size: var(--text-caption, 11px) !important; + text-align: center; +} +.slash { + font-size: 11px; +} +.ms-hint { + flex-basis: 100%; + font-size: 10px; +} +.frame-select { + flex: 1; + min-width: 0; + padding: 3px 4px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--background-color-default, #fff); + color: var(--fg); + font-size: var(--text-caption, 11px); + font-family: inherit; +} +.frame-actions { + display: flex; + gap: 3px; + margin-top: 1px; +} +.icon-btn { + flex: 1; + appearance: none; + border: 1px solid var(--border); + background: transparent; + color: var(--fg); + border-radius: 5px; + padding: 3px 0; + cursor: pointer; + font-size: 12px; + line-height: 1; +} +.icon-btn:hover { + background: var(--n-2-10, rgba(0, 0, 0, 0.05)); +} +.icon-btn.danger:hover { + background: var(--true-color-red-muted, rgba(207, 34, 46, 0.1)); +} +.icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.empty-frames { + font-size: var(--text-body-small, 12px); + padding: 4px 0 0; +} + +/* Draw panel */ +.draw-panel { + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--border); +} +.draw-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.tool { + display: flex; + align-items: center; + gap: 5px; + font-size: var(--text-caption, 12px); + color: var(--muted); +} +.tool input[type="color"] { + width: 30px; + height: 26px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: none; + cursor: pointer; +} +.tool-group { + display: inline-flex; + gap: 0; +} +.tool-group .tool-btn { + border-radius: 0; + margin-left: -1px; +} +.tool-group .tool-btn:first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + margin-left: 0; +} +.tool-group .tool-btn:last-child { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.tool-btn.active { + background: var(--accent); + border-color: var(--accent); + color: var(--color-white, #fff); +} +.draw-options { + display: flex; + gap: 16px; + margin-bottom: 10px; +} +.chk { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-body-small, 12px); + cursor: pointer; +} +.canvas-stage { + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + margin-bottom: 10px; + display: flex; + justify-content: center; +} +.canvas-stage .onion, +.canvas-stage canvas { + position: relative; + max-width: 100%; + display: block; + touch-action: none; +} +.canvas-stage .onion { + position: absolute; + inset: 0; + margin: auto; + opacity: 0.28; + pointer-events: none; + object-fit: contain; +} +#draw-canvas { + cursor: crosshair; +} + +/* Toast */ +.toast { + position: fixed; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + background: var(--fg); + color: var(--background-color-default, #fff); + padding: 8px 14px; + border-radius: 999px; + font-size: var(--text-body-small, 12px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22); + z-index: 50; + max-width: 90%; + text-align: center; +} +.toast[hidden] { + display: none; +}