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/rotationfields (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 ontotransform: 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'suseSubLayers) 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
pinactually 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 mergeoverlaySchemaandsceneSchemainto 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.mdestablishes 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. - Measurement —
useRenderedLayerSizes.tsmeasures 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 (canvasmeasureText, 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(untypedrecord<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, eventuallyoverrideCue). Worth doing, deliberately sequenced later — after cues and keyframe animation land, not before, sinceoverrideCue(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 eventualChangeunion 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/propsin place, the pre-migration value should land in a history row first — this codebase already has the<domain>_historytable 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
anchordefault 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 pt — pt 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.
- extents — content 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
pivotfield mirroring AE's Anchor Point (its own reference point, customizable per layer). Decided instead: no authoredpivotconcept whatsoever. Rotate/scale always happens around the centre of the layer's own resolvedcontentbounds — neverextentsorraster— a fixed resolver convention every consumer implements identically, not a per-layer value anyone can set or forget to set. Pinningcontentspecifically (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
pivotfield would be a second mechanism achieving what the motion graph (section 1) already achieves viapin— 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 toflowBoxbut purpose-built as a bare positional reference (no container fields likedirection/alignAcross/gap— justlayout,animations, and anidfor other layers to point at; still resolvesw/htrivially per the uniform sizing rule rather than being exempt from it — see its own subsection below). Animate thepinPoint's own rotation/scale instead of the real layer's, and the real layer inherits the composed transform around thepinPoint's position — the exact effect a custom pivot would have bought, using the mechanism that already has to exist forpin/flow, at near-zero marginal cost (it rides the same "never paint this type" exception, the same generic by-id addressing, the samelayoutpositioning already built forflowBox). - 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 axescontent— 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'sanchor: 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'sanchoronceanimationStartSchemagets 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
(
autoFindSplitsinapps/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/ConfirmRetailSplitsDtoinapps/api-v1/src/retail-studio/voiceover/, hard-coded toscenes.length - 1splits 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): liftaudioStartSchema's existing{layerId, animation, anchor: start|middle|end, offsetFrames}cross-layer-reference shape ontoanimationStartSchema, 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 mechanismpinalready 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 toease-in-cubicpapers 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 fourtheasing.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" }
}
]
}
atreplaces a bareframenumber, 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" —offsetFramescan be negative, e.g. "15 frames before the end of scene 2") andcueFraction("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, everyatre-resolves against the cues' new positions, no keyframe needs touching by hand.valuereuses themanifest | current | explicitkinds per-keyframe, as already agreed above.easingattaches to the segment running from this keyframe to the next one (Web Animations model) — meaningless, and absent, on a track's last keyframe.continuousbecomes 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 theat-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).