diff --git a/docs/PIXEL_ART_FEATURE.md b/docs/PIXEL_ART_FEATURE.md index 02a9ab9..fe45d46 100644 --- a/docs/PIXEL_ART_FEATURE.md +++ b/docs/PIXEL_ART_FEATURE.md @@ -133,6 +133,22 @@ All four phases shipped. 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. diff --git a/src/help/pixelart.md b/src/help/pixelart.md index 6effedc..4c775b5 100644 --- a/src/help/pixelart.md +++ b/src/help/pixelart.md @@ -43,6 +43,9 @@ Palette-indexed grid editor for sprite work + simple animation. between frames, playback FPS is derived from the GIF's own frame delays, and **Frames** samples a long animation down evenly. A GIF already at grid size and palette-limited imports pixel-exact. + Animations with a baked-in background keep it on every frame, even + when the file was exported as space-saving delta frames; animations + on genuine transparency stay transparent. - Selecting several image files at once imports them as consecutive frames, in pick order. - **Apply to** decides where the import lands. Current frame / New diff --git a/src/index.html b/src/index.html index a7abe2e..0fe253f 100644 --- a/src/index.html +++ b/src/index.html @@ -21338,6 +21338,7 @@ const comp = new Uint8ClampedArray(gw * gh * 4); let gce = { delayMs: 100, transparentIndex: -1, disposal: 0 }; const raw = []; + let opaqueBase = false; const HARD_FRAME_CAP = 400; while (p < d.length) { @@ -21400,9 +21401,37 @@ } raw.push({ rgba: comp.slice(), delayMs: gce.delayMs }); - if (gce.disposal === 2) { - // Restore to background — the spec's background colour, but every - // real renderer clears to transparent, and so do we. + // Does this animation open on a solid, fully painted frame? Decided + // once, from the first frame, and used by the disposal rule below. + if (raw.length === 1) { + let opaque = 0; + for (let o = 3; o < comp.length; o += 4) if (comp[o] === 255) opaque++; + opaqueBase = opaque >= (gw * gh) * 0.98; + } + + // Disposal 2 = "restore to background", which every real renderer + // implements as clearing to transparent (the spec's background colour + // is routinely garbage — plenty of files point it at an unrelated + // palette slot). + // + // We suppress that clear in exactly one situation: a full-canvas + // frame in an animation that *opened on a fully opaque frame*. Such + // an animation demonstrably has a baked-in background, so "restore to + // background" cannot coherently mean "erase to nothing" — and in + // practice these files are mis-tagged deltas, where an exporter + // marked the unchanged background as the transparent index to shrink + // each frame. Honouring the clear punches that background out of + // every later frame. + // + // Both conditions are load-bearing: + // - 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; + // - opaque-first-frame only, so an animation on real transparency — + // including our own exporter's output, which writes full-canvas + // disposal-2 frames — still clears and leaves no trail either. + const fullCanvas = ix === 0 && iy === 0 && iw === gw && ih === gh; + if (gce.disposal === 2 && !(fullCanvas && opaqueBase)) { for (let r = 0; r < ih; r++) { const gy = iy + r; if (gy < 0 || gy >= gh) continue;