Files
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

190 lines
8.8 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>