Skip to content

Foundations: Pipeline & Architecture

Shared across every feature — no single feature owns this content, and every feature assumes it. Read this before any Feature page.

Surfaces touched

Surface Role New / extended
packages/retail-studio-schema zod.ts union reshape (layout.mode, content item union, unified typography units); schemaVersion widened to admit 1 \| 2; typography resolver; validate.ts gating; editorModeSchema rename; cue-addressing schema (animationAtSchema), extended by the full cue system's authored-cue/retiming schema; migrations/v1-to-v2.ts (the upgradeV1ToV2 transformer, the manual migration tool's conversion logic) Extended
packages/retail-studio-compositions The resolve→measure→solve→animate→place engine; rewritten rich-text.tsx / FitText.tsx / text-metrics.ts (all three confirmed substantial, real forks needed, see Schema-Version Gating); padding-as-paint-only fixes across ~10 files; the point/box text collapse; price formatter; background layer; group flow; pins (superseding attachTo); cue resolution in the resolve stage Extended (significant)
A frozen legacy render module (name/location TBD) Complete snapshot of packages/retail-studio-compositions as it exists on main pre-this-plan, serving every schemaVersion: 1 document, permanently, never touched again except genuine v1 bugfixes New
packages/retail-studio-compositions/scripts render-stills.ts for per-hard-case still proofs and a regression safety net, extended to prove legacy-vs-v2 dispatch produces byte-identical output for a v1 document before and after the fork, and that mixed-fleet dispatch doesn't cross-contaminate Extended
apps/web One required fix: the Builder's selection-ring geometry (useRenderedLayerSizes.ts / CanvasSelectionOverlay), owned by Text Foundation's padding fix. The layout reshape keeps ratio keys where they are (layer.layout[ratio] still resolves for an absolute layer, see Positioning); the apps/web work is preserving mode on whole-layout writes and skipping it when iterating ratio keys — at minimum TemplateBuilder/editOps.ts, EditStep.tsx, and whatever CanvasLayoutManipulator/CanvasSelectionOverlay write or iterate directly. The editorModeSchema rename sweep touches useMaxLayoutMode.ts, useEffectiveLayoutMode.ts, RetailStudioLayoutModeSwitcher.tsx. TemplateBuilder also needs updating to author schemaVersion: 2 documents directly. The full authoring GUI stays out of scope Extended (narrow)
packages/retail-studio-interpreter PSD/importer emitting the new constructs (price runs, groups, backgrounds) Extended — later (after render)
apps/api-v1/src/retail-studio/voiceover/ confirmSplits/ConfirmRetailSplitsDto's confirm-and-persist path, needs new, parallel plumbing for cues specifically (Cues), alongside its existing scene-split handling Extended
apps/aerender-engine/src/retiming.jsx Reference only, read for its portable stretch-factor/region math — none of its own imperative plumbing (markers, timeRemap, AVLayer) is touched or reused directly (Cues) Read-only reference
packages/retail-studio-schema/src/scene-splits.ts autoFindSplits's fuzzy word/phrase matching, already generic, reused directly for cue-hint candidates (Cues) Extended
packages/retail-studio-compositions render-error-codes render-error-codes.ts (today a one-entry stub) gains new codes plus the layer-id-carrying mechanism for measurement/solve/animate failures (see Cross-cutting concerns below) Extended

The resolve → measure → solve → animate → place pipeline

The core is a render-time pipeline living entirely inside packages/retail-studio-compositions: four ordered stages per scene per aspect ratio, with measurement as a service the solve stage calls rather than a stage of its own.

