Skip to content

Retail Studio Layout & Animation — Intended Shape (Working Notes)

Status: working notes, not a decision doc. This captures a long design conversation about where Retail Studio's layer/layout/animation architecture should head — the "big picture" to step back and look at, not a spec ready to implement. It deliberately did not go through the /neo-idea/neo-specification/neo-architect chain. Treat every open item below as genuinely open, not deferred-but-decided. Revisit and prune this file as things get built or change — it will drift out of date fast, same as any other snapshot of a moving target.

Related, separately-settled documents: - RETAIL_STUDIO_RENDER_ARCHITECTURE_DECISION.md — render substrate stays Remotion+React+DOM. Orthogonal to everything below; nothing here depends on or threatens that decision. - RETAIL_STUDIO_RESOLVER_ENGINE_BRIEF.md (same folder; formerly the NEO-1253 Text Engine plan, now consolidated and renamed "Resolver Engine") — covers the layout.mode reshape (M1) and the resolve→measure→solve→animate→place pipeline. Several items below (bounds naming, layout modes) are refinements on top of that brief, not a competing design. - Linear NEO-1560 — the Preset Library ticket. The keyframe work in section 4 is what its presets should eventually be authored against.


1. Architecture — the resolver system & single source of truth

Golden rule: never have two disparate mechanisms computing the same fact. Never recompute per-frame what's cheap to compute once and cache — but be precise about what that means: cue resolution and measurement are campaign-scoped (VO-dependent, content-dependent, not frame-dependent) and are worth caching once per campaign; layout and motion evaluation are frame-scoped, cheap, pure functions of (state, frame) — recomputing those every frame is already the correct choice (that's what interpolateAnimatedProps already does, deliberately, because a pure per-frame function is simpler than a cache with an invalidation story).

The resolver chain, in dependency order:

cue (time)  →  measure (size)  →  layout (space, resting box + pivot/anchor)  →  motion (per-frame)  →  place

Each stage is a pure function. Every downstream consumer — the renderer and the editor's selection overlay / manipulator — calls the same functions. That's the whole point: no consumer re-derives geometry independently.

This isn't strictly one-directional, and that's fine, but it needs saying explicitly. The moment a layer's w/h can independently be "content" per axis (section 2), measure needs a constraint from layout before it can run — a fixed width in, a wrapped height out — the same way box text already implicitly works today (fixed width constrains wrapping, which produces the height). Layout hands measure whichever axes are fixed; measure hands back whichever axes are "content". Not a new problem, just an explicit generalization of what box text already does implicitly per layer type, now happening per axis on any layer type.

Worth being precise about which part of layout this is, since it sounds like it reverses the chain above: it's the authored per-axis mode — a static schema read, "is this axis a fixed number or "content"?" — not layout's resolved position/box, which genuinely can't exist until measure returns. The schema read has no dependency on measure; the resolved box does depend on measure. So the chain diagram's order (cue → measure → layout → ...) still holds for resolution — what runs "before" measure is a config lookup, not a resolver stage producing output measure would depend on circularly.

What's already good — the model to extend, not replace: interpolateAnimatedProps (packages/retail-studio-compositions/src/interpreter/animation.ts, being extracted into a new interpreter/animate/animateLayer.ts module in the NEO-1253 worktree right now) is shared between the renderer and the builder's own selection overlay via apps/web/src/hooks/retail-studio/useLayerLayouts.ts. Proof the "one resolver, everyone reads it" pattern already works in this codebase.

The motion graph — one mechanism, not three

Scene enter/exit motion, pin, and flow membership are all the same underlying relationship — "this layer's effective transform composes its own local transform with whatever it's chained to" — not three separate mechanisms that happen to look similar. Scene motion already proves this works: a scene's own enter/exit transform already composes into every layer inside it today (optional fields — absent means identity, so an ordinary scene with no authored motion contributes nothing extra). pin and flow are the same relationship, just with a different, per-layer-declared "parent" (a pin target, a flowBox — see section 2) instead of always being the containing scene.

  • Call this the motion graph, not the layout graph — it carries motion composition all the way through, not just static position.
  • Internally, every layer's fully-composed transform resolves to a 2D affine matrix. This is purely internal to the motion resolver — authors never see or edit a matrix, only the familiar x/y/w/h/scale/rotation fields (section 2). Matrices are the right internal representation specifically because cumulative composition (parent chained into child chained into grandchild, however deep) is exactly what matrix multiplication is for — no bespoke formula needed per level of nesting — and a composed matrix maps directly onto transform: matrix(a,b,c,d,tx,ty), the actual CSS the renderer hands to the DOM either way.
  • This is not a new problem invented by pin/flow — it already exists for component sub-layers. A bespoke component's internal sub-layers (EffectEntry's useSubLayers) already need their own motion-blur vectors, distinct from the component's own, for the same reason a pinned layer needs its own vector distinct from its pin target's: a point further from a rotating parent's pivot traces a bigger arc than the parent's own centre does. A naive "reuse the parent's vector" would visibly break the moment there's any real offset. Building the motion graph's composition function once and evaluating it at frame N and frame N−1 (differencing the results) gives you the correctly-varying-by-offset motion vector as a near-free byproduct of the same function that computes position — not a second mechanism to build.
  • Real transform composition, not just position-tracking, is what pin actually needs. Re-resolving a pin target's box every frame already gets positional tracking through scale for free (the target's corner moves outward as it grows). It does not, by itself, make the pinned layer's own size/rotation change to match the target's — that needs the target's full transform composed in, which is the same "make me a child of this other layer" relationship the whole motion graph is built around.
  • Overlays are floating scenes in this same graph, not a special case. An overlay doesn't need bespoke carve-out logic to avoid inheriting scene motion — it's simply a scene-like container that (today, deliberately) never authors any motion of its own, exactly like an ordinary scene with no enter/exit. Every layer's chain just starts at "whichever scene-like container it's directly in"; overlays fit that without exception. Decided: don't merge overlaySchema and sceneSchema into one type. The motion-graph unification (both are "containers a layer's chain can start at") doesn't require merging the schemas to work, and merging would cost something real: scenes aren't just an engineering container, they're the unit the customer actually sees and understands, and — once cues exist (section 3) — every scene boundary doubles as a default cue point. Overlays have no equivalent customer-facing sequencing role. Keeping them as two schema types preserves that distinction cleanly; sharing the motion-graph composition mechanism under the hood doesn't need them to share a type. This only settles motion inheritance, though — where an overlay actually sits in time (does it get its own cue/duration, does it participate in scene sequencing at all, how it places relative to the scenes around it) is untouched by this argument and remains fully open. "Floating scene" answers what it inherits, not when or where it appears.
  • Cycle detection needs to cover the whole reference graph, not each mechanism separately. Once a layer's parent-in-the-chain can be a pin target or a flowBox or (transitively) something pinned to one of those, topological solve + cycle rejection has to consider the whole graph at once, not validate pin references and flow membership as if they were independent.

