gif-export.ts
PURPOSE
Encodes a baked flipbook component into an animated GIF for Discord/doc preview. Phase-5 naive-but-correct in-house encoder; ships no LZW compression and a 216-color web-safe palette to avoid a new dependency. Not a shipping asset format — preview-only.
OWNS
GifExportOptionsinterface —tileSize,frameCount,delayMs,loop(all optional; defaults derived frombaked.bake).renderFramesForGif(def, opts)— async; renders all frames of a baked component to RGBAUint8ClampedArray[], Y-flipped to image space. Returns{ width, height, frames, delayMs }.encodeGif(width, height, frames, delayMs, loop)— sync; emits a singleUint8Arraycontaining a complete GIF89a file.- Private helpers:
flipAndCopy,quantizeFrame,encodePixelsLzwTrivial.
READS FROM
./preview-surface—PreviewSurfacefor single-layer baked components../compositor—LayerCompositorfor multi-layer baked components (baked.layers.length > 1)../component-schema—migrateBakedToV2,ComponentDeftype../shader-templates/registry—TemplateIdtype.def.baked.bake—tileSize,frameCount,fps,loopMode.def.baked.layers/.shader/.params/.palette— render inputs.
PUSHES TO
- Returns raw bytes. Has no side effects on any store, DOM, or filesystem; caller (ComponentEditor) is responsible for triggering download/upload.
- Disposes own
LayerCompositor/PreviewSurfaceinfinallyblocks.
DOES NOT
- Does not write to disk, Blob, or download anchor. Caller wraps the
Uint8Array. - Does not LZW-compress — uses a “trivial” 9-bit code stream that emits CLEAR every 253 literals to keep dictionary < 512 entries and code width fixed.
- Does not optimize the palette. Every frame is quantized to a fixed 6×6×6 web-safe cube (216 colors); remaining 40 palette slots are black.
- Does not preserve partial alpha — alpha < 128 collapses to fully transparent (index 0); ≥ 128 is opaque.
- Does not call out to
gifencor any external encoder (noted as the more-robust drop-in, deferred). - Does not handle non-baked component kinds — throws
${def.id} is not a baked componentifdef.kind !== 'baked'ordef.bakedis falsy. - Does not normalize
tNormforparameterizedmode separately fromloop— both usei / frameCount; non-loop modes usei / max(frameCount - 1, 1).
Signals
- Throw on non-baked input (only failure path).
delayCs = max(1, round(delayMs / 10))— GIF time unit is centiseconds; minimum 1cs (10ms) clamp.- Netscape 2.0 loop block emitted only when
loop === true. - Transparent color index hardcoded to 0 (the first palette slot = black).
- Graphic Control packed byte
0x05= transparent-flag set + disposal method 1 (do not dispose). - Global Color Table descriptor byte
0xf7= GCT flag + color-resolution 7 + size 7 (256 entries).
Entry points
renderFramesForGif(def, opts?)— called by ComponentEditor “Export GIF” button.encodeGif(width, height, frames, delayMs, loop)— called immediately afterrenderFramesForGifwith its returned fields.
Pattern notes
- Two-path render:
useCompositor = layers.length > 1. Multi-layer goes throughLayerCompositor.renderStack+readPixels; single-layer falls back toPreviewSurface.useTemplate+setUniforms+render+readPixels. Single-layer path resolvesshader/params/palettefromlayers[0]first, then top-levelbakedfields. tNormselection mirrors loop-mode contract elsewhere:loop/parameterizeduse the modulari / frameCount; one-shot uses inclusivei / (frameCount - 1).flipAndCopyflips Y in a single pass viasubarray+set; output is a freshUint8ClampedArray(decoupled from the GL readback buffer’s lifetime).quantizeFramequantizes RGB byround(c / 51)into a 0..5 axis and indexes viar*36 + g*6 + b. 51 ≈ 255/5.encodePixelsLzwTrivialis a degenerate LZW: start dictionary size = 258 (256 colors + CLEAR=256 + EOI=257); emit CLEAR every 253 literals so258 + 253 = 511 < 512keeps the decoder’s dictionary below the 9→10-bit bump point. Code width stays fixed at 9 bits forever. Output is larger than a real LZW stream but decodes in every spec-compliant reader.- GIF byte writer is a simple
number[]push +& 0xffmask, returned asnew Uint8Array(bytes). Sub-blocks are emitted in 255-byte chunks with a length prefix and0x00terminator after each frame. - Header byte order:
'GIF89a'magic → screen width/height shorts (little-endian viawriteShort) → packed byte → bg index → aspect ratio → GCT → optional Netscape loop → per-frame (GCE → image descriptor → LZW data → block terminator) →0x3btrailer.
EXTRACT-CANDIDATE
- Frame baking loop — the
for (let i = 0; i < frameCount; i++) { tNorm = ...; render; readPixels; flipAndCopy }pattern is shared with the PNG flipbook baker. Both compute the sametNormfromloopModeand both Y-flip GL readback to image space. AbakeFrames(def, opts)helper returning{ frames, width, height }would dedupe. flipAndCopy— generic Y-flip of an RGBA tile buffer. Reusable by any surface→image-space adapter.quantizeFrame/ 6×6×6 palette — web-safe quantization is a stable primitive. If a second consumer ever needs it (e.g. a thumbnail encoder), lift toengine/vfx-workbench/quantize.ts.encodePixelsLzwTrivial— only consumer today, but the “fixed-width 9-bit, CLEAR every 253” trick is reusable for any GIF emitter that wants to skip dictionary management.