flowchart LR
    R["① Resolve<br/>expand content → styled runs<br/>relative typography → absolute<br/>cues → frame numbers"]
    S["② Solve<br/>frame-invariant resting box per layer<br/>absolute / flow / pin / derived<br/>walked in topological order"]
    M["Measure<br/>canvas measureText<br/>ink extent, line breaks<br/><i>the only code needing a canvas</i>"]
    An["③ Animate<br/>per-frame transform + opacity<br/>walking the motion graph"]
    P["④ Place<br/>emit positioned elements<br/>browser only paints glyphs"]
    R --> S --> An --> P
    S <-. "measure(runs, widthConstraint)<br/>once the constraint is known" .-> M
  1. Resolve content — expand each content item into styled runs. Text items pass through; price items are formatted (see Price) into role-tagged runs. Resolve typography, including relative ({ ofRoot }) sizes, to absolute numbers, and resolve at-addressed cue references (Cues) to concrete frame numbers — frame-invariant precompute, not spatial, so it belongs here rather than in solve.
  2. Solve — compute each layer's frame-invariant resting box, walking the layers in the motion graph's topological order so every layer's dependencies are solved before it is: absolute layers read their authored per-ratio box; flow members are laid out by their flowBox; pin layers resolve against their target's already-solved box; derived layers (Background) take the union of their members' already-solved boxes. Measure is the service this stage calls for any layer whose size depends on its content: given the layer's styled runs and its now-known width constraint, canvas measureText returns ink extents and, for wrapped text, line breaks (each candidate line measured as one whole-string call, never summed pre-measured word widths — summing is vulnerable to kerning-pair inaccuracy at word boundaries). It's called from inside the topological walk, not before it, because the width constraint isn't always a static schema read: a stretch flow member's width is its container's, a w: { unit: "targetFraction" } text layer's width is a fraction of its pin target's, and neither exists until that upstream layer has solved. Measure is the only code in the pipeline that needs a canvas.
  3. Animate — resolve per-frame values by walking the motion graph, not a bag of independent point-to-point interpolation calls. Every layer's resolved transform is a node, pin/flowBox/background relationships are edges, and a layer only ever asks the graph for its own resolved transform, never reading another layer directly. Animate changes a layer's transform and opacity only — never its box. w/h are solve inputs, not motion (see the motion graph).
  4. Place — emit each run/layer as an absolutely-positioned element at its animated coordinate. The browser only paints glyphs at a fixed size.

Why a separate Animate stage

This stage doesn't exist in a naive solve→place read of the pipeline — every layer's box in this renderer resolves per-frame via useCurrentFrame(), so solve alone cannot be the final answer for an animating pin target, flow member, or spanning background.

The pipeline stages as function signatures

The stages above are four function calls — this is the type contract every feature's code is written against:

flowchart LR
    RS["resolveScene(document, sceneId, ratio)<br/>→ ResolvedScene"] --> SS["solveScene(scene, ratio, measurer)<br/>→ SolvedScene"]
    SS --> AS["animateScene(solvedScene, frame, fps)<br/>→ AnimatedLayer[]"]
    AS --> PL["place(animatedLayers)<br/>→ JSX"]
resolveScene(document: TemplateDocument, sceneId: SceneId, ratio: AspectRatio): ResolvedScene
solveScene(scene: ResolvedScene, ratio: AspectRatio, measurer: Measurer): SolvedScene
animateScene(solvedScene: SolvedScene, frame: number, fps: number): AnimatedLayer[]
place(animatedLayers: AnimatedLayer[]): JSX.Element

measurer wraps canvas measureText — the only parameter that ties solveScene to a real browser. resolveScene takes the whole document rather than one scene because a scene's cue frames depend on every earlier scene's duration and on the document's canvas[ratio] dimensions, neither of which the scene carries itself. It produces the resolved cue-id-to-frame-number map (Cues), and that map threads unchanged through SolvedScene into animateScene — cue resolution is resolve's job, never solve's or animate's. fps reaches animateScene because easing durations are authored in frames but springs and drift are simulated in seconds.

Why this shape

The spec's headline constraint is Firefox-preview == Chromium-render parity. Browser text layout (line boxes, align-items: baseline across mixed sizes, flex rounding) is exactly what differs between engines, so we must not depend on it — we compute layout ourselves and delegate only glyph rasterisation, which reads the same font file in both.

Canvas measureText (actualBoundingBox* / fontBoundingBox*) is font-file-derived and consistent across engines for the ink-extent family. Kerning/shaping/complex-script fidelity is not a canvas-vs-DOM gap — canvas and CSS share the same text-shaping engine within one browser. This architectural choice is what Text Foundation is actually built against — see that page for the calibrated specifics (cap-height, line-breaking).

