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

  • GifExportOptions interface — tileSize, frameCount, delayMs, loop (all optional; defaults derived from baked.bake).
  • renderFramesForGif(def, opts) — async; renders all frames of a baked component to RGBA Uint8ClampedArray[], Y-flipped to image space. Returns { width, height, frames, delayMs }.
  • encodeGif(width, height, frames, delayMs, loop) — sync; emits a single Uint8Array containing a complete GIF89a file.
  • Private helpers: flipAndCopy, quantizeFrame, encodePixelsLzwTrivial.

READS FROM

  • ./preview-surfacePreviewSurface for single-layer baked components.
  • ./compositorLayerCompositor for multi-layer baked components (baked.layers.length > 1).
  • ./component-schemamigrateBakedToV2, ComponentDef type.
  • ./shader-templates/registryTemplateId type.
  • def.baked.baketileSize, 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 / PreviewSurface in finally blocks.

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 gifenc or 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 component if def.kind !== 'baked' or def.baked is falsy.
  • Does not normalize tNorm for parameterized mode separately from loop — both use i / frameCount; non-loop modes use i / 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 after renderFramesForGif with its returned fields.

Pattern notes

  • Two-path render: useCompositor = layers.length > 1. Multi-layer goes through LayerCompositor.renderStack + readPixels; single-layer falls back to PreviewSurface.useTemplate + setUniforms + render + readPixels. Single-layer path resolves shader/params/palette from layers[0] first, then top-level baked fields.
  • tNorm selection mirrors loop-mode contract elsewhere: loop/parameterized use the modular i / frameCount; one-shot uses inclusive i / (frameCount - 1).
  • flipAndCopy flips Y in a single pass via subarray + set; output is a fresh Uint8ClampedArray (decoupled from the GL readback buffer’s lifetime).
  • quantizeFrame quantizes RGB by round(c / 51) into a 0..5 axis and indexes via r*36 + g*6 + b. 51 ≈ 255/5.
  • encodePixelsLzwTrivial is a degenerate LZW: start dictionary size = 258 (256 colors + CLEAR=256 + EOI=257); emit CLEAR every 253 literals so 258 + 253 = 511 < 512 keeps 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 + & 0xff mask, returned as new Uint8Array(bytes). Sub-blocks are emitted in 255-byte chunks with a length prefix and 0x00 terminator after each frame.
  • Header byte order: 'GIF89a' magic → screen width/height shorts (little-endian via writeShort) → packed byte → bg index → aspect ratio → GCT → optional Netscape loop → per-frame (GCE → image descriptor → LZW data → block terminator) → 0x3b trailer.

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 same tNorm from loopMode and both Y-flip GL readback to image space. A bakeFrames(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 to engine/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.