mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
0916fe444d
* Add APNG Studio canvas extension APNG Studio is an interactive GitHub Copilot canvas extension for building Animated PNG (APNG) files from frames: draw or upload frames, tune per-frame timing and compositing, preview live, send the result to a phone by QR, and export an animated .png. Adds extensions/apng-studio/ with the required .github/plugin/plugin.json and assets/preview.png, and regenerates .github/plugin/marketplace.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for APNG Studio Security and robustness (extension.mjs): - Reject "." and ".." project ids so canvas/action input cannot resolve outside artifacts/. - Bound request bodies (1 MiB JSON, 40 MiB frame upload); return 413 when exceeded. - Reject malformed JSON with 400 instead of coercing to {}, so a truncated body cannot trigger destructive routes such as /frames/clear. - Scope shares per project so one canvas cannot rotate or stop another's share; the LAN server is shared and torn down once no shares remain. - get_state treats hiddenFirst as active only with >=2 frames, matching the encoder, so a single-frame project reports the correct duration. Accessibility (web/): - Expose Pen/Eraser state with aria-pressed and keep it in sync in setTool. - Mark the toast as an aria-live status region. Packaging: - Add extensions/apng-studio/.gitignore with artifacts/ so the documented runtime-data exclusion holds for this extension. - Update the README install section to reference the committed awesome-copilot extension path. Regenerated .github/plugin/marketplace.json. * Use "GitHub Copilot app" wording Update the plugin description (and regenerated marketplace entry) to refer to the host as the GitHub Copilot app. * Address deeper review for APNG Studio Security: - Require a per-server access token on every loopback data/mutation request (minted per canvas server, carried in the iframe URL, attached to every renderer request). Static assets stay public. Blocks a local process or cross-origin page from reading state or driving mutations. Concurrency and durability: - Serialize the load-mutate-save cycle per project; dedupe concurrent first loads; write project.json via temp file + rename. Lifecycle: - Track SSE clients per instance and end them before server.close(). - Stop a project's LAN share only when no other panel references it. Correctness: - Report exact numerator/denominator total duration; handle pointercancel. QR and accessibility: - Place QR version bits least-significant-bit first (versions 7-10). - Associate delay/fps/dispose/blend labels with their inputs via for=. * Address deeper concurrency and validation review for APNG Studio - Run assemble() under the project lock; add_color_frame renders and appends within one lock. - Validate uploaded frames (PNG chunk scan, size + dimension checks) before writing; first frame stores real dimensions; CanvasError maps to 400. - Validate move delta; clear frames in memory before deleting files. - Route the open-handler rename through applySettings. - Deduplicate LAN share startup; clean up if the canvas closes mid-start. - End SSE streams before server.close(); skip dead streams in broadcast. - Exact fractional duration; drawing surface stays >= 1px; refresh state before re-seeding "start from last frame". * Address review: PNG format validation, share bind, limits, a11y - Validate uploaded frames are 8-bit RGBA non-interlaced PNGs (standard compression/filter); reject formats the codec can't encode. - Cap projects at 600 frames (add + duplicate). - Bind the LAN share server to the private address, not 0.0.0.0; require a private address and rebind when it changes while idle; build the URL from the bound address. - Align width/height inputs and clampSize to the 2048 maximum. - Size the drawing surface by width + aspect ratio for narrow panels. - set_frame with an unknown frameId returns a validation error. * Address review: encoded-byte budget, timing modes, id keys, load errors - 256 MiB aggregate encoded-byte budget (add + duplicate); per-frame size tracked and backfilled on load, so a frame count alone no longer bounds assembly memory. - Frame timing is one exclusive mode (delayMs | fps | delayNum/delayDen); combinations are rejected instead of producing hybrid delays. - Collision-resistant project storage keys: safe ids are used verbatim (existing projects still load) and only unsafe ids get a hash suffix, so "foo/bar" and "foo?bar" can't share a directory. - Load only treats a missing file as a new project; other read/parse errors surface instead of silently overwriting real data. - Share server binds to a real LAN interface (skips virtual/VPN/container, prefers physical NICs and common ranges). - Generated export names get millisecond + random suffix to avoid same-second overwrite. - PNG validator accepts the encoder's Uint8Array so agent solid-color frames aren't rejected. * Address review: crash-safe frame writes, server startup, APNG first frame - Write the PNG before mutating project metadata on add/duplicate, and delete after persisting, so an interrupted disk op can't dangle a reference or advance unsaved state. - Evict a project from the in-memory cache if its save fails, so a transient write error can't desync the live session from disk. - Reject on the loopback server's listen error and drop a half-initialized instance so startup failures clean up and can retry. - Normalize APNG dispose_op PREVIOUS to BACKGROUND on the first animated frame (spec requirement). - Release decoded ImageBitmaps after use so a large upload batch doesn't retain native image memory. - Surface otherwise-silent async UI failures via a toast; check the state response is ok before parsing it. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1235 lines
55 KiB
JavaScript
1235 lines
55 KiB
JavaScript
// Extension: apng-studio
|
||
// Interactive studio to create Animated PNG (APNG) files from frames.
|
||
//
|
||
// Architecture:
|
||
// • Server-owned state: frames live on disk under artifacts/<projectId>/ so
|
||
// they survive extension reloads and are shared between the interactive
|
||
// iframe UI and the agent-callable actions.
|
||
// • One loopback HTTP server per open canvas instance serves the renderer
|
||
// (web/), JSON state, per-frame PNGs, a live `/preview.png`, and mutation
|
||
// endpoints. Server-Sent Events push "changed" so every open panel and the
|
||
// preview stay in sync.
|
||
// • APNG assembly + a small RGBA→PNG encoder live in ./apng.mjs.
|
||
|
||
import { createServer } from "node:http";
|
||
import { fileURLToPath } from "node:url";
|
||
import { join, extname } from "node:path";
|
||
import { promises as fs } from "node:fs";
|
||
import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
|
||
import { networkInterfaces } from "node:os";
|
||
|
||
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
|
||
import { assembleApng, solidColorPng, encodeRgbaPng } from "./apng.mjs";
|
||
import { encodeQr } from "./qr.mjs";
|
||
|
||
const EXT_DIR = fileURLToPath(new URL(".", import.meta.url));
|
||
const WEB_DIR = join(EXT_DIR, "web");
|
||
const ARTIFACTS_DIR = join(EXT_DIR, "artifacts");
|
||
const EXPORTS_DIR = join(ARTIFACTS_DIR, "exports");
|
||
const DEFAULT_PROJECT = "default";
|
||
const MAX_FRAMES = 600; // count cap so a project can't accumulate unbounded frames
|
||
const MAX_TOTAL_BYTES = 256 << 20; // 256 MiB of encoded frames — bounds assembly memory
|
||
|
||
let session;
|
||
|
||
// ---- helpers ------------------------------------------------------------
|
||
const clampInt = (n, lo, hi, dflt) => {
|
||
const v = Math.round(Number(n));
|
||
return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt;
|
||
};
|
||
const clampDim = (n) => clampInt(n, 1, 2048, 256);
|
||
const clampLoops = (n) => clampInt(n, 0, 65535, 0);
|
||
const clampDen = (n) => clampInt(n, 1, 65535, 1000);
|
||
const clampDispose = (n, dflt = 0) => clampInt(n, 0, 2, dflt);
|
||
const clampBlend = (n, dflt = 0) => clampInt(n, 0, 1, dflt);
|
||
const frameMs = (f) => Math.round((f.delayNum / f.delayDen) * 1000);
|
||
const usedBytes = (meta) => meta.frames.reduce((a, f) => a + (f.bytes || 0), 0);
|
||
|
||
// Normalize a stored/incoming frame record to the full field set, migrating the
|
||
// legacy { id, delayMs } shape to explicit delay numerator/denominator plus the
|
||
// per-frame compositing ops.
|
||
function normalizeFrame(f) {
|
||
const num = f.delayNum != null ? f.delayNum : f.delayMs;
|
||
return {
|
||
id: String(f.id),
|
||
delayNum: clampInt(num, 0, 65535, 100),
|
||
delayDen: clampDen(f.delayDen),
|
||
disposeOp: clampDispose(f.disposeOp),
|
||
blendOp: clampBlend(f.blendOp),
|
||
bytes: Number.isFinite(f.bytes) && f.bytes > 0 ? Math.floor(f.bytes) : 0,
|
||
};
|
||
}
|
||
// Map a project id to a filesystem-safe key. Ids that are already safe (including
|
||
// the legacy "default" and GUID-style ids) are used verbatim, so directories stay
|
||
// stable across restarts/upgrades and re-sanitizing is a no-op. Only ids that
|
||
// aren't filesystem-safe get a hash suffix — keyed on the raw id — so two distinct
|
||
// unsafe ids ("foo/bar" vs "foo?bar") can never share a directory, lock, or entry.
|
||
const SAFE_ID = /^[\w.-]{1,64}$/;
|
||
const sanitizeId = (s) => {
|
||
const raw = String(s ?? "") || DEFAULT_PROJECT;
|
||
if (raw !== "." && raw !== ".." && SAFE_ID.test(raw)) return raw;
|
||
const prefix = raw.replace(/[^\w.-]+/g, "_").slice(0, 40) || "p";
|
||
return `${prefix}-${createHash("sha256").update(raw).digest("hex").slice(0, 12)}`;
|
||
};
|
||
// Frame ids are internal monotonic counters, so a lightweight path-safe cleaner is
|
||
// enough (and keeps frame filenames readable).
|
||
const sanitizeFrameId = (s) => String(s).replace(/[^\w.-]+/g, "_").slice(0, 64) || "0";
|
||
const sanitizeName = (s) => String(s || "animation").replace(/[^\w.-]+/g, "_").slice(0, 80) || "animation";
|
||
const ensureDir = (d) => fs.mkdir(d, { recursive: true });
|
||
|
||
function log(message, level = "info") {
|
||
try {
|
||
session?.log(message, { level });
|
||
} catch {
|
||
/* logging is best-effort */
|
||
}
|
||
}
|
||
|
||
// ---- project store (disk-backed, shared across instances) ---------------
|
||
const projects = new Map(); // projectId -> meta
|
||
const loadingProjects = new Map(); // projectId -> in-flight load Promise
|
||
const projectLocks = new Map(); // projectId -> tail of the mutation queue
|
||
const projectDir = (id) => join(ARTIFACTS_DIR, sanitizeId(id));
|
||
const framePath = (id, fid) => join(projectDir(id), `frame-${sanitizeFrameId(fid)}.png`);
|
||
|
||
// Serialize the full 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<res> }
|
||
|
||
// Resolve which project an action targets: an explicit projectId wins, else the
|
||
// project bound to the invoking canvas instance, else the default project.
|
||
function resolveProjectId(ctx) {
|
||
if (ctx?.input?.projectId) return sanitizeId(ctx.input.projectId);
|
||
const entry = servers.get(ctx?.instanceId);
|
||
if (entry) return entry.projectId;
|
||
return DEFAULT_PROJECT;
|
||
}
|
||
|
||
// Constant-time compare for the per-server access token.
|
||
function tokenMatches(provided, expected) {
|
||
if (typeof provided !== "string" || provided.length !== expected.length) return false;
|
||
try {
|
||
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Push a "changed" event to every open panel of a project (across instances).
|
||
function broadcast(projectId) {
|
||
const pid = sanitizeId(projectId);
|
||
for (const entry of servers.values()) {
|
||
if (entry.projectId !== pid) continue;
|
||
for (const res of entry.sse) {
|
||
// Drop responses that have already ended/reset rather than writing
|
||
// to a dead stream.
|
||
if (res.writableEnded || res.destroyed) {
|
||
entry.sse.delete(res);
|
||
continue;
|
||
}
|
||
try {
|
||
res.write(`data: changed\n\n`);
|
||
} catch {
|
||
entry.sse.delete(res);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const CONTENT_TYPES = {
|
||
".html": "text/html; charset=utf-8",
|
||
".js": "text/javascript; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
};
|
||
|
||
function send(res, status, type, body, extraHeaders) {
|
||
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", ...(extraHeaders || {}) });
|
||
res.end(body);
|
||
}
|
||
|
||
// Error carrying an explicit HTTP status (e.g. 400 bad JSON, 413 too large).
|
||
class HttpError extends Error {
|
||
constructor(status, message) {
|
||
super(message);
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
const MAX_JSON_BYTES = 1 << 20; // 1 MiB — mutation payloads are tiny
|
||
const MAX_UPLOAD_BYTES = 40 << 20; // 40 MiB — a single decoded frame PNG
|
||
|
||
function readBody(req, maxBytes) {
|
||
return new Promise((resolve, reject) => {
|
||
const chunks = [];
|
||
let total = 0;
|
||
let over = false;
|
||
req.on("data", (c) => {
|
||
if (over) return; // past the limit: drain without buffering
|
||
total += c.length;
|
||
if (total > maxBytes) {
|
||
over = true;
|
||
reject(new HttpError(413, "Request body too large."));
|
||
return;
|
||
}
|
||
chunks.push(c);
|
||
});
|
||
req.on("end", () => {
|
||
if (!over) resolve(Buffer.concat(chunks));
|
||
});
|
||
req.on("error", reject);
|
||
});
|
||
}
|
||
async function readJson(req) {
|
||
const buf = await readBody(req, MAX_JSON_BYTES);
|
||
if (!buf.length) return {};
|
||
try {
|
||
return JSON.parse(buf.toString("utf8"));
|
||
} catch {
|
||
throw new HttpError(400, "Invalid JSON body.");
|
||
}
|
||
}
|
||
|
||
async function handleRequest(entry, req, res) {
|
||
const projectId = entry.projectId;
|
||
const url = new URL(req.url, "http://localhost");
|
||
const path = url.pathname;
|
||
const method = req.method || "GET";
|
||
|
||
try {
|
||
// Static renderer assets are public (they carry no project data). The
|
||
// iframe is loaded with the token in its URL; app.js then reads it and
|
||
// attaches it to every data request below.
|
||
if (method === "GET" && (path === "/" || path === "/index.html")) {
|
||
return send(res, 200, CONTENT_TYPES[".html"], await fs.readFile(join(WEB_DIR, "index.html")));
|
||
}
|
||
if (method === "GET" && (path === "/app.js" || path === "/styles.css")) {
|
||
const file = path.slice(1);
|
||
return send(res, 200, CONTENT_TYPES[extname(file)] || "text/plain", await fs.readFile(join(WEB_DIR, file)));
|
||
}
|
||
if (path === "/favicon.ico") return send(res, 204, "text/plain", "");
|
||
|
||
// Everything below reads or mutates project data. Require the per-server
|
||
// token so another local process or a cross-origin page that guesses the
|
||
// port cannot read state or drive mutations (e.g. /frames/clear).
|
||
if (!tokenMatches(url.searchParams.get("k") || "", entry.token)) {
|
||
return send(res, 403, "text/plain", "Forbidden");
|
||
}
|
||
|
||
// State.
|
||
if (method === "GET" && path === "/state") {
|
||
const meta = await loadProject(projectId);
|
||
return send(res, 200, "application/json", JSON.stringify(publicState(meta)));
|
||
}
|
||
|
||
// Server-Sent Events. Track the client on this instance so its canvas
|
||
// can end just its own streams on close without disturbing other panels.
|
||
if (method === "GET" && path === "/events") {
|
||
res.writeHead(200, {
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-store",
|
||
Connection: "keep-alive",
|
||
});
|
||
res.write(": connected\n\n");
|
||
entry.sse.add(res);
|
||
const drop = () => entry.sse.delete(res);
|
||
req.on("close", drop);
|
||
res.on("close", drop);
|
||
res.on("error", drop);
|
||
return;
|
||
}
|
||
|
||
// A single frame PNG (for thumbnails / onion skin / draw base).
|
||
if (method === "GET" && path === "/frame") {
|
||
const fid = url.searchParams.get("id");
|
||
try {
|
||
const buf = await fs.readFile(framePath(projectId, fid));
|
||
return send(res, 200, "image/png", buf);
|
||
} catch {
|
||
return send(res, 404, "text/plain", "frame not found");
|
||
}
|
||
}
|
||
|
||
// Live-assembled APNG preview (served as image/png — APNG is byte-compatible
|
||
// with PNG, so browsers animate it and default viewers still open it).
|
||
if (method === "GET" && path === "/preview.png") {
|
||
const bytes = await assemble(projectId);
|
||
if (!bytes) return send(res, 204, "image/png", "");
|
||
return send(res, 200, "image/png", Buffer.from(bytes));
|
||
}
|
||
|
||
// Add a frame (raw PNG body).
|
||
if (method === "POST" && path === "/frames") {
|
||
const buf = await readBody(req, MAX_UPLOAD_BYTES);
|
||
if (!buf.length) return send(res, 400, "text/plain", "empty body");
|
||
const delayMs = url.searchParams.get("delayMs");
|
||
const id = await addFrameBuffer(projectId, buf, { delayMs });
|
||
return send(res, 200, "application/json", JSON.stringify({ id }));
|
||
}
|
||
|
||
// JSON mutation endpoints.
|
||
if (method === "POST") {
|
||
const body = await readJson(req);
|
||
switch (path) {
|
||
case "/frames/move":
|
||
await moveFrame(projectId, body.id, body.delta);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/frames/delete":
|
||
await deleteFrame(projectId, body.id);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/frames/duplicate":
|
||
await duplicateFrame(projectId, body.id);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/frames/props":
|
||
await setFrameProps(projectId, body.id, body);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/frames/props-all":
|
||
await setFramePropsAll(projectId, body);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/frames/clear":
|
||
await clearFrames(projectId);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/settings":
|
||
await applySettings(projectId, body);
|
||
return send(res, 200, "application/json", "{}");
|
||
case "/export": {
|
||
const out = await exportApng(projectId, body.filename);
|
||
log(`APNG exported: ${out.path}`);
|
||
return send(res, 200, "application/json", JSON.stringify(out));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- "Send to phone" control plane (loopback only) --------------
|
||
if (method === "POST" && path === "/share/start") {
|
||
try {
|
||
const info = await startShare(projectId);
|
||
return send(res, 200, "application/json", JSON.stringify(info));
|
||
} catch (err) {
|
||
const status = err instanceof CanvasError ? 400 : 500;
|
||
return send(res, status, "text/plain", err.message || "Could not start sharing.");
|
||
}
|
||
}
|
||
if (method === "POST" && path === "/share/stop") {
|
||
stopShare(projectId);
|
||
return send(res, 200, "application/json", "{}");
|
||
}
|
||
if (method === "GET" && path === "/share/qr.png") {
|
||
const s = shares.get(projectId);
|
||
if (!s || Date.now() > s.expiresAt) {
|
||
return send(res, 409, "text/plain", "no active share");
|
||
}
|
||
return send(res, 200, "image/png", Buffer.from(renderQrPng(shareUrlFor(projectId))));
|
||
}
|
||
|
||
return send(res, 404, "text/plain", "not found");
|
||
} catch (err) {
|
||
if (err instanceof HttpError) return send(res, err.status, "text/plain", err.message);
|
||
// CanvasError is a user-facing validation error (bad frame, size
|
||
// mismatch, nothing to export), not a server fault.
|
||
if (err instanceof CanvasError) return send(res, 400, "text/plain", err.message);
|
||
return send(res, 500, "text/plain", String(err && err.message ? err.message : err));
|
||
}
|
||
}
|
||
|
||
async function startServer(entry) {
|
||
entry.token = randomBytes(16).toString("hex");
|
||
entry.sse = new Set();
|
||
const server = createServer((req, res) => handleRequest(entry, req, res));
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", () => {
|
||
server.removeListener("error", reject);
|
||
resolve();
|
||
});
|
||
});
|
||
const { port } = server.address();
|
||
entry.server = server;
|
||
// The token travels in the iframe URL; app.js reads it and attaches it to
|
||
// every data request so other local origins cannot reach project data.
|
||
entry.url = `http://127.0.0.1:${port}/?k=${entry.token}`;
|
||
return entry;
|
||
}
|
||
|
||
// ---- "Send to phone" share server ---------------------------------------
|
||
// A separate, read-only HTTP server bound to the LAN so a phone can fetch the
|
||
// live animation. It exposes ONLY a landing page and the preview image, gated
|
||
// by a short-lived random token, and it shuts itself down when the token
|
||
// expires. Mutation endpoints stay on the loopback server and are never
|
||
// reachable from the network.
|
||
const SHARE_TTL_MS = 10 * 60 * 1000;
|
||
let shareServer = null; // single LAN-bound HTTP server, created on demand
|
||
let shareServerStarting = null; // in-flight startup promise (dedupe concurrent starts)
|
||
let shareServerBindIp = null; // the private IPv4 the server is actually bound to
|
||
const shares = new Map(); // projectId -> { token, expiresAt, timer }
|
||
|
||
// Best-guess private (RFC1918) LAN IPv4 to bind the share server to, or null.
|
||
// Interfaces that are typically virtual (VPN/VM/container: utun, ipsec, tun/tap,
|
||
// bridge, vmnet, docker, veth, wg, awdl…) are skipped, and real NICs (en*, eth*,
|
||
// wl*) on common home/office ranges are preferred. This is a heuristic — on an
|
||
// unusual multi-homed host it can still pick the wrong interface.
|
||
function lanIPv4() {
|
||
const isPrivate = (ip) =>
|
||
/^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip);
|
||
const virtual = /^(utun|ipsec|ppp|tun|tap|awdl|llw|bridge|vmnet|vboxnet|docker|veth|wg|zt)/i;
|
||
const physical = /^(en|eth|wl|wlan)/i;
|
||
const cands = [];
|
||
for (const [name, addrs] of Object.entries(networkInterfaces())) {
|
||
for (const a of addrs || []) {
|
||
if (a.family !== "IPv4" || a.internal) continue;
|
||
if (!isPrivate(a.address)) continue;
|
||
if (virtual.test(name)) continue;
|
||
let score = 0;
|
||
if (physical.test(name)) score -= 100;
|
||
if (/^192\.168\./.test(a.address)) score -= 10;
|
||
else if (/^10\./.test(a.address)) score -= 5; // 172.16/12 is often Docker; least preferred
|
||
cands.push({ ip: a.address, score });
|
||
}
|
||
}
|
||
cands.sort((a, b) => a.score - b.score);
|
||
return cands.length ? cands[0].ip : null;
|
||
}
|
||
|
||
function shareUrlFor(projectId) {
|
||
const s = shares.get(projectId);
|
||
const { port } = shareServer.address();
|
||
// Use the address the server is actually bound to, not a freshly resolved
|
||
// one, so the link always points where the listener is really accepting.
|
||
return `http://${shareServerBindIp}:${port}/s?t=${s.token}`;
|
||
}
|
||
|
||
// Resolve an active share from its token (constant-time compare), so tokens from
|
||
// one project's panel can never address another project's share.
|
||
function shareForToken(token) {
|
||
if (typeof token !== "string" || !token) return null;
|
||
const tokenBuf = Buffer.from(token);
|
||
for (const [projectId, s] of shares) {
|
||
if (s.token.length !== token.length) continue;
|
||
let ok = false;
|
||
try {
|
||
ok = timingSafeEqual(tokenBuf, Buffer.from(s.token));
|
||
} catch {
|
||
ok = false;
|
||
}
|
||
if (ok) return { projectId, share: s };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function shareLandingHtml(token) {
|
||
const src = `/s/preview.png?t=${encodeURIComponent(token)}`;
|
||
// Self-contained page: no external assets, checkerboard behind the image.
|
||
return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>APNG Studio</title>
|
||
<style>
|
||
:root { color-scheme: light dark; }
|
||
body { margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
||
justify-content:center; gap:20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||
background:#0d1117; color:#e6edf3; padding:24px; box-sizing:border-box; }
|
||
h1 { font-size:17px; font-weight:600; margin:0; }
|
||
.frame { padding:14px; border-radius:12px;
|
||
background-color:#fff;
|
||
background-image:linear-gradient(45deg,#d9dbe0 25%,transparent 25%),linear-gradient(-45deg,#d9dbe0 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#d9dbe0 75%),linear-gradient(-45deg,transparent 75%,#d9dbe0 75%);
|
||
background-size:16px 16px; background-position:0 0,0 8px,8px -8px,-8px 0; }
|
||
img { display:block; max-width:min(88vw,480px); height:auto; image-rendering:auto; }
|
||
a { color:#4493f8; font-size:14px; text-decoration:none; }
|
||
p { margin:0; font-size:13px; opacity:.7; }
|
||
</style></head><body>
|
||
<h1>APNG Studio</h1>
|
||
<div class="frame"><img src="${src}" alt="Animated PNG preview" /></div>
|
||
<a href="${src}" download="animation.png">Save to your device</a>
|
||
<p>Tap and hold the image to save it. This link expires shortly.</p>
|
||
</body></html>`;
|
||
}
|
||
|
||
async function shareRequest(req, res) {
|
||
try {
|
||
const url = new URL(req.url, "http://localhost");
|
||
const token = url.searchParams.get("t") || "";
|
||
const match = shareForToken(token);
|
||
if (!match) return send(res, 403, "text/plain", "Invalid or expired link.");
|
||
if (Date.now() > match.share.expiresAt) {
|
||
stopShare(match.projectId);
|
||
return send(res, 410, "text/plain", "This link has expired.");
|
||
}
|
||
if (req.method === "GET" && (url.pathname === "/s" || url.pathname === "/s/")) {
|
||
return send(res, 200, CONTENT_TYPES[".html"], shareLandingHtml(token));
|
||
}
|
||
if (req.method === "GET" && url.pathname === "/s/preview.png") {
|
||
const bytes = await assemble(match.projectId);
|
||
if (!bytes) return send(res, 204, "image/png", "");
|
||
return send(res, 200, "image/png", Buffer.from(bytes));
|
||
}
|
||
return send(res, 404, "text/plain", "not found");
|
||
} catch (err) {
|
||
if (err instanceof HttpError) return send(res, err.status, "text/plain", err.message);
|
||
return send(res, 500, "text/plain", String(err && err.message ? err.message : err));
|
||
}
|
||
}
|
||
|
||
// Start (or reuse) the single LAN share server. Concurrent callers share one
|
||
// in-flight startup promise so two /share/start requests can't each bind a
|
||
// separate listener and leak one.
|
||
async function ensureShareServer(bindIp) {
|
||
if (shareServer) {
|
||
// Reuse the running server, unless the LAN address changed and nothing
|
||
// is currently being shared — then rebind to the new private address.
|
||
if (shareServerBindIp === bindIp || shares.size > 0) return shareServer;
|
||
const old = shareServer;
|
||
shareServer = null;
|
||
shareServerStarting = null;
|
||
shareServerBindIp = null;
|
||
try {
|
||
old.close();
|
||
} catch {
|
||
/* already closing */
|
||
}
|
||
}
|
||
if (!shareServerStarting) {
|
||
shareServerStarting = (async () => {
|
||
const server = createServer(shareRequest);
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
// Bind only to the private LAN address, not 0.0.0.0, so the
|
||
// listener is never exposed on public/VPN interfaces.
|
||
server.listen(0, bindIp, resolve);
|
||
});
|
||
shareServer = server;
|
||
shareServerBindIp = bindIp;
|
||
return server;
|
||
})().catch((err) => {
|
||
shareServerStarting = null;
|
||
throw err;
|
||
});
|
||
}
|
||
return shareServerStarting;
|
||
}
|
||
|
||
async function startShare(projectId) {
|
||
const ip = lanIPv4();
|
||
if (!ip) {
|
||
throw new CanvasError(
|
||
"no_network",
|
||
"No local Wi-Fi/LAN address found. Connect to a local network to share to your phone."
|
||
);
|
||
}
|
||
await ensureShareServer(ip);
|
||
// The canvas may have closed while the server was binding. If no open panel
|
||
// still references this project, don't leave a share (or an idle LAN server)
|
||
// behind.
|
||
const stillOpen = [...servers.values()].some((e) => e.projectId === sanitizeId(projectId));
|
||
if (!stillOpen) {
|
||
if (shares.size === 0 && shareServer) {
|
||
const server = shareServer;
|
||
shareServer = null;
|
||
shareServerStarting = null;
|
||
shareServerBindIp = null;
|
||
try {
|
||
server.close();
|
||
} catch {
|
||
/* already closing */
|
||
}
|
||
}
|
||
throw new CanvasError("canvas_closed", "The canvas was closed before sharing started.");
|
||
}
|
||
const existing = shares.get(projectId);
|
||
if (existing?.timer) clearTimeout(existing.timer);
|
||
const token = randomBytes(16).toString("hex");
|
||
const expiresAt = Date.now() + SHARE_TTL_MS;
|
||
const timer = setTimeout(() => stopShare(projectId), SHARE_TTL_MS);
|
||
timer.unref?.();
|
||
shares.set(projectId, { token, expiresAt, timer });
|
||
return { url: shareUrlFor(projectId), expiresAt, ttlMs: SHARE_TTL_MS };
|
||
}
|
||
|
||
function stopShare(projectId) {
|
||
const s = shares.get(projectId);
|
||
if (!s) return;
|
||
if (s.timer) clearTimeout(s.timer);
|
||
shares.delete(projectId);
|
||
// Close the shared LAN server once nothing is being shared.
|
||
if (shares.size === 0 && shareServer) {
|
||
const server = shareServer;
|
||
shareServer = null;
|
||
shareServerStarting = null;
|
||
shareServerBindIp = null;
|
||
try {
|
||
server.close();
|
||
} catch {
|
||
/* already closing */
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render a QR matrix into a scannable PNG using the RGBA->PNG encoder.
|
||
function renderQrPng(text, scale = 8, quiet = 4) {
|
||
const { matrix, size } = encodeQr(text);
|
||
const dim = (size + quiet * 2) * scale;
|
||
const rgba = new Uint8Array(dim * dim * 4).fill(255);
|
||
for (let r = 0; r < size; r++) {
|
||
for (let c = 0; c < size; c++) {
|
||
if (!matrix[r][c]) continue;
|
||
for (let y = 0; y < scale; y++) {
|
||
for (let x = 0; x < scale; x++) {
|
||
const o = (((r + quiet) * scale + y) * dim + ((c + quiet) * scale + x)) * 4;
|
||
rgba[o] = 0;
|
||
rgba[o + 1] = 0;
|
||
rgba[o + 2] = 0;
|
||
rgba[o + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return encodeRgbaPng(dim, dim, rgba);
|
||
}
|
||
|
||
// ---- canvas declaration -------------------------------------------------
|
||
const openInputSchema = {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string", description: "Identifier for the animation project (defaults to 'default')." },
|
||
name: { type: "string", description: "Optional display name for the animation." },
|
||
},
|
||
additionalProperties: false,
|
||
};
|
||
|
||
session = await joinSession({
|
||
canvases: [
|
||
createCanvas({
|
||
id: "apng-studio",
|
||
displayName: "APNG Studio",
|
||
description:
|
||
"Build an Animated PNG (APNG) from frames: upload or draw frames, set per-frame delays and loop count, preview live, and export an animated .png file.",
|
||
inputSchema: openInputSchema,
|
||
actions: [
|
||
{
|
||
name: "get_state",
|
||
description: "Return the current project's dimensions, loop count, frame count and per-frame delays.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: { projectId: { type: "string" } },
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
const meta = await loadProject(resolveProjectId(ctx));
|
||
// hiddenFirst only takes effect with >=2 frames (matches the
|
||
// encoder in apng.mjs), so a lone frame still counts as animated.
|
||
const hidden = meta.hiddenFirst && meta.frames.length >= 2;
|
||
const animated = hidden ? meta.frames.slice(1) : meta.frames;
|
||
// Sum exact numerator/denominator fractions, then convert
|
||
// once, so the total matches the encoded timing rather than
|
||
// accumulating per-frame rounding.
|
||
const totalMs = Math.round(
|
||
animated.reduce((a, f) => a + f.delayNum / f.delayDen, 0) * 1000
|
||
);
|
||
return {
|
||
...publicState(meta),
|
||
frameCount: meta.frames.length,
|
||
totalDurationMs: totalMs,
|
||
exportsDir: EXPORTS_DIR,
|
||
};
|
||
},
|
||
},
|
||
{
|
||
name: "set_settings",
|
||
description:
|
||
"Update project settings. width/height only apply when there are no frames yet. loops: 0 = infinite. hiddenFirst: make frame 1 a static fallback that is not part of the animation.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string" },
|
||
width: { type: "integer", minimum: 1, maximum: 2048 },
|
||
height: { type: "integer", minimum: 1, maximum: 2048 },
|
||
loops: { type: "integer", minimum: 0, maximum: 65535 },
|
||
hiddenFirst: { type: "boolean", description: "Frame 1 becomes a static, non-animated fallback image." },
|
||
name: { type: "string" },
|
||
},
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
const { projectId, ...settings } = ctx.input || {};
|
||
const meta = await applySettings(resolveProjectId(ctx), settings);
|
||
return publicState(meta);
|
||
},
|
||
},
|
||
{
|
||
name: "add_color_frame",
|
||
description:
|
||
"Append a solid-color frame at the project's dimensions. Useful for building simple animations programmatically. Color accepts a hex value (#ff8800) or a name like 'blue'.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string" },
|
||
color: { type: "string", description: "Hex (#rrggbb / #rrggbbaa) or a color name." },
|
||
delayMs: { type: "integer", minimum: 0, maximum: 65535, description: "Frame delay in ms. Use one timing mode only." },
|
||
delayNum: { type: "integer", minimum: 0, maximum: 65535, description: "Delay numerator; pair with delayDen. Use one timing mode only." },
|
||
delayDen: { type: "integer", minimum: 1, maximum: 65535, description: "Delay denominator (default 1000). Use one timing mode only." },
|
||
disposeOp: { type: "integer", minimum: 0, maximum: 2, description: "0=None, 1=Background, 2=Previous." },
|
||
blendOp: { type: "integer", minimum: 0, maximum: 1, description: "0=Source, 1=Over." },
|
||
},
|
||
required: ["color"],
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
const id = resolveProjectId(ctx);
|
||
const color = parseColor(ctx.input?.color);
|
||
const { color: _c, projectId: _p, ...opts } = ctx.input || {};
|
||
if (opts.delayMs == null && opts.delayNum == null && opts.delayDen == null && opts.fps == null) opts.delayMs = 120;
|
||
// Read dimensions, render, and append under one lock so a
|
||
// concurrent set_settings can't change the size between
|
||
// rendering the PNG and recording the frame.
|
||
return withProjectLock(id, async () => {
|
||
const meta = await loadProject(id);
|
||
const png = solidColorPng(meta.width, meta.height, color);
|
||
const fid = await addFrameToMeta(meta, png, opts);
|
||
await saveProject(meta);
|
||
broadcast(meta.id);
|
||
return { frameId: fid, frameCount: meta.frames.length };
|
||
});
|
||
},
|
||
},
|
||
{
|
||
name: "set_frame",
|
||
description:
|
||
"Change timing/compositing for one frame (by frameId) or every frame (all: true). Timing (choose exactly one mode): delayMs, or fps (exact frame rate), or delayNum/delayDen — combining modes is rejected. Compositing: disposeOp (0=None,1=Background,2=Previous), blendOp (0=Source,1=Over).",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string" },
|
||
frameId: { type: "string", description: "Target frame id. Omit and set all:true to apply to every frame." },
|
||
all: { type: "boolean", description: "Apply to all frames instead of a single frameId." },
|
||
delayMs: { type: "integer", minimum: 0, maximum: 65535 },
|
||
fps: { type: "integer", minimum: 1, maximum: 1000, description: "Exact frame rate; sets delay to 1/fps s." },
|
||
delayNum: { type: "integer", minimum: 0, maximum: 65535 },
|
||
delayDen: { type: "integer", minimum: 1, maximum: 65535 },
|
||
disposeOp: { type: "integer", minimum: 0, maximum: 2 },
|
||
blendOp: { type: "integer", minimum: 0, maximum: 1 },
|
||
},
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
const id = resolveProjectId(ctx);
|
||
const { projectId: _p, frameId, all, ...props } = ctx.input || {};
|
||
if (all) {
|
||
await setFramePropsAll(id, props);
|
||
} else if (frameId != null) {
|
||
await setFrameProps(id, frameId, props);
|
||
} else {
|
||
throw new CanvasError("no_target", "Provide a frameId, or set all:true to apply to every frame.");
|
||
}
|
||
return publicState(await loadProject(id));
|
||
},
|
||
},
|
||
{
|
||
name: "clear_frames",
|
||
description: "Remove all frames from the project.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: { projectId: { type: "string" } },
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
await clearFrames(resolveProjectId(ctx));
|
||
return { ok: true };
|
||
},
|
||
},
|
||
{
|
||
name: "export",
|
||
description: "Assemble the frames into an animated .png (APNG) file on disk and return its absolute path.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
projectId: { type: "string" },
|
||
filename: { type: "string", description: "Optional output filename (without directory)." },
|
||
},
|
||
additionalProperties: false,
|
||
},
|
||
handler: async (ctx) => {
|
||
return exportApng(resolveProjectId(ctx), ctx.input?.filename);
|
||
},
|
||
},
|
||
],
|
||
open: async (ctx) => {
|
||
const projectId = sanitizeId(ctx.input?.projectId ?? DEFAULT_PROJECT);
|
||
let meta = await loadProject(projectId);
|
||
if (ctx.input?.name && typeof ctx.input.name === "string") {
|
||
// Route the rename through the locked mutation path so it
|
||
// can't race a concurrent save or skip the panel broadcast.
|
||
meta = await applySettings(projectId, { name: ctx.input.name });
|
||
}
|
||
let entry = servers.get(ctx.instanceId);
|
||
if (!entry) {
|
||
entry = { instanceId: ctx.instanceId, projectId, sse: new Set() };
|
||
servers.set(ctx.instanceId, entry);
|
||
try {
|
||
await startServer(entry);
|
||
} catch (err) {
|
||
// Don't leave a half-open instance behind: a later open would
|
||
// skip startup and return an undefined URL, and onClose would
|
||
// dereference a missing server. Drop it so it can retry.
|
||
servers.delete(ctx.instanceId);
|
||
throw err;
|
||
}
|
||
} else {
|
||
// Re-open: repoint the existing server at the requested
|
||
// project in place so the loopback URL stays stable.
|
||
entry.projectId = projectId;
|
||
}
|
||
return {
|
||
title: `APNG Studio — ${meta.name}`,
|
||
url: entry.url,
|
||
status: `${meta.frames.length} frame${meta.frames.length === 1 ? "" : "s"}`,
|
||
};
|
||
},
|
||
onClose: async (ctx) => {
|
||
const entry = servers.get(ctx.instanceId);
|
||
if (!entry) return;
|
||
servers.delete(ctx.instanceId);
|
||
// End this instance's SSE streams first, otherwise server.close()
|
||
// waits on the open /events response and never resolves.
|
||
for (const res of entry.sse) {
|
||
try {
|
||
res.end();
|
||
} catch {
|
||
/* already closed */
|
||
}
|
||
}
|
||
entry.sse.clear();
|
||
// Only stop this project's LAN share when no other open panel
|
||
// still references the project, so closing one of two panels
|
||
// doesn't invalidate the other's phone link.
|
||
const stillOpen = [...servers.values()].some((e) => e.projectId === entry.projectId);
|
||
if (!stillOpen) stopShare(entry.projectId);
|
||
if (entry.server) await new Promise((resolve) => entry.server.close(() => resolve()));
|
||
},
|
||
}),
|
||
],
|
||
});
|
||
|
||
await ensureDir(EXPORTS_DIR);
|
||
log("APNG Studio ready.");
|