chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-12 23:54:21 +00:00
parent 61b1e4dc84
commit 38ad3a1b9a
14 changed files with 3618 additions and 0 deletions
+6
View File
@@ -81,6 +81,12 @@
"description": "Bootstrap and run a multi-agent AI development team with named roles (Producer, Dev Team, QA). Sprint planning, brainstorm prompts with distinct agent voices, cross-chat context survival, and parallel team workflows. Based on a proven template that shipped a 30-game app in 5 days with zero human-written code.",
"version": "1.0.0"
},
{
"name": "apng-studio",
"source": "extensions/apng-studio",
"description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.",
"version": "1.0.0"
},
{
"name": "arcade-canvas",
"source": "extensions/arcade-canvas",
+19
View File
@@ -0,0 +1,19 @@
{
"name": "apng-studio",
"description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.",
"version": "1.0.0",
"author": {
"name": "Andrea Griffiths",
"url": "https://github.com/AndreaGriffiths11"
},
"keywords": [
"animated-png",
"apng",
"copilot-canvas",
"frame-animation",
"image-export",
"interactive-canvas"
],
"logo": "assets/preview.png",
"extensions": "."
}
+11
View File
@@ -0,0 +1,11 @@
# Runtime user data — per-project frames and exports the extension writes
# under its own directory at runtime. This is local state, not source.
artifacts/
# Exported animations
*.apng
# Node / editor / OS cruft
node_modules/
.DS_Store
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 octobooth-1
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+97
View File
@@ -0,0 +1,97 @@
# APNG Studio
An interactive [GitHub Copilot app](https://github.com/features/ai/github-app) **canvas extension** for building [Animated PNG (APNG)](https://wiki.mozilla.org/APNG_Specification) files from frames — draw or upload frames, tune the full practical APNG spec surface, preview live, and export an animated `.png`.
The canvas renders in a side panel; the agent can also drive it through callable actions.
<p align="center">
<img src="assets/demo.png" alt="An animated walkthrough of the APNG Studio canvas building an animation from frames" width="640" />
</p>
<p align="center"><em>APNG Studio in action — an animated PNG built with the canvas itself.</em></p>
## 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 builtin canvas (pen/eraser, fill, onionskin, "start from last frame"). Reorder, duplicate, and delete frames.
- **Perframe timing** — set the delay as an exact `numerator / denominator` fraction with a live `= N ms · N fps` readout.
- **Perframe 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 nonAPNG viewers, excluded from the animation loop (encoded as a default image with no leading `fcTL`, `num_frames = N1`).
- **Live preview** — a real animated PNG is assembled on every change and served from `/preview.png`; a **Reload** button resyncs state and rebuilds the preview.
- **Send to phone** — a **Send to phone** button opens a QR code; scan it with your phone camera (same WiFi) to open the live animation in your phone's browser and save it. Served readonly from a shortlived, tokengated 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 bytecompatible with every PNG viewer: APNGaware 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/<projectId>/`, so they survive reloads and are shared between every open panel and the agent actions. That folder is local user data and is **gitignored**.
## Agent actions
The extension exposes these callable actions on the `apng-studio` canvas:
| action | what it does |
| ----------------- | ------------------------------------------------------------------------------------------------------- |
| `get_state` | Return project settings + perframe timing/compositing and total duration. |
| `set_settings` | Update `width`/`height` (only with 0 frames), `loops`, and `hiddenFirst`. |
| `add_color_frame` | Append a solidcolor frame; accepts `delayNum`/`delayDen`/`disposeOp`/`blendOp`. |
| `set_frame` | Update one frame (`frameId`) or all (`all: true`): timing via `delayMs`/`fps`/`delayNum`+`delayDen`, plus `disposeOp`/`blendOp`. |
| `clear_frames` | Remove every frame. |
| `export` | Assemble and write the animated `.png` (APNG) to disk; returns the absolute path. |
All actions accept an optional `projectId` to target a specific animation.
## How it works
- **`extension.mjs`** — one loopback HTTP server per open canvas instance serves the renderer, JSON state, perframe PNGs, the live `/preview.png`, and mutation endpoints. ServerSent Events (`/events`) push a `changed` signal so every open panel and the preview stay in sync. **Send to phone** spins up a separate, readonly LAN server that serves only a landing page and the preview image, gated by a shortlived random token and torn down on expiry.
- **`apng.mjs`** — assembles the APNG chunk stream (`IHDR` / `acTL` / `fcTL` / `IDAT` / `fdAT` / `IEND`) with contiguous sequence numbers, plus a minimal RGBA→PNG encoder.
- **`qr.mjs`** — a small, dependencyfree QR encoder (byte mode, errorcorrection level M) used to render the **Send to phone** code. The QR is drawn into a PNG with the `apng.mjs` encoder.
- **`web/`** — the iframe UI. It talks to its server over plain HTTP; there is no privileged host bridge.
## License
[MIT](./LICENSE)
+310
View File
@@ -0,0 +1,310 @@
// APNG codec helpers (Node-side).
//
// - assembleApng(): repackages a list of already-encoded PNG frames into a
// single Animated PNG, following https://wiki.mozilla.org/APNG_Specification.
// Because every frame is already a valid PNG sharing one IHDR, we only need
// to lift each frame's IDAT stream into the default image (frame 0) or into
// `fdAT` chunks (later frames), wrapped by `acTL`/`fcTL` control chunks with
// freshly computed CRC-32s. No re-compression required.
// - encodeRgbaPng(): minimal RGBA8 PNG encoder used for agent-generated frames.
import { deflateSync } from "node:zlib";
const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
return table;
})();
function crc32(bytes) {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
function concat(chunks) {
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
function readU32(bytes, off) {
return (
((bytes[off] << 24) |
(bytes[off + 1] << 16) |
(bytes[off + 2] << 8) |
bytes[off + 3]) >>>
0
);
}
// Build a PNG chunk: [length][type][data][crc], CRC over type+data.
function chunk(type, data) {
const len = data.length;
const out = new Uint8Array(12 + len);
const view = new DataView(out.buffer);
view.setUint32(0, len);
out[4] = type.charCodeAt(0);
out[5] = type.charCodeAt(1);
out[6] = type.charCodeAt(2);
out[7] = type.charCodeAt(3);
out.set(data, 8);
view.setUint32(8 + len, crc32(out.subarray(4, 8 + len)));
return out;
}
// Parse the pieces of a PNG we care about: its IHDR data and the concatenated
// IDAT stream. Ancillary/color chunks are intentionally dropped — every frame
// shares a uniform RGBA8 IHDR so they are not needed.
function parsePng(bytes) {
for (let i = 0; i < PNG_SIGNATURE.length; i++) {
if (bytes[i] !== PNG_SIGNATURE[i]) {
throw new Error("Not a PNG (bad signature)");
}
}
let off = 8;
let ihdrData = null;
let width = 0;
let height = 0;
const idatParts = [];
while (off + 8 <= bytes.length) {
const len = readU32(bytes, off);
const type = String.fromCharCode(
bytes[off + 4],
bytes[off + 5],
bytes[off + 6],
bytes[off + 7]
);
const dataStart = off + 8;
const data = bytes.subarray(dataStart, dataStart + len);
if (type === "IHDR") {
ihdrData = data.slice();
width = readU32(data, 0);
height = readU32(data, 4);
} else if (type === "IDAT") {
idatParts.push(data.slice());
} else if (type === "IEND") {
break;
}
off = dataStart + len + 4; // skip data + CRC
}
if (!ihdrData) throw new Error("PNG missing IHDR");
if (idatParts.length === 0) throw new Error("PNG missing IDAT");
return { ihdrData, width, height, idat: concat(idatParts) };
}
function acTLChunk(numFrames, numPlays) {
const data = new Uint8Array(8);
const view = new DataView(data.buffer);
view.setUint32(0, numFrames >>> 0);
view.setUint32(4, numPlays >>> 0);
return chunk("acTL", data);
}
// dispose_op: 0 = NONE (leave as-is), 1 = BACKGROUND (clear region to transparent
// black), 2 = PREVIOUS (revert region to what it was before this frame).
// blend_op: 0 = SOURCE (overwrite region, alpha included), 1 = OVER (alpha-blend
// this frame over the current canvas contents).
function fcTLChunk(sequence, width, height, params) {
const {
delayNum = 100,
delayDen = 1000,
disposeOp = 0,
blendOp = 0,
xOffset = 0,
yOffset = 0,
} = params || {};
const data = new Uint8Array(26);
const view = new DataView(data.buffer);
view.setUint32(0, sequence >>> 0); // sequence_number
view.setUint32(4, width >>> 0); // width
view.setUint32(8, height >>> 0); // height
view.setUint32(12, xOffset >>> 0); // x_offset
view.setUint32(16, yOffset >>> 0); // y_offset
view.setUint16(20, delayNum & 0xffff); // delay_num
view.setUint16(22, delayDen & 0xffff); // delay_den
data[24] = disposeOp & 0xff; // dispose_op
data[25] = blendOp & 0xff; // blend_op
return chunk("fcTL", data);
}
function fdATChunk(sequence, idat) {
const data = new Uint8Array(4 + idat.length);
new DataView(data.buffer).setUint32(0, sequence >>> 0);
data.set(idat, 4);
return chunk("fdAT", data);
}
const clampU16 = (n, dflt = 0) => {
const v = Math.round(Number(n));
return Number.isFinite(v) ? Math.max(0, Math.min(0xffff, v)) : dflt;
};
const clampDen = (n) => {
const v = Math.round(Number(n));
return Number.isFinite(v) && v >= 1 ? Math.min(0xffff, v) : 1000;
};
const clampOp = (n, hi) => {
const v = Math.round(Number(n));
return Number.isFinite(v) ? Math.max(0, Math.min(hi, v)) : 0;
};
// Normalize a caller-supplied frame descriptor into the exact fcTL fields.
// Timing accepts either delayNum/delayDen (exact) or a delayMs shorthand
// (treated as delayNum ms over a 1000 denominator).
function frameParams(f) {
let delayNum;
let delayDen;
if (f.delayNum != null) {
delayNum = clampU16(f.delayNum, 100);
delayDen = clampDen(f.delayDen);
} else {
delayNum = clampU16(f.delayMs, 100);
delayDen = 1000;
}
return {
delayNum,
delayDen,
disposeOp: clampOp(f.disposeOp, 2),
blendOp: clampOp(f.blendOp, 1),
};
}
// The APNG spec forbids APNG_DISPOSE_OP_PREVIOUS on the first fcTL (decoders
// must treat it as BACKGROUND). Normalize it for whichever frame is composited
// first so our output is well-defined instead of relying on decoder leniency.
function firstFrameParams(f) {
const p = frameParams(f);
if (p.disposeOp === 2) p.disposeOp = 1;
return p;
}
/**
* Assemble an APNG from a list of PNG frames.
*
* @param {Array<{png: Uint8Array, delayMs?: number, delayNum?: number, delayDen?: number, disposeOp?: number, blendOp?: number}>} frames
* @param {{loops?: number, hiddenFirst?: boolean}} [options]
* loops: 0 = infinite. hiddenFirst: when true (and >=2 frames) the first frame
* becomes the static default image shown by non-APNG viewers and is NOT part
* of the animation; frames 2..N make up the loop.
* @returns {Uint8Array} APNG bytes.
*/
export function assembleApng(frames, options = {}) {
if (!Array.isArray(frames) || frames.length === 0) {
throw new Error("assembleApng requires at least one frame");
}
const loops = Math.max(0, Math.round(Number(options.loops) || 0));
const hiddenFirst = !!options.hiddenFirst && frames.length >= 2;
const parsed = frames.map((f) => parsePng(f.png));
const width = parsed[0].width;
const height = parsed[0].height;
for (const p of parsed) {
if (p.width !== width || p.height !== height) {
throw new Error(
`All frames must share dimensions (${width}x${height}); found ${p.width}x${p.height}`
);
}
}
const parts = [PNG_SIGNATURE, chunk("IHDR", parsed[0].ihdrData)];
const numFrames = hiddenFirst ? parsed.length - 1 : parsed.length;
parts.push(acTLChunk(numFrames, loops));
let seq = 0;
if (hiddenFirst) {
// Default image = frame 0, with no fcTL, so it is not animated.
parts.push(chunk("IDAT", parsed[0].idat));
for (let i = 1; i < parsed.length; i++) {
const params = i === 1 ? firstFrameParams(frames[i]) : frameParams(frames[i]);
parts.push(fcTLChunk(seq++, width, height, params));
parts.push(fdATChunk(seq++, parsed[i].idat));
}
} else {
// Frame 0 = default image AND first animation frame: fcTL then IDAT.
parts.push(fcTLChunk(seq++, width, height, firstFrameParams(frames[0])));
parts.push(chunk("IDAT", parsed[0].idat));
// Remaining frames: fcTL then fdAT (each carries a sequence number).
for (let i = 1; i < parsed.length; i++) {
parts.push(fcTLChunk(seq++, width, height, frameParams(frames[i])));
parts.push(fdATChunk(seq++, parsed[i].idat));
}
}
parts.push(chunk("IEND", new Uint8Array(0)));
return concat(parts);
}
/**
* Encode an 8-bit RGBA pixel buffer into a (non-animated) PNG.
*
* @param {number} width
* @param {number} height
* @param {Uint8Array} rgba length must be width*height*4
* @returns {Uint8Array} PNG bytes.
*/
export function encodeRgbaPng(width, height, rgba) {
if (rgba.length !== width * height * 4) {
throw new Error("rgba length does not match width*height*4");
}
const ihdr = new Uint8Array(13);
const view = new DataView(ihdr.buffer);
view.setUint32(0, width >>> 0);
view.setUint32(4, height >>> 0);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter method
ihdr[12] = 0; // interlace
// Filtered raw scanlines: one leading filter byte (0 = None) per row.
const stride = width * 4;
const raw = new Uint8Array((stride + 1) * height);
for (let y = 0; y < height; y++) {
const src = y * stride;
const dst = y * (stride + 1);
raw[dst] = 0;
raw.set(rgba.subarray(src, src + stride), dst + 1);
}
const compressed = deflateSync(raw, { level: 9 });
return concat([
PNG_SIGNATURE,
chunk("IHDR", ihdr),
chunk("IDAT", new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.length)),
chunk("IEND", new Uint8Array(0)),
]);
}
/**
* Convenience: encode a solid-color frame as a PNG.
* @param {number} width
* @param {number} height
* @param {{r:number,g:number,b:number,a:number}} color 0-255 components
*/
export function solidColorPng(width, height, color) {
const rgba = new Uint8Array(width * height * 4);
const { r, g, b, a } = color;
for (let i = 0; i < rgba.length; i += 4) {
rgba[i] = r;
rgba[i + 1] = g;
rgba[i + 2] = b;
rgba[i + 3] = a;
}
return encodeRgbaPng(width, height, rgba);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -0,0 +1,4 @@
{
"name": "apng-studio",
"version": 1
}
File diff suppressed because it is too large Load Diff
+438
View File
@@ -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 };
}
+669
View File
@@ -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();
+189
View File
@@ -0,0 +1,189 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APNG Studio</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="app-header">
<div>
<h1>APNG Studio</h1>
<p class="muted" id="subtitle">Assemble an animated PNG from frames.</p>
</div>
<div class="header-actions">
<button class="btn btn-sm" id="btn-reload"
title="Reload — re-sync frames and rebuild the preview">
<span class="reload-glyph" id="reload-glyph"></span> Reload
</button>
<span class="badge" id="mime-badge">image/apng</span>
</div>
</header>
<!-- Preview -->
<section class="card">
<div class="preview-wrap checker">
<img id="preview" alt="APNG preview" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" />
<div class="empty-preview" id="empty-preview">
<span>No frames yet</span>
<small class="muted">Upload images or draw a frame below.</small>
</div>
</div>
<div class="preview-meta">
<span id="meta-frames">0 frames</span>
<span class="dot">·</span>
<span id="meta-duration">0.0s</span>
<span class="dot">·</span>
<span id="meta-loops">loops ∞</span>
</div>
<div class="row gap">
<button class="btn btn-primary" id="btn-export">Export .png</button>
<button class="btn" id="btn-download">Download</button>
<button class="btn" id="btn-share">Send to phone</button>
<button class="btn btn-ghost" id="btn-restart" title="Restart animation">↺ Replay</button>
</div>
<p class="saved-path muted" id="saved-path" hidden></p>
<!-- Send to phone -->
<div class="share-panel" id="share-panel" hidden>
<div class="share-qr checker">
<img id="share-qr-img" alt="QR code — scan with your phone camera to open the animation" />
</div>
<div class="share-body">
<div class="share-title">Scan with your phone camera</div>
<p class="muted tiny">Your phone must be on the same WiFi as this computer.</p>
<a class="share-url" id="share-url" href="#" target="_blank" rel="noopener"></a>
<div class="share-foot">
<span class="muted tiny" id="share-expiry"></span>
<button class="btn btn-sm btn-ghost" id="btn-share-stop">Stop</button>
</div>
</div>
</div>
</section>
<!-- Settings -->
<section class="card">
<div class="section-title">Settings</div>
<div class="settings-grid">
<label class="field">
<span>Width</span>
<input type="number" id="in-width" min="1" max="2048" step="1" />
</label>
<label class="field">
<span>Height</span>
<input type="number" id="in-height" min="1" max="2048" step="1" />
</label>
<label class="field">
<span>Loops <small class="muted">(0 = ∞)</small></span>
<input type="number" id="in-loops" min="0" max="65535" step="1" />
</label>
<div class="field">
<span>First frame</span>
<label class="chk" id="hidden-first-label">
<input type="checkbox" id="in-hidden-first" /> Static fallback
</label>
</div>
</div>
<p class="muted tiny" id="dim-hint">Canvas size can only change while there are no frames.</p>
<p class="muted tiny" id="hidden-first-hint" hidden>
Frame&nbsp;1 is shown by viewers without APNG support and is not part of the loop.
</p>
<div class="subsection">
<div class="subsection-title">Apply to all frames</div>
<div class="apply-rows">
<span class="inline">
<label class="mini" for="in-delay-all">Delay</label>
<input type="number" id="in-delay-all" min="0" max="65535" step="10" value="120" />
<span class="mini">ms</span>
<button class="btn btn-sm" id="btn-delay-all">Apply</button>
</span>
<span class="inline">
<label class="mini" for="in-fps">Frame rate</label>
<input type="number" id="in-fps" min="1" max="120" step="1" value="12" />
<span class="mini">fps</span>
<button class="btn btn-sm" id="btn-fps">Set</button>
</span>
<span class="inline">
<label class="mini" for="in-dispose-all">Dispose</label>
<select id="in-dispose-all">
<option value="0">None</option>
<option value="1">Background</option>
<option value="2">Previous</option>
</select>
<label class="mini" for="in-blend-all">Blend</label>
<select id="in-blend-all">
<option value="0">Source</option>
<option value="1">Over</option>
</select>
<button class="btn btn-sm" id="btn-ops-all">Apply</button>
</span>
</div>
<details class="help">
<summary>What do dispose &amp; blend do?</summary>
<ul>
<li><b>Blend · Source</b> replaces the canvas region with this frame (alpha included). <b>Over</b> composites this frame on top of what's already there — use it with transparency to build an image up frame by frame.</li>
<li><b>Dispose · None</b> keeps the frame on screen for the next one. <b>Background</b> clears it to transparent first. <b>Previous</b> restores whatever was there before this frame.</li>
</ul>
</details>
</div>
</section>
<!-- Frames -->
<section class="card">
<div class="section-title row between">
<span>Frames</span>
<button class="btn btn-sm btn-ghost danger" id="btn-clear">Clear all</button>
</div>
<div class="frame-strip" id="frame-strip"></div>
<div class="empty-frames muted" id="empty-frames">Frames you add appear here in order.</div>
</section>
<!-- Add frames -->
<section class="card">
<div class="section-title">Add frames</div>
<div class="row gap wrap">
<button class="btn" id="btn-upload">⬆ Upload images</button>
<button class="btn" id="btn-toggle-draw">✎ Draw a frame</button>
<input type="file" id="file-input" accept="image/*" multiple hidden />
</div>
<!-- Draw panel -->
<div class="draw-panel" id="draw-panel" hidden>
<div class="draw-tools">
<label class="tool">
<span>Color</span>
<input type="color" id="draw-color" value="#3b82f6" />
</label>
<label class="tool">
<span>Size</span>
<input type="range" id="draw-size" min="1" max="48" value="6" />
</label>
<div class="tool-group" role="group" aria-label="Tool">
<button class="btn btn-sm tool-btn active" id="tool-pen" aria-pressed="true">Pen</button>
<button class="btn btn-sm tool-btn" id="tool-eraser" aria-pressed="false">Eraser</button>
</div>
<button class="btn btn-sm" id="btn-fill">Fill</button>
<button class="btn btn-sm" id="btn-clear-draw">Clear</button>
</div>
<div class="draw-options">
<label class="chk"><input type="checkbox" id="opt-onion" /> Onion skin</label>
<label class="chk"><input type="checkbox" id="opt-from-last" /> Start from last frame</label>
</div>
<div class="canvas-stage checker" id="canvas-stage">
<img id="onion-img" class="onion" alt="" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" hidden />
<canvas id="draw-canvas"></canvas>
</div>
<div class="row gap">
<button class="btn btn-primary btn-sm" id="btn-add-drawing">Add frame</button>
<button class="btn btn-sm btn-ghost" id="btn-cancel-draw">Close</button>
</div>
</div>
</section>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="app.js"></script>
</body>
</html>
+620
View File
@@ -0,0 +1,620 @@
:root {
--radius: 10px;
--radius-sm: 7px;
--gap: 12px;
--card-bg: var(--background-color-default, #ffffff);
--muted: var(--text-color-muted, #59636e);
--border: var(--border-color-default, #d1d9e0);
--fg: var(--text-color-default, #1f2328);
--accent: var(--true-color-blue, #0969da);
--danger: var(--true-color-red, #cf222e);
}
* {
box-sizing: border-box;
}
/* Ensure the `hidden` attribute always wins over element display rules. */
[hidden] {
display: none !important;
}
body {
margin: 0;
padding: 14px 14px 40px;
background: var(--background-color-default, #ffffff);
color: var(--fg);
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
font-size: var(--text-body-medium, 14px);
line-height: var(--leading-body-medium, 20px);
-webkit-font-smoothing: antialiased;
}
h1 {
font-family: var(--font-sans-display, var(--font-sans, sans-serif));
font-size: var(--text-title-medium, 18px);
font-weight: var(--font-weight-semibold, 600);
line-height: 1.2;
margin: 0;
}
.app-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--gap);
margin-bottom: 14px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.reload-glyph {
display: inline-block;
}
.reload-glyph.spinning {
animation: apng-spin 0.6s linear infinite;
}
@keyframes apng-spin {
to {
transform: rotate(360deg);
}
}
.muted {
color: var(--muted);
}
.tiny {
font-size: var(--text-caption, 11px);
}
.muted.tiny {
margin: 8px 0 0;
}
.badge {
font-family: var(--font-mono, monospace);
font-size: var(--text-code-inline, 12px);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
#subtitle {
margin: 3px 0 0;
font-size: var(--text-body-small, 12px);
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 14px;
}
.section-title {
font-weight: var(--font-weight-semibold, 600);
margin-bottom: 12px;
}
.row {
display: flex;
align-items: center;
}
.row.gap {
gap: 8px;
}
.row.wrap {
flex-wrap: wrap;
}
.row.between,
.between {
justify-content: space-between;
}
/* Buttons */
.btn {
appearance: none;
border: 1px solid var(--border);
background: var(--background-color-default, #fff);
color: var(--fg);
border-radius: var(--radius-sm);
padding: 7px 12px;
font-size: var(--text-body-small, 13px);
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease;
}
.btn:hover {
background: var(--n-2-10, rgba(0, 0, 0, 0.04));
}
.btn:active {
transform: translateY(0.5px);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-sm {
padding: 4px 9px;
font-size: var(--text-caption, 12px);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--color-white, #fff);
}
.btn-primary:hover {
filter: brightness(1.06);
background: var(--accent);
}
.btn-ghost {
background: transparent;
border-color: transparent;
}
.btn-ghost:hover {
background: var(--n-2-10, rgba(0, 0, 0, 0.05));
}
.danger {
color: var(--danger);
}
/* Preview */
.preview-wrap {
position: relative;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
min-height: 160px;
max-height: 320px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 10px;
}
#preview {
max-width: 100%;
max-height: 300px;
image-rendering: auto;
display: block;
}
#preview.pixelated {
image-rendering: pixelated;
}
.empty-preview {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
background: var(--background-color-default, #fff);
text-align: center;
}
.preview-meta {
display: flex;
align-items: center;
gap: 7px;
justify-content: center;
color: var(--muted);
font-size: var(--text-body-small, 12px);
margin: 10px 0 12px;
}
.preview-meta .dot {
opacity: 0.5;
}
.saved-path {
margin: 10px 0 0;
font-family: var(--font-mono, monospace);
font-size: var(--text-caption, 11px);
word-break: break-all;
}
/* Checkerboard for transparency */
.checker {
--checker-base: var(--background-color-default, #fff);
--checker-square: var(--border-color-default, #d9dbe0);
background-color: var(--checker-base);
background-image:
linear-gradient(45deg, var(--checker-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--checker-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--checker-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--checker-square) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
/* Send to phone */
.share-panel {
display: flex;
gap: var(--gap);
align-items: center;
margin-top: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card-bg);
}
.share-qr {
flex-shrink: 0;
padding: 8px;
border-radius: var(--radius-sm);
line-height: 0;
}
.share-qr img {
width: 132px;
height: 132px;
image-rendering: pixelated;
display: block;
}
.share-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.share-title {
font-weight: var(--font-weight-semibold, 600);
}
.share-url {
font-family: var(--font-mono, monospace);
font-size: var(--text-code-inline, 12px);
color: var(--accent);
word-break: break-all;
text-decoration: none;
margin-top: 2px;
}
.share-url:hover {
text-decoration: underline;
}
.share-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
}
/* Settings */
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-body-small, 12px);
color: var(--muted);
}
.field .inline {
display: flex;
gap: 6px;
}
input[type="number"],
input[type="text"] {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--background-color-default, #fff);
color: var(--fg);
font-size: var(--text-body-medium, 13px);
font-family: inherit;
}
input:focus-visible,
.btn:focus-visible,
select:focus-visible {
outline: 2px solid var(--color-focus-outline, var(--accent));
outline-offset: 1px;
}
/* hidden-first checkbox living inside the settings grid */
.field .chk {
color: var(--fg);
font-size: var(--text-body-small, 12px);
padding: 4px 0;
}
.field .chk.disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Settings subsection: apply-to-all controls */
.subsection {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.subsection-title {
font-size: var(--text-caption, 11px);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 600;
margin-bottom: 8px;
}
.apply-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.apply-rows .inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.apply-rows .inline input[type="number"] {
width: 68px;
}
.apply-rows select {
padding: 5px 7px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--background-color-default, #fff);
color: var(--fg);
font-size: var(--text-body-small, 12px);
font-family: inherit;
}
.mini {
font-size: 11px;
color: var(--muted);
}
.help {
margin-top: 12px;
font-size: var(--text-body-small, 12px);
}
.help summary {
cursor: pointer;
color: var(--accent);
font-size: var(--text-caption, 11px);
}
.help ul {
margin: 8px 0 0;
padding-left: 18px;
color: var(--muted);
}
.help li {
margin-bottom: 5px;
}
.help b {
color: var(--fg);
}
/* Frame strip */
.frame-strip {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
}
.frame-card {
flex: 0 0 auto;
width: 168px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--card-bg);
}
.frame-card.is-static {
border-color: var(--accent);
border-style: dashed;
}
.frame-thumb-wrap {
position: relative;
border-bottom: 1px solid var(--border);
}
.frame-thumb {
width: 100%;
height: 88px;
object-fit: contain;
display: block;
}
.static-badge {
position: absolute;
top: 5px;
left: 5px;
background: var(--accent);
color: var(--color-white, #fff);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 4px;
}
.frame-body {
padding: 7px;
display: flex;
flex-direction: column;
gap: 6px;
}
.frame-index {
font-size: var(--text-caption, 11px);
color: var(--muted);
font-weight: 600;
}
.frame-field {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.frame-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
min-width: 46px;
}
.num-in {
width: 52px !important;
padding: 3px 4px !important;
font-size: var(--text-caption, 11px) !important;
text-align: center;
}
.slash {
font-size: 11px;
}
.ms-hint {
flex-basis: 100%;
font-size: 10px;
}
.frame-select {
flex: 1;
min-width: 0;
padding: 3px 4px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--background-color-default, #fff);
color: var(--fg);
font-size: var(--text-caption, 11px);
font-family: inherit;
}
.frame-actions {
display: flex;
gap: 3px;
margin-top: 1px;
}
.icon-btn {
flex: 1;
appearance: none;
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
border-radius: 5px;
padding: 3px 0;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.icon-btn:hover {
background: var(--n-2-10, rgba(0, 0, 0, 0.05));
}
.icon-btn.danger:hover {
background: var(--true-color-red-muted, rgba(207, 34, 46, 0.1));
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.empty-frames {
font-size: var(--text-body-small, 12px);
padding: 4px 0 0;
}
/* Draw panel */
.draw-panel {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--border);
}
.draw-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.tool {
display: flex;
align-items: center;
gap: 5px;
font-size: var(--text-caption, 12px);
color: var(--muted);
}
.tool input[type="color"] {
width: 30px;
height: 26px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.tool-group {
display: inline-flex;
gap: 0;
}
.tool-group .tool-btn {
border-radius: 0;
margin-left: -1px;
}
.tool-group .tool-btn:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
margin-left: 0;
}
.tool-group .tool-btn:last-child {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.tool-btn.active {
background: var(--accent);
border-color: var(--accent);
color: var(--color-white, #fff);
}
.draw-options {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.chk {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-body-small, 12px);
cursor: pointer;
}
.canvas-stage {
position: relative;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 10px;
display: flex;
justify-content: center;
}
.canvas-stage .onion,
.canvas-stage canvas {
position: relative;
max-width: 100%;
display: block;
touch-action: none;
}
.canvas-stage .onion {
position: absolute;
inset: 0;
margin: auto;
opacity: 0.28;
pointer-events: none;
object-fit: contain;
}
#draw-canvas {
cursor: crosshair;
}
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
background: var(--fg);
color: var(--background-color-default, #fff);
padding: 8px 14px;
border-radius: 999px;
font-size: var(--text-body-small, 12px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
z-index: 50;
max-width: 90%;
text-align: center;
}
.toast[hidden] {
display: none;
}