+
+
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/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/extensions/qr.mjs b/extensions/apng-studio/extensions/qr.mjs new file mode 100644 index 00000000..e90e0703 --- /dev/null +++ b/extensions/apng-studio/extensions/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/extensions/web/app.js b/extensions/apng-studio/extensions/web/app.js new file mode 100644 index 00000000..7ace7abc --- /dev/null +++ b/extensions/apng-studio/extensions/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/extensions/web/index.html b/extensions/apng-studio/extensions/web/index.html new file mode 100644 index 00000000..2576082a --- /dev/null +++ b/extensions/apng-studio/extensions/web/index.html @@ -0,0 +1,189 @@ + + + + + +Assemble an animated PNG from frames.
+Canvas size can only change while there are no frames.
+ + +