Heads up: fontBoundingBox rounding, already has a fix

fontBoundingBox* has one confirmed cross-engine gap — Chrome rounds it to whole CSS pixels against the font file's fractional real values, a filed defect (web-platform-tests/interop#427/#159). The fix is to not read it at all: ascent, descent, cap-height, and x-height come from the font file's own hhea/OS/2 tables, read once per font and cached (Text Foundation); canvas's value is only the fallback for a font whose tables can't be read.

Hard boundaries

  • The measure/solve boundary is a hard architectural line: it lives in the renderer only, mid-pipeline, operating on a resolved Scene at a specific frame and aspect ratio. Neither packages/retail-studio-schema (validation, static resolver) nor packages/retail-studio-interpreter (the PSD importer, which runs client-side in apps/web — not server-side, and not without canvas access) ever calls it — neither operates on a resolved Scene at render time, so there's no resting frame/ratio context for measure/solve to run against, regardless of what runtime either happens to execute in.
  • The schema reshape follows make-illegal-states-unrepresentable (coding-standards): layout is a discriminated union on mode so a layer is positioned exactly one way (see Positioning); a content item is a discriminated union so it is either a text run or a price run, never both (see Text Foundation / Price). The union lists only built variants; a document using an unbuilt one fails Zod validation, with a friendly message in validate.ts — see Reserved-but-unbuilt variant gating below.

Alternative rejected at the pipeline level

Alternative Why rejected
Trust browser DOM layout + read back positions Least code, but is the status quo that breaks parity — rejected by the parity gate

(Alternatives specific to text rendering, positioning, and animation are listed within those Feature pages rather than here.)

Reserved-but-unbuilt variant gating

A single shared mechanism, used by every Feature rather than invented per-feature: validate.ts gains a reserved-but-unbuilt check — if a raw layer carries a layout.mode (or content/layer variant, or cue id) that is designed-but-not-yet-admitted, emit a clear message ("pins aren't available yet") rather than a raw discriminated-union error, extending the existing renderableLayerTypes allow-list precedent (already in validate.ts, today gating whole layer types) to variant-level fields, rather than inventing an unrelated second mechanism per feature (architecture-standards / Reuse Before Build). Single-source the admitted-vs-designed variant lists (one ALL_LAYOUT_MODES constant, one ADMITTED_LAYOUT_MODES subset, and the analogous pair for content item variants and cue validation) so promoting a variant is a one-line diff, not two files kept in sync by hand.

This pattern is what makes schemaVersion's own gating (Schema-Version Gating), Positioning's flow/pin reservation, Text Foundation's content-union text-run-only gate, and Cues' dangling-cue-reference check all the same mechanism wearing different admitted-value lists, not four separate validators.

Cross-cutting concerns

Concern Approach
Parity The whole point of self-computed layout. A cross-engine parity harness is the intended guard for this — see Text Foundation for the calibrated specifics and its current coverage. Sub-pixel antialiasing differences are accepted.
Validation All document validation at the schema boundary, never in the renderer (coding-standards / Validate at boundaries). Manifest bindings apply after validation, so a binding may only target a numeric leaf of layout — never mode, pinTo, flowIn, or of — and the binding-path allow-list in validate.ts enforces that, so a bound value can never create the cycle or dangling reference validation just ruled out (see Positioning).
Error handling Missing price value → renders nothing (no NaN, see Price); unknown currency → show code, warn (Price); background with all members missing → renders nothing (Background); pin cycle → rejected at validation (Positioning).
Performance Measurement is O(runs); line-breaking O(words), one whole-string measureText call per candidate line. Canvas measurements cached per (font, size, text) within a render.
Observability Measurement/solve/animate failures surface as render-error codes with the offending layer id. render-error-codes.ts exists today as a one-entry stub (RENDER_SERVICE_UNAVAILABLE) for an unrelated service-availability contract — this work adds new codes and the layer-id-carrying mechanism from scratch.
Accessibility N/A — output is rendered video frames, not interactive UI.