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
- Resolve content — expand each
contentitem 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 resolveat-addressed cue references (Cues) to concrete frame numbers — frame-invariant precompute, not spatial, so it belongs here rather than insolve. - 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:
absolutelayers read their authored per-ratio box;flowmembers are laid out by theirflowBox;pinlayers resolve against their target's already-solved box;derivedlayers (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, canvasmeasureTextreturns 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: astretchflow member's width is its container's, aw: { 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. - 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/hare solve inputs, not motion (see the motion graph). - 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) norpackages/retail-studio-interpreter(the PSD importer, which runs client-side inapps/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):layoutis a discriminated union onmodeso a layer is positioned exactly one way (see Positioning); acontentitem 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 invalidate.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. |