What's broken today and needs consolidating into this chain:

  • Pivot/anchor — computed independently in at least four places: layer-anchor.ts, transform3d.ts (hardcodes 'center'), motion-blur-vector.ts (explicitly documents that it ignores the layer's real anchor), layers/FitText.tsx. No shared resolver. Fix: pivot/anchor becomes part of the layout resolver's contract (section 2) — one function, every consumer reads it. This is not a minor bug: RETAIL_STUDIO_RENDER_ARCHITECTURE_DECISION.md establishes motion blur is the single most pervasive effect in the product (currently off in prod as a stopgap, about to come back), so this divergence is about to matter on nearly every layer.
  • MeasurementuseRenderedLayerSizes.ts measures live DOM (querySelectorAll('[data-rs-layer-id]') + offsetWidth/offsetHeight), independent of whatever the renderer itself measures. A second, unshared source of truth for size. NEO-1253 fixes this for text specifically (canvas measureText, no DOM round-trip); the same discipline should generalize to non-text layers eventually.
  • The "bleed" naming collision — resolved by the bounds renaming in section 2. Padding-pill overflow and the motion-blur render-buffer margin were both called "bleed" — two unrelated concepts sharing one word.
  • Campaign overrides — four incompatible mechanisms in packages/retail-studio-schema/src/zod.ts: campaignPropsSchema (untyped record<string, unknown>), campaignLayoutOverridesSchema, campaignRemovedLayerIdsSchema, campaignAddedLayersSchema — each its own shape, own growth cap, own addressing scheme. Should converge into one typed "changes" model (campaignChanges: Change[], a discriminated union: setField / setLayout / addLayer / removeLayer, eventually overrideCue). Worth doing, deliberately sequenced later — after cues and keyframe animation land, not before, since overrideCue (and whatever a keyframe override needs) can't be designed until those shapes exist. Keep front-of-mind while building sections 3 and 4 so the eventual Change union is designed against real cue/keyframe shapes, not guessed at now.

Schema versioning — a blocking prerequisite, not an optional item on this list. Every shape change this file describes — layout.mode, sizing/anchor/bounds, flowBox, the coordinate-unit change in section 2, keyframe animation — invalidates every existing authored template the instant it ships, unless old documents can be transformed forward on load. This isn't something to defer alongside this file's other open items; nothing else here is safe to ship live without it existing and working first.

schemaVersion already exists (frozen at 1) on the template document, manifest, and sticker document schemas — unused for real migration today. Intended mechanism going forward: freeze the old schema, write one dedicated upgradeVNtoVN+1 transformer, walk the chain on load, every downstream consumer only ever sees the latest shape. Bump per genuinely-structural break (old data can't parse without translation), not per PR — purely additive/optional changes need no bump and no transformer at all. Migrate-and-persist-back on write shrinks the population of old-version documents over time, so old transformers can eventually be retired once no live document needs them (same principle AE itself uses for .aep format upgrades). NEO-1253's layout.mode reshape should become this v1→v2 bump rather than the permanent inline z.preprocess compat-shim it is today — open item, not yet changed.

The migration process — needs designing now, not left implicit in "walk the chain on load"

Explicitly raised as its own concern: the transformer-chain mechanism above describes what upgrades a document's shape, but not the process around running that safely at scale — backup, rollback, validation, testing — the same rigor already required of every Supabase migration in this codebase (database-standards.md). Grounding in where this data actually lives: retail_template_versions.document/.manifest (jsonb, apps/api-v1/src/retail-studio/retail-studio.repository.ts) and campaign-level overrides in retail_creatives.props and its sibling override columns — real Postgres jsonb, not some abstract format, so the existing migration/backfill/history-table playbook applies directly rather than needing an invented process.

  • Batch, not purely lazy-on-read. "Walk the chain on load" alone means a bad transformer is only discovered one document at a time, whenever someone happens to open the wrong one — too slow to catch a systemic problem. A dedicated batch migration (dry-run first, reporting everything that fails to migrate cleanly, before any write) should run across all live data ahead of a schema bump, with lazy-on-load as the safety net for anything a batch run missed rather than the primary mechanism.
  • Backup/rollback via history, not by memory. Before overwriting document/props in place, the pre-migration value should land in a history row first — this codebase already has the <domain>_history table convention (database-standards.md) for exactly this purpose. A bad migration then rolls back by restoring the last historical row, not by trying to re-derive what the data used to look like.
  • Validation needs two layers, not one. Structural (does the migrated document still parse against the new Zod schema) is necessary but not sufficient — a document can be structurally valid post-migration and still be behaviorally wrong (e.g. a newly-required anchor default lands somewhere subtly different from the old implicit behavior). The stronger check is rendering the migrated document and visually diffing it against a render of the pre-migration document — Retail Studio's existing QC pipeline is the natural tool for this comparison, since it already exists for a related purpose.
  • Campaign overrides must migrate in lockstep with the base template, not as an afterthought. This is the sharpest risk: an override references paths/fields in the base template's old shape (e.g. "override layer X's box.x"). If the base template's shape changes independently, an unmigrated override silently goes stale or inert rather than failing loudly. Every base transformer needs a paired override-transformer, and both need testing together against real historical campaign data — not the base template in isolation, and not a synthetic override invented for the test.
  • Test against real historical fixtures, not hand-written synthetic ones — a representative sample of actual old-shape templates and their actual campaign overrides, run through the full migration, checked for both structural validity and (where feasible) visual parity.
  • This is a constraint on the schema shapes themselves, not a downstream concern to solve later. Every reshape this file proposes — layout.mode, sizing/anchor/bounds, flowBox, the coordinate-unit change, keyframe animation — should be checked against "can this actually be migrated with full fidelity from the old shape" as part of deciding on the shape, not treated as separately solvable once the shape is already locked in. The goal is nailing this process down before any of these shapes ship, specifically so a design choice doesn't get made that turns out to be impractical or impossible to migrate safely afterward.

Resolved — spun off into its own real SDD chain and Linear ticket, NEO-1657, rather than left as open items in this file: - Reusable tool, not one-off scripts. One standing CLI (dry-run default, --apply to write, per-row failure reporting, resumable), reused per version bump — not rebuilt each time. - History tables, but not uniformly. Turned out to split by table on a real, verified fact: retail_template_versions is DB-enforced immutable (a real trigger blocks changing document/manifest in place), so migrating a template is publishing a new version row, never an UPDATE — the untouched old row already is the rollback path, no backup table needed there. retail_creatives (campaign overrides) has no such net, since those columns are ordinary mutable jsonb — a new retail_creatives_history table snapshots them pre-write. - Visual-regression check is a new, purpose-built path, not a QC-pipeline mode. Verified the existing QC pipeline is entirely ffprobe/ffmpeg metadata/timing/loudness checking — no pixel-comparison capability at all — so reusing it wasn't an option. Built on @remotion/renderer's renderStill (already used elsewhere in this repo) plus a new pixelmatch dependency, advisory-only, never a hard gate.

Full detail: .architectural-plans/retail-studio-schema-migration-tooling-14082026-architectural-plan.md (NEO-1657) — implementation is in progress in its own Orca worktree as of 14/08/2026.


2. Layout resolver

Owns the full geometric contract for a layer: position, size, anchor (rotate/scale always fixed to a layer's own centre — see below, no authored pivot). Motion (section 4) only interpolates whichever of these properties carry keyframes — it doesn't own any of them. The motion graph (section 1) is what actually composes a layer's resolved transform once its own local values are known — this section is about what those local values are, not how they compose up the chain.

Shape — mode-discriminated, wrapped per-ratio at the mode level (not via a separate box sub-key — that nesting doesn't earn its keep and breaks from how the rest of the schema's discriminated unions already spread their fields flat):

{
  "mode": "absolute",
  "16:9": { "w": 150, "h": 200, "anchor": "top-centre" },
  "1:1":  { ... },
  "9:16": { ... }
}

Modes: - absolute — grid-positioned. Per-ratio, since raw positions genuinely differ by ratio. - flow — member of a flowBox (see below). - pin — anchored to another layer's resolved box. Supersedes and retires attachTo.

Sizing — uniform across every layer type, not just text: w/h per axis is either an explicit number or the literal "content" (self-measure). "extents" is not a valid sizing value — but not for the reason originally given here. (fable-verify's logic pass refuted the original "circular" framing: content is a measurement output, never an input, and extents is just content plus a fixed, declared constant — there's no feedback loop, any more than sizing-to-content itself has one.) The real reason: extents is never independently variable from content — it's always exactly content plus that same fixed constant — so w: "extents" could never produce a box that w: "content" doesn't already produce once its paint additions expand outward as normal. Offering it as a sizing mode would be a redundant spelling of "content", not a materially different box.

This one change collapses boxTextLayerSchema/pointTextLayerSchema into a single text layer type — today's two hardcoded layer types are just two fixed points in this space (both axes content, no wrap = today's point text; fixed width, content height, wrapped = today's box text). Behavior falls out of w/h mode × a multiline flag, not which layer type was authored. Generalizes cleanly to images too: w: 150, h: "content" = aspect-ratio-preserving auto height, a genuinely different (and often more useful) intent than fit: cover/contain/fill — complements it, doesn't replace it.

Coordinate units — flagged for inclusion: real px, not the normalized 0–1000 grid

Layout coordinates today live in a ratio-independent, normalized 0–LAYOUT_COORDINATE_SPACE (1000) grid (packages/retail-studio-schema/src/layout.ts) — every stored x/y/w/h is a fraction of 1000, scaled to each ratio's real pixel canvas at render/measure time (canvas.width / LAYOUT_COORDINATE_SPACE, canvas.height / LAYOUT_COORDINATE_SPACE). That convention exists for one specific reason: a layout authored once can seed every other ratio by simple proportional scaling, since the grid span is identical across ratios even though the pixel canvas isn't.

Once layout is mode-discriminated and wrapped per-ratio at the mode level (this section), that reason mostly falls away — every ratio already stores its own explicit, independently-authored values; nothing is being reprojected from one shared normalized value at the storage layer any more. The normalized grid then becomes pure indirection: every consumer still divides/multiplies by LAYOUT_COORDINATE_SPACE to get to/from real pixels (layout.ts, editOps.ts, layer-hit-rect.ts, layout-geometry.ts, useRenderedLayerSizes.ts, CanvasComponentItemLayer.tsx, at minimum), for no benefit once nothing is actually being reprojected across ratios in storage.

Proposed: store x/y/w/h in real px (each ratio's own canvas units), dropping LAYOUT_COORDINATE_SPACE from the stored schema entirely. The one place normalization still earns its keep is the authoring-time convenience of seeding a freshly-added ratio's layout from an already-authored one (e.g. add a layer in 16:9, want a sane starting point in 1:1) — that's a proportional-scaling operation using each canvas's own width/height, exactly what layout.ts's reprojection helpers already compute; it doesn't need an intermediate abstract 1000-unit space to do that, just the source and target canvas dimensions.

Decided: - Rides along with the layout.mode schemaVersion bump — one v1→v2 transformer handles both reshapes at once rather than two separate version bumps back to back. - px, not ptpt is a print unit and has no bearing on a DOM/CSS renderer. - The call-site inventory above was incomplete. A repo-wide grep for LAYOUT_COORDINATE_SPACE turns up two more groups worth flagging explicitly: - apps/web/src/lib/template-builder/animationPresets.ts and exportCorrectionBundle.ts — two more builder-side consumers beyond the six named above. - packages/retail-studio-interpreter/src/interpreters/psd/** (assemble/layout.ts, assemble/scenes.ts, assemble/point-text-*.ts, assemble/box-text-*.ts, assemble/text-background.ts, convert.ts) — the PSD-import pipeline, which assigns normalized-grid coordinates when converting a Photoshop file into a template. This is a genuinely different migration concern from the builder/renderer consumers above: it's the one place that produces fresh layout values from an external, real-pixel source (the PSD's own canvas), so it needs its own conversion-math check, not just a find-and-replace of the constant.

Bounds — three named variants (settled): - content — the tight, ink/pixel-only box. - extentscontent expanded by declared, static paint-only additions (padding, stroke, shadow spread). What a pin should be able to target when it needs "the visual edge," not just the ink. - raster — the motion-blur/effect capture margin. Per-axis, sized from the worst-case motion vector, held constant for the duration of a motion-blur window (not recomputed per frame). Purely internal — never a pin/anchor target.

anchor — a single reference point on a layer's own box, universal across all modes (including absolute, not just pin), optional, defaulting to top-left for ordinary layers. For text layers specifically, an unset anchor infers from the authored typography alignment instead of defaulting to top-left — so the common case never needs both fields kept in sync by hand.

  • Distinct from the rotate/scale origin — which, decided, is not a field at all. Originally scoped as a pivot field mirroring AE's Anchor Point (its own reference point, customizable per layer). Decided instead: no authored pivot concept whatsoever. Rotate/scale always happens around the centre of the layer's own resolved content bounds — never extents or raster — a fixed resolver convention every consumer implements identically, not a per-layer value anyone can set or forget to set. Pinning content specifically (rather than leaving the bounds variant unstated) matters: paint-only additions (padding, stroke, shadow spread) are asymmetric more often than not, and letting them shift the rotation centre would make it a function of decoration choices rather than a stable resolver convention. Reasoning:
  • A dedicated pivot field would be a second mechanism achieving what the motion graph (section 1) already achieves via pin — the golden rule this whole doc is built around explicitly warns against exactly that redundancy.
  • It would also reopen the precise bug that started this conversation: pivot/anchor computed independently in four places (layer-anchor.ts, transform3d.ts, motion-blur-vector.ts, FitText.tsx). A rarely-used, per-layer-configurable field is the shape of thing most likely to drift out of sync again, and least likely to get caught quickly given how little it'd be exercised.
  • The one real recurring need for a custom rotate/scale origin — a group of layers sharing one absolute reference point regardless of any single layer's own position — is already better served by pinning the group to a shared reference layer than by a per-layer relative pivot (an absolute point can't be expressed as "relative to my own box" without losing the "stays put when I move" property that made it useful in the first place). A single layer wanting an off-centre rotate/scale origin gets the same answer: pin it (full-parenting mode) to a pinPoint — a new, minimal non-visual layer type, sibling to flowBox but purpose-built as a bare positional reference (no container fields like direction/alignAcross/gap — just layout, animations, and an id for other layers to point at; still resolves w/h trivially per the uniform sizing rule rather than being exempt from it — see its own subsection below). Animate the pinPoint's own rotation/scale instead of the real layer's, and the real layer inherits the composed transform around the pinPoint's position — the exact effect a custom pivot would have bought, using the mechanism that already has to exist for pin/flow, at near-zero marginal cost (it rides the same "never paint this type" exception, the same generic by-id addressing, the same layout positioning already built for flowBox).
  • Grow-from/align-from (anchor) and rotate-around (the fixed centre convention) remaining different points by design is still the important thing this bullet originally protected — grow from a corner, rotate around centre, is completely ordinary — this decision doesn't change that, it just settles rotate-around as always-centre rather than leaving it as a per-layer variable.
  • Distinct from text's horizontalAlign/verticalAlign (where a line sits within the resolved content box — anchor positions the resolved box itself, externally). These only become vacuously redundant in one narrow case (single-line, both axes content — no slack left for align to act on); in every other case, including multiline + content-sized, they're fully independent and both meaningful.
  • A third, unrelated meaning of the word already exists in the schema, worth flagging alongside the two distinctions above: audioStartSchema's anchor: start|middle|end (referenced in section 3) is a temporal anchor — a point in another animation's timeline — not a spatial one at all. Same word, a third distinct concept. No proposal here to rename it (out of scope for this file), just flagging the collision now so it isn't mistaken for this section's anchor once animationStartSchema gets built on top of it.

pin mode shape (illustrative, not finalized — wrapped per-ratio like every other mode, per the Shape rule above):

{
  "mode": "pin",
  "16:9": {
    "offsetX": 0,
    "offsetY": 0,
    "w": "content",
    "h": 50,
    "anchor": "bottom-left",
    "pinTo": "layer-abc123",
    "pinToBounds": "extents",
    "pinToAnchor": "bottom-right",
    "pinType": "full"
  },
  "1:1":  { ... },
  "9:16": { ... }
}

pinTo*-prefixed fields describe the target; the bare anchor describes this layer's own point. Resolution goes through the motion graph (section 1) — pin means "compose my transform against my target's," not just "read my target's box corner." Topological solve with cycle rejection at validation (same pattern the plan already specifies, now widened to the whole reference graph, not pin alone).

pinType — decided: two values, "full" | "position", not three. "full" inherits the target's complete transform (position, scale, rotation), as if truly parented. "position" tracks the target's chosen pinToAnchor point frame-by-frame — moves if that point moves, but the pinned layer's own scale/rotation stay untouched. A third, "fixed regardless of target scale/rotation" behavior was considered and doesn't need its own value: since rotate/scale always happens around the centre of a layer's own content bounds (the pivot decision above, now settled rather than an open per-layer variable), pinType: "position" with pinToAnchor: "centre" and pinToBounds: "content" already produces exactly that "fixed" behavior for free — the target's content-centre is invariant under its own rotate/scale by construction, so pinning to it in position-mode is automatically immune to the target's scale/rotation changes. No pinType: "fixed" needed; it's a special case of "position", not a third mode.

The equivalence is specifically a content-bounds equivalence, not a general one — it does not carry over to pinToBounds: "extents" or "raster". Those boxes are content expanded by paint-only additions (padding, stroke, shadow spread) or the motion-blur capture margin, and neither is guaranteed to share content's centre once those additions are asymmetric. Pinning position-mode to an extents-or-raster "centre" is not a bug and not "almost fixed" — it's a different, legitimate reference: the pinned layer tracks that box's actual centre, which does move in a small arc as the target rotates/scales, precisely because that centre isn't the pivot. Anyone who wants the "immune to the target's rotation/scale" guarantee has to ask for pinToBounds: "content" explicitly; asking for extents/raster is asking for the box-tracking behavior instead, on purpose.

targetFraction — a pin-only unit for offsetX/offsetY/w/h, resolving the numeric-anchor gap without touching the anchor/pinToAnchor vocabulary. The named-only anchor decision above leaves one real gap: a value that needs to track an arbitrary point or span along a resizing target — a decorative notch at 75% along a variable-width price block, an underline under the first 40% of a variable-length headline — isn't on any named grid point, and a fixed-px offsetX/offsetY provably doesn't scale when the target reflows. Rather than reopening anchor with a numeric escape hatch (rejected above, for good reasons that still hold), the fix lives one level down: each of pin mode's offsetX, offsetY, w, h can individually take, instead of a plain number, { "value": number, "unit": "targetFraction" } — a scalar multiple of the pinTo target's resolved pinToBounds box (width for offsetX/w, height for offsetY/h), applied on top of wherever pinToAnchor already resolved to. Same origin convention as px offsets today (X rightward, Y downward from the resolved anchor point); unbounded, not clamped to 0–1, so a negative value reaches back past the anchor point and a value past 1 reaches beyond the target's far edge — it's a scalar multiplier of a box dimension, not a proportion, which is also why it isn't just called "fraction": rs.fraction() already means something narrower and bounded elsewhere in this schema (a 0–1 manifest-control type), and reusing that name here for an unbounded value would make the same word mean two different things depending on context.

This resolves both motivating cases with the one mechanism, each field opting in independently (not a block-wide unit flag, since a fixed-size notch wants only its offset fractional while a fixed-thickness underline wants only its width fractional):

// Notch at 75% along a variable-width price block, a fixed 4×12px tick mark
{
  "mode": "pin",
  "16:9": {
    "offsetX": { "value": 0.75, "unit": "targetFraction" },
    "offsetY": 0,
    "w": 4,
    "h": 12,
    "anchor": "top-left",
    "pinTo": "price-block",
    "pinToBounds": "content",
    "pinToAnchor": "top-left",
    "pinType": "position"
  }
}
// Underline spanning the first 40% of a variable-length headline, a fixed 3px thickness
{
  "mode": "pin",
  "16:9": {
    "offsetX": 0,
    "offsetY": 4,
    "w": { "value": 0.4, "unit": "targetFraction" },
    "h": 3,
    "anchor": "top-left",
    "pinTo": "headline",
    "pinToBounds": "content",
    "pinToAnchor": "bottom-left",
    "pinType": "position"
  }
}

Resolves away before the transform math, not a new runtime concept. At solve time, once the pinTo target's box is known (already guaranteed by the topological solve before this layer resolves), each targetFraction value is multiplied by the target's resolved width or height and replaced with a plain px number — then it enters the exact same pin/transform-composition pipeline a px-authored value always has. The unit exists only at the authoring/storage layer; by the time a frame is composed there is no such thing as a targetFraction value left to reason about. w/h in pin mode therefore become a three-way union — plain number (px), "content", or { value, unit: "targetFraction" } — and offsetX/offsetY a two-way union — plain number or the same object — fully backward-compatible, since every currently-authored value is already a plain number and stays one.

Deliberately scoped to pin only, not absolute or flow, even though the same underlying concept ("a value expressed as a fraction of a locally-relevant box") would extend to either. - absolute mode's "local box" would be the scene/canvas — i.e. resurrecting scene-relative percentage positioning as an authoring option. Nothing raised so far needs it; both motivating cases are pin-target-relative, never scene-relative. Same mechanism, undesigned until something concrete needs it. - flow mode's "local box" would be the member's own flowBox container — and unlike pin (whose target's box is resolved by the same topological solve before this layer, with existing cycle rejection), a flow member sized as a fraction of a flowBox whose own w/h is "content" is a genuine circular dependency (the same problem CSS has with percentage widths inside an auto-sized flex container, and the reason CSS needed its own extra resolution rule for it). Not a reason to reject it, just real added weight with no concrete case demanding it yet — left undesigned.

flow — decided: flowBox as a real layer type, not a scene-level flowGroups collection

The original plan's flowGroups (a scene-level array — {id, members: LayerId[], layout, flow: {direction, align, justify, gap}, zIndex} — with members referenced by id, no nesting) was compared directly against making the flow container a real layer type members point at, the same direction pin already points at its target:

flowGroups (scene-level collection) flowBox (real layer type)
"What's in this group?" One place — the group's own members list No such list; found by scanning layers for a backward reference (mitigated: Builder UI can trivially maintain a reverse index, so this is a raw-JSON-legibility cost, not an authoring-experience one)
Addressing consistency with pin A second, different mechanism Same mechanism everywhere — a layer references another layer by id, full stop
Invalid states Dual-group-membership is representable, needs an explicit rejection rule Dual-membership is structurally impossible — one layout value, one target
Group-level animation Can't express it — no animations field on scene-level metadata Free, via commonLayerFields — and via the motion graph (section 1), members compose against it automatically
Renderer plumbing Never enters the paintable layer union Needs an explicit "never paint this type" rule — but this is no more work than pin already requires, since both need the same motion-graph composition regardless
Two-sided declarations Membership declared twice (member's own layout.mode: 'flow' and the group's members list) — nothing stops them disagreeing Declared once, on the member

Decided: flowBox wins, on cohesion and structural-soundness grounds, not just preference — one addressing mechanism for every cross-layer reference in the schema, invalid states that are unrepresentable rather than validated-against, and free group-level animation. The rendering cost I'd originally called out as flowBox-specific isn't actually extra: pin already needs identical parent→child transform composition once it's built properly (see section 1), so flowBox is the second consumer of that mechanism, not a new one.

Non-painted layers (flowBox, pinPoint) get animations from commonLayerFields, but not opacity/effects — decided. Nothing is ever drawn for either type, so paint-only fields don't apply; animations does, since animating a non-painted layer's own transform is the entire point (it's what other layers compose against via the motion graph).

direction/alignAcross/alignAlong/gap/wrapLayers/alignWrap all live in flowBox's own props object — this is an existing, established convention, not a new one: layer-type-specific fields already live in a typed props sub-object per type (textLayerPropsSchema, productLayerPropsSchema, packages/retail-studio-schema/src/zod.ts:702,1008), so flowBox gets a flowBoxLayerPropsSchema the same way, rather than these fields sitting flat on the layer. Per-ratio variation is a non-issue — verified against real code, not assumed: props values already vary by ratio today via a granular, per-field rs.perRatio(...) wrapper (radius/ padding inside backgroundSchema, zod.ts:675-677, while sibling colour/alpha fields in the same object stay flat, per the comment at zod.ts:662). direction (and any other field that needs it) just gets wrapped the same way — no schema-shape conflict, nothing new to invent.

direction — decided: "row" | "column" only, no reversed variants. Reordering is already fully achievable by reordering members in the scene's layer list; CSS's reverse variants mostly solve RTL-language and authoring-convenience problems that don't apply here.

alignAcross/alignAcrossSelf and alignAlong — reuse the anchor vocabulary's tiers, axis-relative, plus the two genuinely new concepts neither anchor nor anything else in the schema has a name for. Named alignAcross/alignAlong rather than borrowing CSS's own align-items/justify-content split — reusing two unrelated words for what's structurally the same kind of thing (alignment on an axis) doesn't read as intuitive even in CSS itself, and justify separately collides with its own unrelated typographic meaning. alignAcross/ alignAcrossSelf is always the cross-axis (perpendicular to direction); alignAlong is always the main axis (along direction, container-only — no alignAlongSelf, matching real CSS flexbox precedent that main-axis distribution has no per-item override). Which physical direction each one means flips with direction, it isn't fixed to "vertical" or "horizontal": - direction: "row" → main axis horizontal (alignAlong), cross axis vertical (alignAcross). - direction: "column" → main axis vertical (alignAlong), cross axis horizontal (alignAcross). - alignAcross/alignAcrossSelf values: whichever anchor tier matches the cross-axis — top | centre | bottom | baseline for row, left | centre | right for column — plus stretch (member's cross-axis size fills the container), a genuinely new concept borrowed the same way grow was, since nothing existing expresses "fill" sizing. - alignAlong values: whichever anchor tier matches the main axis — left | right | centre for row, top | bottom | centre for column — plus space-between | space-around | space-evenly (CSS-borrowed, same precedent as grow/stretch), since main-axis space distribution has no existing equivalent anywhere in the schema either.

flowBox shape (illustrative, props nesting now reflected):

// scene.layers — flowBox is a real, non-visual layer; never painted
{ "id": "discount-row-box", "type": "flowBox", "layout": {"mode":"absolute","16:9":{...}},
  "zIndex": 2,
  "props": { "direction": "row", "alignAcross": "baseline", "alignAlong": "space-between",
             "gap": 4, "wrapLayers": false, "overflow": "show" } }
// a member points backward at it via `flowIn` (not `flowBox` — that name was already taken by
// the primitive's own `type`, exactly the collision `pinTo` avoids by not being named `pin` again)
{ "id": "prefix", "layout": {"mode":"flow","16:9":{"flowIn":"discount-row-box","w":..,"grow":0}} }

grow — CSS flex-grow, adapted. Once a flowBox computes how much main-axis space its members need at their base size (w/h) plus gaps, any leftover space (a fixed-size container bigger than its members' combined need) distributes proportionally among members by grow value — grow: 2 claims twice the leftover of a sibling's grow: 1; grow: 0 (default) never expands past base size. grow and alignAlong aren't independent: alignAlong's spacing rules only ever act on space no member's grow already claimed — if every member is grow: 0, alignAlong places the full leftover; if any member has grow > 0, it eats leftover space before alignAlong gets a turn.

wrapLayers (boolean) + alignWrap — wrapping onto multiple rows/columns. Named wrapLayers/alignWrap rather than lineWrap/wrapAlign, keeping the established word order (qualifier second, matching alignAcross/alignAlong). alignWrap is CSS align-content's job — once wrapping produces more than one row/column, alignWrap governs how those lines distribute within the container's cross-axis extent (do multiple wrapped rows clump at the top of a tall container, spread evenly, sit centred as a group). Only has any effect when wrapLayers is on and wrapping actually produced more than one line — otherwise moot.

Whether this reuses the text engine's own wrapping code — checked against the real implementation, not assumed. Verdict: same algorithmic shape, but not shared code. fable-verify read the actual NEO-1253 worktree implementation (packages/retail-studio-compositions/src/interpreter/layers/text-metrics.ts:170-235, breakLines/breakLineRanges). Both are genuinely a self-computed greedy first-fit line-breaker (not browser-delegated), which is the right shape of algorithm for flowBox too — but the actual functions take a raw text: string and tokenize it internally, and are built specifically around re-measuring each candidate line as one whole-string call rather than summing pre-measured per-word widths, because word widths are not additive (kerning shifts a word's measured width depending on what's next to it — the function's own docblock states this explicitly, text-metrics.ts:164-168). A flowBox member's width is simply additive (a box's width doesn't change depending on its neighbour) — the exact case text wrapping was deliberately built to not assume. So flowBox wrapping needs its own small (~20-line, per the check) greedy row-packing implementation — structurally the same idea, genuinely simple to build, just not a function call into the text engine's code. Worth remembering as precedent for why this is safe to build rather than a red flag: the algorithm shape isn't a novel invention, it's already proven out once in this codebase, just not literally shareable given the two domains' different measurement disciplines.

Nested flow — decided: a flowBox can be a flow member of another flowBox. Nothing in the shape prevents it (a flowBox's own layout.mode can be "flow" same as any other layer), and it's a completely ordinary thing to want (nested flex containers are routine in CSS). The failure case (a cycle) is already covered by the general motion-graph-wide cycle rejection (section 1) — no new validation needed for cycles specifically; the depth cap immediately below is the one new rule nested flow (and pin) chains actually need.

New depth cap on pin/flow chains — 5 links total, ONE combined budget, not an existing convention. Checked against the real codebase first: no existing "motion graph depth" limit was found anywhere (unsurprising — pin/flow/the motion graph aren't implemented yet, this whole mechanism is still a proposal). So this is a brand-new number being set here, not a citation of precedent. Explicitly one shared budget across a chain that mixes pin and flow links — a chain like scene → pin → flow → pin → flow → pin is 5 links deep and hits the cap, exactly the same as 5 pin links or 5 flow links alone would; pin and flow do not get separate budgets that combine to 10. Counts links (hops from one layer to its pin/flow parent), not authored layers. Rationale: if a real composition needs a chain 5 links deep, that's a sign the structure wants to be a bespoke component instead — better to fail loudly at validation than let an unbounded chain quietly degrade editor/render performance or debuggability.

What determines a member's position along the main axis — decided: scene layer-array order, not a separate concept. Since flowBox deliberately has no members list (the reason it beat flowGroups), the only remaining source of "which member comes first" is the order members appear in the scene's layers array, filtered to whichever reference this flowBox via flowIn. Fully decoupled from zIndex, which governs paint order independently — confirmed, a member later in flow order can still paint under an earlier one if zIndex says so, no new mechanism needed. CSS's order property (reorder visually without touching array order) is a possible future addition if a real need shows up; not built now, no concrete case for it yet.

A flow member's own animations apply on top of its flow-resolved position — confirmed, no new design needed. flowBox membership is just another motion-graph parent relationship (section 1), so a member's own enter/exit motion composes against its flow-resolved resting position exactly the way it already would under absolute or pin.

Three real gaps a fable-verify audit found in the above — all now decided: - "content" sizing on flowBox's main axis, resolved. Main-axis w/h: "content" sizes to the no-wrap size (sum of members' base sizes + gaps). Combining that with wrapLayers: true on the same axis is a validation error, not a silent no-op — wrapping exists to handle "not enough room," and a content-sized main axis is defined to always have exactly enough room by construction, so the combination can never mean anything; fail loudly rather than degrade quietly, consistent with this doc's stance elsewhere (the depth cap above). The cross axis was never actually in tension — its content-size is just the sum of each wrapped line's own extent plus inter-line gaps, well-defined after wrapping runs, never an input to it. Left as a smaller, deferred question: whether a wrapping flowBox ever needs a separate line-gap (CSS row-gap/column-gap-equivalent) distinct from the existing single gap between same-line members — defaulting to reusing one gap for both until a real need for independent tuning shows up, same call as direction's reverse variants. - Space-deficit case, resolved: overflow, no shrink mechanism. A fixed-size, non-wrapping flowBox smaller than its members' combined need doesn't get a CSS flex-shrink-equivalent — too much added complexity (a second proportional-distribution system mirroring grow) for a case that's usually an authoring mistake rather than a deliberate intent, especially once wrapLayers already covers the "content doesn't fit, handle it gracefully" story for anyone who wants that. Instead, a new overflow: "show" | "hide" field on flowBox's props governs the two real rendering behaviors (members extend past the bounds vs. get clipped at them — CSS overflow: visible/hidden). "Warn" is deliberately NOT a third overflow value — it's a different, orthogonal concern (should the author be alerted that this happened) from what the render actually does, and conflating them would produce an ambiguous third value (warn and show? warn and hide?). Surfacing an overflow warning is a Builder/QC-tooling concern that can fire regardless of which overflow value is set, not a schema field. - alignAcross: "baseline" for non-text members, resolved: adopt the same fallback real CSS align-items: baseline already uses, rather than restricting to text-only members. A non-text member's own bottom edge (whichever bounds the flow is resolving against — content/extents) stands in for its baseline. Not a new invention — existing, well-proven precedent, consistent with how grow/alignAlong already borrowed CSS conventions instead of inventing bespoke ones. Chosen over requiring every member be text (simpler to validate, but would reject the common icon-next-to-price-text case outright).

pinPoint — a new, minimal non-visual layer type, sibling to flowBox

Introduced specifically to replace the pivot field this section decided against (see above): a bare positional reference other layers can pin to, with none of flowBox's container semantics (no props.direction/alignAcross/gap — it doesn't arrange members, it isn't "for" containing anything). Positioned via the same absolute/pin/flow modes as any other layer, plus an id for a pinTo reference to target, plus animations (it has to be — the whole point is animating the pinPoint's own rotation/scale so a pinned real layer inherits motion around it).

Cost is smaller than a whole new mechanism, but not literally "just the type entry" — a fable-verify pass against the real codebase corrected the original framing here. Two things this section said were free aren't quite: - "No size" contradicts this section's own "uniform sizing across every layer type" rule — a pinPoint still needs some w/h resolution, even if trivially zero, rather than being genuinely exempt from the sizing contract. - "Reuses the 'never paint this type' exception already required for flowBox" is true in intent but not automatic in practice: the real codebase's only current non-visual layer (audio) is excluded via a type-level split (VisualLayer = Exclude<Layer, AudioLayer>, packages/retail-studio-schema/src/types.ts:177) plus roughly eight separate inline !== 'audio' filters scattered across the interpreter and validator, not one shared exception point — and audio is non-visual by having no layout at all, whereas flowBox/pinPoint are a new third category (positioned but never painted) the current binary split can't express as-is. pinPoint's marginal cost over flowBox is genuinely close to zero if flowBox's own implementation consolidates those scattered filters into one shared "non-painted types" predicate — but that consolidation is flowBox's cost to earn, not something this doc gets for free by assertion.

// scene.layers — pinPoint is a real, non-visual layer; never painted; no container fields
// (w/h still resolve trivially per the uniform sizing rule — see the correction above)
{ "id": "growth-origin", "type": "pinPoint", "layout": {"mode":"absolute","16:9":{...}} }
// a layer wanting a custom rotate/scale origin pins to it in full-parenting mode, and the
// pinPoint's own rotation/scale (not the real layer's) gets animated
{ "id": "price-badge", "layout": {"mode":"pin","16:9":{"pinTo":"growth-origin","pinType":"full",...}} }

Naming settled on pinPoint over AE's own term ("null") specifically because "null" already means something different and more fundamental to developers than to motion designers — reusing it here would trade one confusion for another. pinPoint mirrors flowBox's own naming pattern (<verb-root> + <shape-noun>) and reuses vocabulary (pin) already established in this section.

Decided: - flowGroups' old scene-level validation rules don't need their own persistence — they collapse into the same dangling-id check pin's pinTo already needs. pin and flow share the same base addressing/validation mechanism; only the resulting layout math differs. - align naming collision resolved, then refined further: the flowBox's own cross-axis property is alignAcross, its member-level override is alignAcrossSelf, and the main-axis property (originally going to borrow CSS's justify-content) is alignAlong — no member-level alignAlongSelf, matching real CSS flexbox precedent where main-axis distribution has no per-item override. Superseded two earlier names along the way: alignContent (rejected — collides with the content bounds concept), and alignLayers/ alignSelf/justify (rejected — reusing CSS's own align-vs-justify split for two axes never actually reads as intuitive, and justify separately collides with the unrelated typographic meaning of "justify," the same "same word, different meaning" problem flagged elsewhere in this section). alignAlong/alignAcross are self-descriptive relative to direction without needing either CSS convention memorized or a second word borrowed. - Flow/pin fields (gap, offset, etc.) do need per-ratio override, beyond the per-ratio wrapping the mode already gets structurally. - Rasterization/bounds ownership for components, settled direction: every primitive gets a pre-defined, optimized, accurate rasterization method plus content/extents bounds computation built in. A composite component that's purely a wrapper around primitives (in effect returning a snippet of template JSON) inherits both for free from its constituent primitives. A component that does genuinely bespoke rendering (not just composing primitives) must supply its own bounds/rasterization via hooks — it doesn't get the free ride. - raster bounds sizing formalization — closed, as a direct extension of the point above. raster needs no separate formalization effort: a primitive's built-in rasterization method computes its own worst-case-motion-vector raster bounds (the motion graph, section 1, already produces the per-layer motion vector as a byproduct of transform composition); a primitives-only composite combines its constituents' raster bounds the same way it inherits their content/extents; a bespoke-rendering component supplies its own via hooks, same as the other two bounds variants. One mechanism, three bounds types, no motion-blur-specific special case left over. - Pin modes — closed at two, pinType: "full" | "position", not three. See the pinType discussion above — the third, "fixed regardless of target scale/rotation" behavior collapses into "position" mode with pinToAnchor: "centre" and pinToBounds: "content", once rotate/scale-origin was itself decided to always be the content-bounds centre (see below). No third enum value needed. The equivalence is content-bounds-specific, not general — see the pinType discussion above for why extents/raster + "centre" is a different, legitimate behavior rather than a looser version of "fixed." - Pivot — closed: no authored field at all. Rotate/scale always happens around the centre of a layer's own resolved content bounds (never extents/raster — asymmetric paint-only additions must not move the rotation centre), a fixed resolver convention, not a per-layer value. The one real need for a custom rotate/scale origin (a group, or a single layer, wanting an off-centre or shared-absolute reference point) is served by pinning to a new pinPoint layer type instead — see its own subsection above. This also closes out the per-layer/per-animation, relative/absolute, and does-it-scale questions this item originally posed: none of them apply once there's no field to ask them about. - Anchor / pinToAnchor vocabulary — closed: named only, no numeric escape hatch. Every candidate use case for an off-grid numeric point either turns out to already be served by plain absolute positioning (it didn't actually need to track a resizing target) or needs something more specific than a fraction of a box anyway (glyph-level position, an angle) — meaning numeric coordinates wouldn't have solved it even if we'd added them. Retrofitting later is a real cost (a schemaVersion bump through the full migration process, section 1) but that only matters weighed against real expected demand, and there isn't any found here — so the asymmetric-migration-cost argument for including it up front doesn't hold once the demand side is this thin. Final vocabulary, a 3×4 grid: - Horizontal tier: left | centre | right. - Vertical tier: top | centre | bottom | baseline — twelve combined values (top-left, top-centre, top-right, centre-left, centre, centre-right, bottom-left, bottom-centre, bottom-right, baseline-left, baseline-centre, baseline-right). - baseline-* is text-only, validated, not just documented: a schema-level check rejects baseline-* as anchor on a non-text layer, and rejects it as pinToAnchor when the pin target isn't a text layer. Derived from font metrics at measure time (first-line baseline — see below), not an arbitrary authorable offset. - First-line baseline only — no last-line variant. Baseline anchoring's real use case (price badges, short labels) is overwhelmingly single-line; the rare case that genuinely wants last-line can already fall back to a manual offsetX/offsetY nudge rather than the schema needing a second baseline concept for it. - The one real gap this decision left — a value tracking an arbitrary point or span along a resizing target — is resolved without reopening this vocabulary at all. See targetFraction above: it lives on pin's offsetX/offsetY/w/h, one level below anchor, so the named-only grid stays exactly as decided here. - background layer type — closed: not a fresh design, reconciling existing prior art. Not a blank slate — the original NEO-1253 plan (now consolidated into RETAIL_STUDIO_RESOLVER_ENGINE_BRIEF.md, M3) already designed this in real detail: { type: "background", props: { of: LayerId[], fill, opacity?, radius?, padding?, stroke? } }, its box derived, never authored, from the union of the referenced members' boxes plus its own padding/radius, re-deriving on member move/resize, rendering nothing if every referenced member is removed. That answers the "does it need to be a distinct layer type" question on its own (yes — nothing else in the model derives its box from other layers' boxes) and the "does it track a group" question (yes, by design). Reconciling it against this file's own later decisions needs exactly two adjustments, both simplifications: - The plan's deferred groupId variant on of is now unnecessary. It existed only because M4's originally-planned flowGroups was a scene-level collection with no box or id of its own to reference. Since flowGroups is superseded by flowBox as a real layer (own id, own resolved — and potentially "content"-sized — box), referencing a flowBox's members is just referencing the flowBox's own layer id through the existing of: LayerId[]. No second field, no new union variant — the supersession removes a planned schema branch rather than adding one. - The union derives from members' extents, not content. The original plan didn't specify which bounds; extents is specifically "the visual edge, not just the ink" (see Bounds above), so a background sitting behind its members should clear their painted stroke/shadow, not just their tight content boxes — otherwise a member with an outer glow bleeds past the background meant to sit behind it. - Everything else in the original M3 design holds unchanged: real layer (so it can itself be pinned to, or pin/anchor other things, through the ordinary motion graph), fully derived rather than authored box (same category as flowBox's content-sizing), same missing-member/re-derive behavior, same paint-only-padding invariant (M1.5) applied to its own padding.

Reopened by fable-verify, deliberately deferred rather than fixed inline — revisit after this file's flow spec actually gets a proper full pass (it's been decided piecemeal — flowBox vs. flowGroups, naming, per-ratio overrides — but never walked through as a complete spec the way pin has): - ~~Pin modes' "fixed" equivalence isn't unconditional.~~ Resolved: the rotate/scale pivot is now explicitly the content-bounds centre (never extents/raster), and the pinType: "position" + pinToAnchor: "centre" "fixed" shortcut is scoped to require pinToBounds: "content" as well — see the pinType discussion and the "Pivot — closed" bullet above. - ~~The numeric-anchor rejection has a real, narrow gap.~~ Resolved: rather than reopening the named-only anchor/pinToAnchor vocabulary, the gap (an arbitrary point or span along a resizing target — a notch at 75%, an underline under the first 40%) is closed one level down — pin's offsetX/offsetY/w/h can each individually take a { value, unit: "targetFraction" } object, a scalar multiple of the pinTo target's resolved box, resolving to a plain px number before the transform math ever sees it. Deliberately scoped to pin only (not absolute's scene-relative case or flow's container-relative case, the latter a real circular-sizing hazard with no concrete need yet). See the targetFraction subsection above.


3. Cue system

(Sits under animation in dependency order — cues resolve time before motion consumes it.)

Modeled directly on the aerender-engine's retiming.jsx. The genuinely portable part is the pure math — getLayerRetimeRegions (protected vs. stretchy span breakdown) and calculateStretchFactor (source-queue → target-queue stretch mapping, with safe/hard-minimum ramping) — not the AE-specific imperative plumbing (markers, timeRemap keyframes, AVLayer manipulation), none of which survives the port.

  • Cues are authored against a template's reference timing (the reference VO it's built against) — same shape as AE, not purely word-boundary-structural, since real reference ads already exist to author against.
  • At campaign/render time, cues retime proportionally against the real per-campaign VO duration; the author can manually drag a cue afterward as an override (auto-resolve, human confirms — same pattern already used for scene splits).
  • Cue-hints are cheap, not speculative: the fuzzy word/phrase matcher (autoFindSplits in apps/web/src/lib/retail-studio/auto-split.ts) is already generic and reusable — it takes arbitrary target strings, not scene-specific ones. Only the confirm/persist path (confirmSplits/ConfirmRetailSplitsDto in apps/api-v1/src/retail-studio/voiceover/, hard-coded to scenes.length - 1 splits writing into scene durations) needs new, parallel plumbing for cues specifically. The hard part — matching — is done.
  • A smaller, cheaper, closely-related stepping stone (surfaced via RETAIL_STUDIO_RENDER_ARCHITECTURE_DECISION.md's "timing-relative choreography" finding): lift audioStartSchema's existing {layerId, animation, anchor: start|middle|end, offsetFrames} cross-layer-reference shape onto animationStartSchema, so a visual layer's animation can anchor to another layer's animation the same way audio already can. Reuses the existing resolver almost unchanged; needs one new topological resolution pass (same mechanism pin already needs, since today every layer animates in total isolation). Worth building before or alongside the full cue system, not instead of it.
  • Manual cue overrides are a per-campaign "changes" fact — should slot into the unified campaign-changes model (section 1) rather than becoming a fifth bespoke override mechanism.

Open / not yet nailed down: - [ ] The actual cue schema entity itself — distinct from sceneFramesSchema's existing key/sub frames, which are presentation markers (thumbnail selection), not retiming anchors. Expected to be reasonably straightforward to design once reached — aerender-engine gives a real reference for desired behavior, even though the specifics of the port differ. - [ ] Whether protected-vs-stretchy regions are even needed, and if so how they'd actually operate — genuinely unresolved, not just undesigned. AE's own retiming model forced a specific shape (markers, protected/stretchy span breakdown) because AE's layer/timeline model left no other option. Retail Studio owns its own render pipeline and isn't bound by that constraint — which sounds like it should make this easier, but actually removes the ready-made answer AE's constraints provided. This needs to be designed from first principles against what Retail Studio's cues actually need to do, not assumed to inherit AE's shape just because the underlying stretch-factor math is portable. - [ ] Where cue resolution actually lives in the real pipeline — likely resolve, not solve, correcting this doc's earlier framing. NEO-1253's actual stages are resolve → measure → solve → animate → place (RETAIL_STUDIO_RESOLVER_ENGINE_BRIEF.md); solve is specifically the frame-invariant spatial stage (resting position/box, topological pin/flow resolution) — it doesn't otherwise deal with time. Cue resolution is about turning authored time-references into concrete frame numbers, which is frame-invariant but not spatial, and needs to be settled before anything spatial runs (this doc's own chain has cues resolve before layout). That points at folding it into resolve (which already does other frame-invariant precompute — expanding content, resolving relative typography sizes) or giving it a new stage of its own that runs before solve, not folding it into solve itself as originally suggested here. Still just a code-structure question, not a schema/behavior one — doesn't change anything else in this section.


4. Keyframe animation concept

Moves off today's three fixed archetypes (forward / return / continuous, packages/retail-studio-schema/src/zod.ts) toward a general per-property keyframe timeline. The archetypes become convenience presets that expand into keyframes under the hood — the same relationship CSS Transitions has to @keyframes — not the only shapes motion can take.

  • Easing attaches per-segment (between adjacent keyframes), not per-whole-animation — matches the CSS/Web Animations model. The schema already has a working precedent for this in miniature: return's existing {from, to} per-ramp easing (confirmed two independent easings, not one, on adversarial review).
  • Spring becomes its own discriminated easing kind{kind: 'spring', stiffness, damping, mass?} — not a string value living in the same enum as real bezier/named curves. A spring is a physically-simulated, non-fixed-duration segment; cramming it next to ease-in-cubic papers over a real structural difference (today's schema already has to special-case it — "realized as a real spring in the per-layer ramp; everywhere else it falls back to a smooth in-out ease").
  • A keyframe's value generalizes today's animatableTargetSchema ('manifest' | 'current' | explicit) down to the per-keyframe level, not just an animation's start/end. This is what makes both drag-and-drop presets (NEO-1560) and campaign-varying values (a price box that auto-sizes differently per campaign) work correctly — a keyframe can point at "whatever this resolves to right now" rather than a value baked in at authoring time.
  • Presets (NEO-1560) are the authoring layer on top of this, not a fourth archetype. Applying a preset writes keyframes authored in relative/pointer terms so the same preset generalizes across arbitrary drop targets.
  • Pivot/anchor do not live here — see section 2. Motion only interpolates properties layout already defines; it doesn't own any geometric concept itself. The motion graph (section 1) — not this section — is what actually composes a layer's transform against its chain; this section is purely about how a single layer's own local properties change over time.
  • Hold/step easing (snap, no interpolation — AE's Hold Keyframe, CSS's steps()) is currently entirely absent from the schema. Cheap to add now as a fourth easing.kind; expensive to retrofit once presets are authored assuming only continuous curves exist.

Keyframe shape — first real draft (illustrative, depends on section 3's cue entity existing first; not yet real Zod):

{
  "property": "x",
  "keyframes": [
    {
      "at": { "mode": "cueOffset", "cue": "cue-scene1-start", "offsetFrames": 20 },
      "value": { "kind": "explicit", "value": 100 },
      "easing": { "kind": "ease-out-cubic" }
    },
    {
      "at": { "mode": "cueFraction", "fromCue": "cue-scene1-start", "toCue": "cue-scene1-end", "fraction": 0.35 },
      "value": { "kind": "manifest" },
      "easing": { "kind": "spring", "stiffness": 200, "damping": 20 }
    },
    {
      "at": { "mode": "cueOffset", "cue": "cue-scene2-end", "offsetFrames": -15 },
      "value": { "kind": "current" }
    }
  ]
}
  • at replaces a bare frame number, and always resolves against a cue — never a raw absolute frame. Two addressing modes, both surfaced directly in this conversation: cueOffset ("N frames before/after cue X" — offsetFrames can be negative, e.g. "15 frames before the end of scene 2") and cueFraction ("N% of the distance between cue X and cue Y"). This is what makes a keyframe track survive retiming: the cues move when VO duration changes, every at re-resolves against the cues' new positions, no keyframe needs touching by hand.
  • value reuses the manifest | current | explicit kinds per-keyframe, as already agreed above.
  • easing attaches to the segment running from this keyframe to the next one (Web Animations model) — meaningless, and absent, on a track's last keyframe.
  • continuous becomes its own keyframe/track kind, not a bare property track — "runs for the layer's whole lifetime, no fixed duration" doesn't fit the two-fixed-points shape above at all, so it needs a distinct kind ({"kind": "continuous", "value": ...} on the track) rather than being squeezed into the at-addressed model.

Considered and explicitly shelved: a "roaming" / "flexible" / "stretchy" keyframe (AE's roving keyframe, adapted) — a keyframe whose time is a percentage distance between its two neighboring keyframes, so squishing those neighbors shifts it proportionally. Worked through live and the reasoning didn't hold up: it only works if both neighbors are themselves fixed points to measure a distance between, but a neighbor that's a cueFraction keyframe is itself already computed relative to two cues — nesting a roaming keyframe's distance calculation on top of an already-relative neighbor doesn't have a stable thing to measure from. Shelved, not rejected — revisit only once cueFraction addressing has shipped and a concrete need for it shows up in practice, rather than trying to solve it in the abstract now.

Open / not yet nailed down: - [ ] The draft at shape above still needs to become real Zod, and needs section 3's actual cue schema entity to exist first (it's the thing cue/fromCue/toCue reference).