Frames after the first came in with the background punched out. The
cause is a common exporter pattern: mark the unchanged background as
the frame's transparent index so each frame compresses down to just
what moved, then tag every frame disposal 2. Those two are
contradictory - disposal 2 ("restore to background") is implemented as
a clear to transparent by every browser, which wipes the very pixels
the delta relies on showing through. The reported file also declares
its background colour as an unrelated cyan palette slot, so honouring
the spec literally is no better.
Suppress the disposal-2 clear in exactly one case: a full-canvas frame
in an animation whose first frame was fully opaque. Such a file
demonstrably has a baked-in background, so "restore to background"
cannot coherently mean "erase to nothing".
Both conditions are load-bearing, and each has a regression test:
- full-canvas only, so sprite erasure - disposal 2 on a small frame
rect, the case the method genuinely exists for - still clears and
leaves no trail over an opaque background;
- opaque-first-frame only, so animations on real transparency keep it.
That includes our own exporter's output, which also writes
full-canvas disposal-2 frames: without this condition a sprite
re-imported from an Artstr GIF trailed its previous position.
Verified on the reported 640x640 40-frame GIF (every frame now solid,
100% opaque, black background intact) and on the transparent-background
Artstr icon animation (corners still transparent on every frame).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
25 KiB
Pixel Art — editable pixel layers & raster export
Goals
Make Artstr a genuine, Nostr-native pixel-art editor. The user paints on a hard-edged pixel grid; the editable grid lives in the project JSON as compact palette-indexed data; PNG (and later GIF) are produced as exports, never as the source of truth.
The core realisation
Pixel art fits Artstr's JSON/Nostr model better than ordinary raster
editing does. A 64×64 sprite is a tiny indexed grid — compact, portable,
forkable, replaceable as a kind:30078 event. The rule:
Never store pixel art as a PNG while editing. Store it as an editable palette-indexed grid in the Artstr JSON; rasterise to PNG/GIF on export.
Two faces — the disc-design / slide pattern, generalised
Pixel art has the same dual nature as a disc design or a slide — a standalone publishable thing and an embeddable component — but generalised: because it is a layer type, its host is not one specific template, it is any design.
| Standalone design | Embeds into | As a… |
|---|---|---|
| Disc design | a disc-label sheet | disc slot |
| Slide | a slide deck | card |
| Pixel art | any design | layer |
- Standalone — a
kind:30078event,casewrap-pixelart, a "Pixel Art" layout on the Designer tab beside Custom Art / Slide / Disc Design. Forkable, editable-in-place,naddr-shareable, encryptable. - Embedded — a
pixelartlayer inside any design (cover, custom art, slide, deck card, stack card). The pixel data embeds inline in the host's event — the host stays self-contained, exactly like an embedded slide or a disc-in-slot.
Product goals
- A
pixelartlayer type — a palette-indexed pixel grid, usable in any design, rendered crisp (nearest-neighbour) at any scale. - A Pixel Editor — its own canvas-area view with a contextual pixel tool set: paint, fill, pick, palette, grid, zoom.
- A standalone Pixel Art Designer-tab layout, publishing as
casewrap-pixelart; importable into any design as a layer. - PNG export at 1× / 2× / 4× / 8×, transparency preserved.
- (Later) animation frames and GIF / sprite-sheet export.
Non-goals (v1 = Phase A)
- GIF / animation in v1 — frames are designed into the data model from day one, but the timeline UI and the (vendored) GIF encoder are Phase C.
- Whole-design → PNG export. Rasterising an arbitrary layered Artstr artboard is a separate, harder problem (the DOM-to-raster question). This spec only rasterises pixel content. General design→PNG is out of scope here.
- Image import / quantization, selection/move, dither, onion skin, symmetry, tile mode — Phase C/D.
- Embedding binary images — pixel data is always the indexed grid, never an embedded PNG blob.
- External media offload (Blossom) — inline only for v1; a manifest escape hatch is sketched for later.
Decisions (proposed — confirm before building)
- Layer-type-first. The
pixelartlayer is the core. The standalone "Pixel Art" mode is a thin wrapper — a project that is one pixel grid. - The Pixel Editor is its own canvas-area view (
#pixelEditorView), not a toolbar tool — joining#discDesignerViewand#deckSorterView. The left tool palette is reused as a contextual host showing pixel tools while it's active. - Frames from day one —
pixelArt.frames[]always exists; a v1 static sprite is simply a one-frame stack with no timeline UI. - Indexed palette + RLE, never raw RGBA. Raster is export-only.
- Additive schema — a
pixelartlayer type and apixelArtpayload object. NoSCHEMA_VERSIONbump. - Publishing is gated on a measured event-size budget (derived from the relays' NIP-11 limits), not on grid dimensions alone — see Size limits.
Implementation status
All four phases shipped.
- Phase A1 — embedded pixel layer: shipped. The
pixelartlayer type +pixelArtdata model (palette + RLE frames),renderPixelGridToCanvasand crisp rendering across the editor / preview-DOM renderers / export canvas. A#pixelEditorViewwith pencil / eraser / fill / eyedropper, a palette panel (pick / add), grid toggle, mirror X/Y, zoom, and per-stroke in-editor undo. Reachable via the ▦ tool-palette button and an "Edit pixels" button / double-click on a selected pixel layer. - Phase A2 — standalone Pixel Art design: shipped.
templateMode: 'pixelart'— a "Pixel Art" layout on the Designer tab where the editor view is the whole canvas area, bound tostate.pixelArt. Save / load / publish / fork ascasewrap-pixelart; shows in the community feed under the newpixel-artcategory. - Phase B — raster export + editor extras: shipped. Crisp PNG export at 1× / 2× / 4× / 8× / 16× / 32× with transparency and optional grid lines, sprite-sheet PNG (Grid or Row layout) for multi-frame designs, line / rectangle / ellipse tools with a Fill toggle, flip H/V + rotate 90° CW/CCW, grid resize (4–128 per side with top-left anchor and transparent fill), and a Browse-community / Import-from-file pipeline that drops a published or local pixel-art design into the current design as a new layer.
- Phase C — animation: shipped. Multi-frame
frames[]data model with a vertical timeline strip (add / duplicate / delete / drag-reorder / click-to-select frames; arrow keys cycle), playback with FPS (1–60) and Loop controls (Space to toggle, clicking the canvas stops), Aseprite-style red-prev / blue-next onion-skin toggle, sprite-sheet PNG export, and an inline GIF89a encoder for animated GIF export — palette-indexed, no vendored dependency. Multi-frame previews animate everywhere they're rendered (publish modal, community browser, feed cards, profile pages). - Phase D — conversion: shipped. "From image / GIF…" button in the pixel editor toolbar: pick any image file → live side-by-side preview as you tune W / H / Colors / fit mode (Cover / Fit / Stretch) → median-cut palette quantization → area-averaged downscale → applies into the editor as a drop-in replacement (one undo restores the prior state).
- Phase D2 — animation import: shipped. An inline GIF89a
decoder — the mirror of the encoder, no vendored dependency —
turns an animated GIF into one editor frame per GIF frame. A
browser
<img>only ever exposes frame 0 to canvas, so decoding is the only way to get at the rest. It handles LZW, interlacing, local colour tables, transparency, and all three disposal methods (frames composite over their predecessor, so partial "changed-rectangle" frames render correctly).- Disposal 2 and mis-tagged deltas. Disposal 2 ("restore to background") is implemented as a clear to transparent, as every browser does — the spec's background colour is routinely garbage, pointing at an unrelated palette slot. The clear is suppressed in exactly one case: a full-canvas frame in an animation whose first frame was fully opaque. Such a file demonstrably has a baked-in background, so "restore to background" cannot mean "erase to nothing"; in practice these are deltas whose exporter marked the unchanged background as the transparent index to shrink each frame, and honouring the clear punches the background out of every later frame. Both conditions are load-bearing — full-canvas only, so sprite erasure (disposal 2 on a small rect, the case the method exists for) still leaves no trail; opaque-first-frame only, so an animation on genuine transparency — including our own exporter's output, which also writes full-canvas disposal-2 frames — keeps its transparency and leaves no trail either.
- One shared palette across every frame, quantized from all frames together — per-frame palettes made an animation shimmer and burned through the 256-entry cap.
- Playback FPS from the source, taken as the median frame delay
(robust against the long pause frame animations often end on);
per-frame
durationMsis preserved in the payload. - Frames control evenly samples a long animation down, and the decoder bounds both frame count and working resolution so a big GIF can't exhaust memory — whatever it drops is reported, never silent.
- A GIF already at grid size with a limited palette imports pixel-exact: the downscaler switches to nearest-neighbour at 1:1 or integer ratios, and the quantizer keeps the source's exact colours when there are fewer than requested.
- Undo / redo integration. The pixel editor's local undo / redo is driven by the top-level Undo / Redo buttons while the editor is open, and falls back to the global design history when the editor is closed. Snapshots capture all frames plus the current-frame index, so an undo across a flip / rotate / resize restores everything coherently. Standalone-mode edits push debounced entries to the global stack too.
Not built (deferred to the backlog): WebP / APNG / animated-WebP export, Blossom offload + external manifest form for very large art, and animated pixel layers that drive Stack actions on click.
Why this fits the repo
What's reused
- The layer system — a
pixelartlayer moves / resizes / rotates / z-orders / locks / clips / sets opacity like any other layer; it shows in the Layers panel; it rides in every payload and preview. - The "separate canvas-area view" pattern —
#previewShellalready swaps between#sheetWrap,#discDesignerView, and#deckSorterView.#pixelEditorViewis one more. - The edit round-trip — entering the pixel editor for an embedded
layer and writing back on exit is the deck's "edit a slide → Back to
deck" pattern (
persistEditingDeckSlide/editingIndex). - The Designer-tab layout system — a
pixelarttemplateModeslots intoapplyLayout,WORKFLOW_ORDER,updateBodyClasses,normalizeTemplateMode,MODE_LABELS, exactly asslidedid. - Undo/redo — the 50-deep snapshot history already covers
state; a pixel layer's data is instate, so a paint stroke is an ordinary history entry. - Publish / fork / feed / encryption — a pixel-art design is a normal
kind:30078design; the private-publishing envelope wraps it free. - Import-as-component — pulling a published
casewrap-pixelartin as a layer mirrors the disc-design → disc-slot import flow.
Genuinely new work
- The
pixelartlayer type + the indexed-grid + RLE data model. - A shared
renderPixelGridToCanvas()— the app's first real raster/<canvas>layer (everything is DOM/SVG today). - The Pixel Editor view + the contextual pixel tool set + palette.
- A PNG export pipeline —
<canvas>.toBlob(); the app has no direct canvas raster export today (JPEG goes via PDF→PDF.js). - (Later) the frame timeline and a vendored GIF encoder.
Current-state facts the plan is built on
- Layer types today:
image,text,color,shape,qr; layers render as absolutely-positioned DOM nodes from inch coordinates. - The canvas region swaps views by mode (
#sheetWrap/#discDesignerView/#deckSorterView); the tool palette is already rendered contextually (only ▶ Present shows in the deck sorter). casewrap-*typedkind:30078events;/share/<naddr>; the community feed; categories includeslide,slide-deck.- "Export JPEG" round-trips through PDF + PDF.js — there is no direct
canvas.toBlob()raster path yet.
Data model
The pixelart layer
{
id: 'l_x1',
type: 'pixelart',
name: 'Pixel sprite',
// standard layer box — inches, on the host canvas:
x, y, w, h, rotate, opacity, z, visible, locked, clip,
pixelArt: {
width: 64, height: 64, // grid size, in cells
palette: [ // up to 256 entries, #rrggbbaa
'#00000000', // index 0 — transparent by convention
'#1a1a2eff',
'#e94560ff'
],
frames: [
{ id: 'f1', name: 'Frame 1', durationMs: 120, pixels: <RLE> }
],
loop: true, fps: 8 // animation playback (Phase C)
}
}
-
framesalways exists. A v1 static sprite has exactly one frame and no timeline UI — but the format never needs migrating when animation lands. -
pixelsis run-length-encoded, row-major: an array of[paletteIndex, runLength]pairs.pixels: [ [0,128], [1,24], [2,8], [1,24] ] // 128 transparent, 24 colour-1, 8 colour-2, 24 colour-1RLE suits pixel art's long flat runs and stays human-portable JSON, but is worse than raw for noisy art — so each frame stores whichever is smaller:
pixels(RLE[[index,count],…]) orpixelsB64(base64 of a rawUint8Arrayof indices, ~1.34 B/cell). Exactly one is present per frame. This bounds the worst case — see Size limits. -
Palette holds
#rrggbbaastrings (alpha lets index 0 be fully transparent); capped at 256 so an index is one byte. -
The layer box defaults to the grid's aspect ratio, and its W/H aspect-lock (already a layer feature) defaults on — pixels stay square; the host never stretches them.
Standalone Pixel Art design (templateMode: 'pixelart')
{
version: SCHEMA_VERSION,
templateMode: 'pixelart',
templateType: 'pixelart',
meta: { … }, // title / category / language
pixelArt: { width, height, palette, frames, loop, fps }
}
The standalone project is one pixel grid — pixelArt sits at the top
level (mirroring how a slide carries slide, a disc design carries
discDesign). Importing it into another design lifts that pixelArt
into a fresh pixelart layer; publishing a layer standalone does the
reverse. After import the two are independent (a fork).
Nostr
- Tag
casewrap-pixelart;d-tagpixelart:<title|id>(random when published privately, perPRIVATE_PUBLISHING_FEATURE.md). - New
pixel-artcategory (joiningslide,slide-deck). - Forkable, editable-in-place (NIP-33),
naddr-shareable, encryptable.
Size limits & the event budget
A pixel layer must never silently break publishing. Relays cap event
size individually (commonly 64 KB; some 128 / 256 KB), and an oversized
event is rejected by some relays and accepted by others — so the
design seems to publish but is unreliable. The budget covers the whole
signed event: every other layer, meta, tags, and ~270 B of
id / pubkey / sig.
Why the indexed grid is small
Coordinates are never stored — cells are row-major, so position is
free. Hex is stored once in palette; each cell is just a small
index. RLE then collapses flat runs. A filled 64×64 sprite stored naively
as [x, y, hex] triples is ~90 KB; the same sprite as an indexed RLE
grid is ~1–5 KB — a 20–90× difference. Dropping coordinates and
de-duplicating the hex is the whole efficiency win.
Rough sizes — encoded pixel data, one frame
RLE size is content-dependent (great for flat art, worse than raw for noise); the encoder picks the smaller of RLE / base64, bounding the worst case at ~1.34 B/cell.
| Grid | Cells | Typical (RLE) | Worst case |
|---|---|---|---|
| 16×16 | 256 | ~0.2 KB | ~0.35 KB |
| 32×32 | 1,024 | ~0.5–1.5 KB | ~1.4 KB |
| 64×64 | 4,096 | ~1–5 KB | ~5.5 KB |
| 128×128 | 16,384 | ~3–20 KB | ~22 KB |
| 256×256 | 65,536 | ~10–60 KB | ~88 KB |
Palette is minor (~12 B per colour). Animation multiplies by frame count — that is where the budget is actually spent.
The limits
- Dimensions — 256×256 hard max; 128×128 recommended ceiling; 32×32 default for a new grid. Palette ≤ 256 entries (1-byte indices).
- Event byte gate — the real guard (dimensions alone can't predict
size). Before publishing, the app measures the would-be event size
against a budget:
- The budget is the minimum
limitation.max_event_sizeadvertised via NIP-11 across the user's relay set, falling back to 64 KB when unknown. - Soft warning at ~50% of budget — suggests a smaller grid, fewer colours, or fewer frames.
- Hard block at ~90% — publishing is refused with a clear message, instead of letting the event be silently dropped.
- So a clean 256×256 publishes fine; a pathologically noisy one is caught before it half-publishes.
- The budget is the minimum
- Live readout — the Pixel Editor always shows current data size against the budget (e.g. "Sprite data: 6.2 KB / 48 KB").
- Oversized animations route to the external-
manifest/ Blossom escape hatch (Phase C); inline storage covers everything that fits.
In short: single static sprites up to ~128×128 are always safe inline; 256×256 works unless the art is pathological; multi-frame animation is the real pressure point and gets the offload path.
Rendering
One shared helper, used by the editor, every preview renderer, the feed cards, and export:
renderPixelGridToCanvas(pixelArt, frameIndex = 0) -> HTMLCanvasElement
- Decodes the frame's RLE into the
palette, draws each cell as a 1×1 fill onto an offscreen<canvas>sized exactlywidth × height. - A
pixelartlayer renders as that<canvas>placed in the layer's div,width:100%; height:100%; image-rendering: pixelated— the CSS equivalent ofimageSmoothingEnabled = false, so any up-scaling stays crisp. - The decoded canvas is memoised keyed by the frame's pixel data, so the per-render layer rebuild stays cheap.
renderCustomArtPreviewDOM(feed cards / publish preview) uses the same helper — pixel layers preview correctly everywhere.
The Pixel Editor
#pixelEditorView — a new canvas-area view, shown when:
- a standalone Pixel Art design is open, or
- the user picks "Edit pixels" on a selected
pixelartlayer (or double-clicks it) inside any other design.
It contains a zoomed, paintable pixel-grid <canvas> with its own
zoom/pan, a palette panel, and the contextual tool palette (the
left palette slot, populated with pixel tools instead of the vector
tools — exactly as the deck sorter shows only ▶ Present).
Entering / leaving follows the deck round-trip: for an embedded layer, "Edit pixels" enters; "Done" writes the grid back to the layer and returns to the host artboard. For a standalone design the editor is the project.
v1 tools (Phase A core)
Pencil paint cells with the current palette index
Eraser paint index 0 (transparent)
Fill flood-fill contiguous same-index region
Eyedropper set current index from a cell
Plus: a palette panel (swatches; add / edit / remove a colour; pick the current colour), a grid overlay toggle, mirror X / Y drawing, and the editor's own zoom (fit / 1× / + / −). Drawing supports click-and-drag; a whole drag-stroke is one undo entry (history is pushed at stroke start, not per cell).
Phase B tools
Line, Rectangle, Ellipse; whole-grid Flip H / V and Rotate 90°;
resize the grid (change width/height, anchored).
Phase C / D tools
Selection + move; dither brush; onion-skin (needs frames); symmetry; tile preview; image-import → pixelate/quantize.
Raster export
PNG first — a clean, self-contained <canvas> pipeline, independent of
the app's PDF/JPEG route:
exportPixelArtPng(pixelArt, { scale, transparentBg, includeGrid }):
native = renderPixelGridToCanvas(pixelArt, frame) // width × height
out = canvas(width*scale, height*scale)
ctx.imageSmoothingEnabled = false
ctx.drawImage(native, 0, 0, out.width, out.height)
return out.toBlob('image/png')
- Scale: 1× / 2× / 4× / 8× (nearest-neighbour always).
- Transparent background on/off; include grid lines on/off.
image/pngis the one formattoBlob()is guaranteed to support; WebP is an easy later add.
Animation export (Phase C): GIF — Canvas can't toBlob('image/gif'),
so this needs a vendored JS/WASM encoder (e.g. gif.js), fed each
frame's rendered ImageData + durationMs. Also sprite-sheet PNG
(all frames tiled) + a metadata.json, which needs no new dependency.
Phased delivery
Phase A — Pixel layer, editor core, Nostr
- The
pixelartlayer type +pixelArtdata model (RLE, frames-of-one). renderPixelGridToCanvas+ crisp layer rendering everywhere (editor artboard, preview-DOM renderer, feed cards).- The
#pixelEditorViewwith the core tools (pencil / eraser / fill / eyedropper), palette panel, grid toggle, mirror, editor zoom. - "Add Layer → Pixel Art"; the per-layer "Edit pixels" round-trip.
- The standalone "Pixel Art" Designer-tab layout (
templateMode: 'pixelart'). - Save / load / publish / fork as
casewrap-pixelart; feed card;pixel-artcategory;pixelartinMODE_LABELS/ mode filters.
Ship gate: paint a 32×32 sprite, see it render crisp as a layer in a Custom Art design and as a standalone design; save, reload, publish — it round-trips and shows in the feed.
Phase B — Raster export + editor extras
- PNG export at 1×/2×/4×/8×, transparency + optional grid.
- Line / rectangle / ellipse tools; flip H/V; rotate 90°; grid resize.
- Import a published
casewrap-pixelartdesign as a layer (the disc-design-style remix flow).
Ship gate: export a sprite as a transparent 8× PNG; import a published pixel design into another design as a layer.
Phase C — Animation
frames[]timeline UI: add / duplicate / delete / reorder frames, per-frame duration, loop/fps; onion-skin; playback preview.- GIF export (vendored encoder); sprite-sheet PNG + metadata export.
Ship gate: build a 4-frame loop, preview it, export a working GIF and a sprite sheet.
Phase D — Conversion & advanced tools
- Image import → nearest-neighbour downscale / palette quantization → editable grid.
- Selection + move; dither brush; symmetry mode; tile preview.
Later / backlog
- WebP / APNG / animated-WebP export.
- Blossom offload + an external
manifestform for very large art. - Animated pixel layers driving Stack actions (a sprite that plays on
onClick).
Risks & tradeoffs
| Risk | Mitigation |
|---|---|
First real raster/<canvas> layer in a DOM-vector app |
One shared renderPixelGridToCanvas; image-rendering: pixelated; the layer is still a normal DOM node hosting a <canvas>. |
| No direct canvas raster export exists today | PNG export of pixel content is self-contained (toBlob), independent of the PDF/JPEG route; whole-design PNG is explicitly out of scope. |
| GIF needs a new vendored dependency | Deferred to Phase C; isolated to export; sprite-sheet export (no dependency) ships alongside as the lighter option. |
| Per-pixel undo would swamp the 50-deep history | A whole drag-stroke / fill / transform is one pushHistory entry, taken at gesture start. |
| Large grids / many frames inflate the event → silent relay drops | Encoded size is gated against a NIP-11-derived budget (min max_event_size across the user's relays, 64 KB fallback): soft warning ~50%, hard block ~90%; the encoder stores the smaller of RLE / base64; oversized animations offload via Blossom (Phase C). See Size limits. |
| Pixels stretched / blurred by a non-matching layer box | Layer box defaults to the grid aspect; W/H aspect-lock defaults on; image-rendering: pixelated everywhere including print. |
| A pixel layer inside a host's PDF/print export | Verify the print path rasterises the <canvas> crisply; fall back to a high-scale PNG draw if needed. |
| Palette index overflow | Palette capped at 256 entries (one byte per index). |
Open questions
templateMode: 'pixelart'on the Designer tab — confirmed?- v1 core tool set — pencil / eraser / fill / eyedropper + palette / grid / mirror / zoom — right cut?
- New-grid size options — 32×32 default is set; which other presets to offer (16 / 32 / 64 / 128 / custom)?
- Editor zoom — reuse the bottom
#canvasToolbarslot, or a dedicated pixel-editor zoom control? - GIF encoder — OK to vendor one (
gif.jsor similar) in Phase C? - Budget thresholds — soft 50% / hard 90% — the right percentages, and is 64 KB the right NIP-11 fallback?
Acceptance summary
Phase A
- A
pixelartlayer renders crisp (nearest-neighbour) at any scale, in the editor, previews, and feed cards. - The Pixel Editor view paints with pencil / eraser / fill / eyedropper; palette add/edit/remove; grid + mirror; editor zoom.
- "Add Layer → Pixel Art" works in any design; "Edit pixels" enters the editor and "Done" writes back.
- A standalone Pixel Art design saves / loads / publishes / forks as
casewrap-pixelartand shows in the feed underpixel-art. - A drag-stroke is a single undo step.
- Publishing is gated on the event-size budget — a live size readout in the editor, a soft warning, then a hard block — so a pixel design can never be silently dropped by relays.
Phase B
- PNG export at 1×/2×/4×/8× with transparency and optional grid.
- Line / rect / ellipse, flip, rotate, grid resize.
- A published pixel design imports into any design as a layer.
Phase C
- A multi-frame sprite plays back; onion-skin works.
- GIF export and sprite-sheet export both produce correct files.
Phase D
- An imported PNG/JPEG converts to an editable pixel grid.
- An animated GIF imports as one editor frame per GIF frame, sharing one palette, at the source's own playback rate.
- Selection/move and the advanced brushes work.