Files
awesome-copilot/extensions/apng-studio/qr.mjs
T
Andrea Liliana Griffiths 0916fe444d Add APNG Studio canvas extension (#2272)
* 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>
2026-07-13 09:53:55 +10:00

439 lines
14 KiB
JavaScript

// 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 };
}