feat(studio): html-backed motion panel#873
Conversation
…nt attributes Re-architects the motion panel to store GSAP motion data as a JSON data attribute (data-hf-studio-motion) on each element instead of a .hyperframes/studio-motion.json sidecar file. Follows the same pattern as position/resize/rotation edits: write to DOM, build patches, persist to HTML source via commitPositionPatchToHtml. Render pipeline: the studioPositionSeekReapplyRuntime now queries [data-hf-studio-motion] elements after each seek, parses their JSON, builds a GSAP timeline, and seeks it to the current frame time. Studio preview: motion reapply is integrated into the manual edits seek hook (reapplyPositionEditsAfterSeek). useManifestPersistence is slimmed to only handle save queue and seek hooks.
vanceingalls
left a comment
There was a problem hiding this comment.
PR re-architects the studio motion panel to bake GSAP motion as a JSON data-hf-studio-motion attribute on each element instead of a .hyperframes/studio-motion.json sidecar. Same architectural direction as #829 (HTML-baked positions/rotation) — good direction, removes a persistence axis. Required CI is green at the time of review (regression-shards pending, optional Windows + WIP shards skip). Pulled the rubric (heygen-skills/skills/pr-review/SKILL.md). No prior reviews to be additive to.
Calibrated strengths
- The new render-time runtime in
manualEditsRenderScript.ts(lines 80-167) is correctly defensive: gates on[data-hf-studio-motion]presence, JSON.parse wrapped in try/catch, finite-number/object-shape validation, swallows malformed entries silently. Good shape for a script that runs at render with no error reporting. - Capturing
lastSeekTimeinside the seek wrap (manualEditsRenderScript.ts:227) before delegating to GSAP — correct ordering; the rebuilt timeline picks up the new target time deterministically. clearStudioMotionFromElementinstudioMotionOps.tscleanly restores transform/opacity/visibility from the threedata-hf-studio-motion-original-*attributes before removing them — symmetric to the write path. The render-script's restore loop (legacystudioMotionRenderScript.ts:156-165) uses the same convention, so attribute names stay shared.
Findings
blocker — JSON attribute values are not HTML-escaped before being written into source HTML
Where: manualEditsDom.ts buildMotionPatches (constructs { type: "attribute", property: STUDIO_MOTION_ATTR, value: motionJson }) → useDomEditCommits.ts:150 applyPatchByTarget → sourcePatcher.ts patchAttributeByTarget / patchAttribute (lines ~292-365 on PR head).
Failure mode: JSON.stringify(payload) produces strings delimited by ". The patcher writes the value verbatim with ${fullAttr}="${value}". For a concrete payload {"start":0,"duration":1,"ease":"power3.out","from":{"x":40},"to":{"x":0}}, the patcher produces:
data-hf-studio-motion="{"start":0,"duration":1,"ease":"power3.out","from":{"x":40},"to":{"x":0}}"
The HTML parser closes the attribute at the first inner " — the persisted element ends up with data-hf-studio-motion="{" plus a series of bareword attributes (start, duration, ease, …) and a stray }". On reload, element.getAttribute(STUDIO_MOTION_ATTR) returns "{", JSON.parse fails, the new HTML-baked motion silently no-ops at render. The element is also corrupted by spurious attributes that could collide with user attribute names.
The same pattern affects STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR (any user transform containing " — rare but possible with url(...) references — would corrupt the same way).
Why no CI catch: the build/lint/typecheck checks don't exercise the persisted file; the CLI smoke test renders from a fresh in-memory bundle, not a post-commit reload. PR description's unchecked test items 2 and 3 ("Reload page → motion persists and plays correctly" and "Clear motion → attribute removed") are exactly the cases that would surface this.
Fix shape: HTML-entity-escape the value before passing to the patcher. Either add a htmlAttrEscape(value) step in patchAttribute/patchAttributeByTarget (escape &, ", <, > — & first), or have buildMotionPatches pass an already-escaped value. The matching read regex (["'])([^"']*)\1 then needs to be tolerant of " content (or matched against the escaped form). Pair the fix with a sourcePatcher.test.ts case using a JSON payload containing a literal ", and a round-trip test for buildMotionPatches → applyPatchByTarget → re-read.
important — Migration hazard for projects that already have .hyperframes/studio-motion.json
Where: the legacy render-time path is still wired up. packages/core/src/studio-api/routes/preview.ts:96-112 injects createStudioMotionRenderBodyScript from studioMotionRenderScript.ts when the sidecar is non-empty, and studio/vite.studioMotion.ts:40 includes the same script in the body-scripts list used by vite.config.ts:283 for renders.
Failure mode: a project upgraded to this PR with an existing .hyperframes/studio-motion.json will execute BOTH render-time motion runtimes:
- Legacy script reads the manifest, resolves targets, calls
setAttribute(STUDIO_MOTION_ATTR, "true")(studioMotionRenderScript.ts:236), builds a timeline under__timelines["studio-motion"], pauses, seeks. - The new seek-reapply runtime (PR's
manualEditsRenderScript.ts) sees[data-hf-studio-motion]matches (including the="true"markers the legacy script just wrote), JSON.parse fails on"true"(silently skipped — good), JSON-baked elements still parse. Both runtimes will rebuild__timelines["studio-motion"]and overwrite each other.
The new runtime fires on every seek; the legacy runtime fires once at body load. Net effect: first frame can show the legacy manifest's motion until the first seek; after that, the new HTML-baked motion takes over but the legacy manifest's motions are dropped — even though the user never asked to remove them. And when the studio panel rewrites motion for an element that the legacy manifest also targets, the studio writes to the HTML attribute but doesn't remove the matching entry from the sidecar manifest — those entries linger and ship in the project.
Fix shape: either (a) explicitly delete .hyperframes/studio-motion.json (or rewrite to an empty { "motions": [] }) on the first motion edit after upgrade — same coalesceKey/save path as the HTML edit — and document the migration; or (b) on PR merge, run a one-shot migration that converts each manifest entry into a data-hf-studio-motion attribute on the targeted element, then deletes the sidecar; or (c) keep the legacy path running but have the new code skip elements that already have a legacy ="true" marker so the two paths are mutually exclusive. Option (b) is the most invariant-preserving for users with motion in production.
important — Zero unit test coverage for the new HTML-baked motion primitives
Where: studioMotion.test.ts and sourcePatcher.test.ts (PR head) contain no tests for readStudioMotionFromElement, writeStudioMotionToElement, clearStudioMotionFromElement, buildMotionPatches, buildClearMotionPatches, or applyStudioMotionFromDom. Same gap as #829 flagged earlier today, now reused in a JSON-payload variant.
Why this is more load-bearing now than for #829: the previous PR's patches carried numeric/CSS values, which trip the patcher far less than JSON. The blocker above is the direct consequence of skipping the round-trip test on buildMotionPatches → applyPatchByTarget → readStudioMotionFromElement. Even one round-trip test on a payload with a literal " would have caught it.
Concrete tests to add at minimum:
buildMotionPatches→applyPatchByTargetagainst a fixture HTML string → re-parse the HTML → assert the round-tripped attribute deserializes viareadStudioMotionFromElementand equals the input motion.buildClearMotionPatchesround-trip — assert all four attributes are gone from the patched HTML.readStudioMotionFromElementsemantics: legacy="true"returns null, malformed JSON returns null, start<0 / duration<=0 returns null, finite-number normalization works forfrom/to.applyStudioMotionFromDomwith a no-op DOM (no elements) — should not crash, should not callgsap.timeline().
important — Seek-time perf: full motion-timeline rebuild on every seek frame at render
Where: manualEditsRenderScript.ts:227-232 — reapplyAll() runs reapplyMotionTimeline(), which kills and recreates the GSAP timeline on every seek. At render, the producer seeks once per frame (e.g. 1800 seeks for 30fps × 60s), so this cost is per-frame and scales with motion-element count.
Risk: rebuilding a GSAP timeline + calling fromTo per element on every frame likely shows up as a perf regression in renders with many motion-tagged elements. The Perf: parity / Perf: scrub checks passed on this PR, but their fixtures probably don't exercise multi-element motion at scale.
Fix shape: cache the built timeline keyed by the concatenated motion JSON across all [data-hf-studio-motion] elements; rebuild only when the keyed hash changes. Or build once per render boundary (first seek) and only totalTime()-scrub afterward — the timeline doesn't change between seeks at render. The PR's lastSeekTime already supports the cheaper path.
nit — applyStudioMotionFromDom ignores its second argument
Where: studioMotion.ts new function signature applyStudioMotionFromDom(document, activeCompositionPath?, currentTime?). The body uses currentTime (via readCurrentTime(win, currentTime)) but activeCompositionPath is unused. Callers in useDomEditCommits.ts pass activeCompPath — pointless, will eventually drift. Drop the param or actually scope the per-comp filter (the legacy path scoped motion to a composition; the DOM-attribute path effectively goes whole-document, which is probably correct for studio but worth a comment).
nit — htmlCompiler.ts:1004 substring check on "data-hf-studio-motion=" is loose
Where: the HF_POSITION_ATTRS substring list is used by compileForRender to decide whether to inject the seek-reapply runtime. The new entry "data-hf-studio-motion=" matches any attribute name that starts with that prefix in any context (e.g. a user could legitimately have data-hf-studio-motion-something on an element and trigger spurious script injection). Low-impact (the script is idempotent and gated). Tightening to 'data-hf-studio-motion="' (include the opening quote) would match only valid attribute starts.
Required closing verdict line
Verdict: REQUEST CHANGES
Reasoning: Save→reload round-trips for JSON attribute values currently corrupt the HTML source (the blocker above) — unchecked test items 2 and 3 in the PR description would surface it. Plus a real migration story for existing .hyperframes/studio-motion.json users is missing. The architectural direction is right; the gaps are all addressable.
— Vai
…igrate sidecar, add tests Blocker: JSON attribute values are now HTML-entity-escaped before being written into source HTML. Read-back unescapes automatically. Perf: motion timeline is cached between seeks at render — only rebuilt when the concatenated JSON key changes, not on every frame. Migration: on mount, empties legacy .hyperframes/studio-motion.json so the legacy render script no-ops. Tests: 46 new tests for motion read/write/clear round-trips, JSON attribute escaping, and source patcher entity handling. Nits: removed unused activeCompositionPath param; tightened htmlCompiler attribute substring check.
vanceingalls
left a comment
There was a problem hiding this comment.
Re-review of f6e187ce against my prior REQUEST CHANGES (793c25b9). Single commit on top: fix(studio): address PR review — html-escape attrs, cache timeline, migrate sidecar, add tests. Verified the fixes against the actual code; required CI is green (regression-shards in progress — non-blocking optional).
Status of prior findings
-
Blocker — JSON attr values not HTML-escaped: ADDRESSED.
sourcePatcher.ts:14-30adds a symmetricescapeHtmlAttribute/unescapeHtmlAttributepair. Escape order is correct (&first); unescape order is correct (&last) — round-tripping&quot;stays as"then". Both write sites apply it (patchAttribute:365,patchAttributeByTarget:331) andreadAttributeByTarget:302unescapes on read. The matching regex(["'])([^"']*)\1still works because"contains no literal". The DOM path (writeStudioMotionToElement→getAttribute) goes via the browser which auto-handles entities. Covered bysourcePatcher.test.ts:212-291(literal-quote round-trip,&/</>escape, re-edit of already-escaped value, full motion JSON via patcher in the newmotion attribute round-trip via sourcePatcherdescribe block). -
Important #1 — legacy
studio-motion.jsonmigration: ADDRESSED (with caveat).useManifestPersistence.ts:143-158runs a one-shot mount-time migration: reads the legacy file, and ifmotions.length > 0writes back{"version":1,"motions":[]}. The legacy injection atpreview.ts/vite.studioMotion.tsshort-circuits viahasStudioMotionEntriesreturningfalsefor emptymotions, so the legacy render-runtime no longer fires once a studio session has touched the project. The chosen migration is neutralize not convert — a user who never opens studio between this PR landing and a render will keep the legacy path; if they then open studio, prior motions are silently dropped (no auto-convert to HTML attributes). Acceptable for a feature that landed days ago and is still iterating; not blocking. -
Important #2 — zero unit coverage: ADDRESSED.
studioMotionOps.test.ts(new, 445 lines, ~25 cases) coversreadStudioMotionFromElementsemantics (legacy"true"marker, malformed JSON, start<0, duration<=0, missing from/to, customEase), write→read DOM round-trip, original-style capture-and-not-overwrite,clearStudioMotionFromElementsymmetry,buildMotionPatchesshape,buildClearMotionPatchesround-trip viaapplyPatchByTarget.sourcePatcher.test.ts:212-491adds 9 cases for escape/round-trip including the exact failure mode the blocker called out. -
Important #3 — per-frame timeline rebuild: ADDRESSED.
manualEditsRenderScript.ts:84-191caches the GSAP timeline keyed by the concatenated motion JSON across all[data-hf-studio-motion]elements (computeMotionKey). On each seek with an unchanged key + existing live timeline, the runtime justtotalTime(lastSeekTime, false)on the cached timeline instead of kill+rebuild. Cache invalidates when an element appears/disappears or its JSON changes — correct. Edge case: same JSON content on different element references survives the cache, so a DOM-rebuild that doesn't go through a full script reload would animate stale references; in practice the studio reloads on HTML save and the render path's DOM is stable per frame, so this is fine. -
Nit — unused
activeCompositionPath: ADDRESSED.studioMotion.ts:233nowapplyStudioMotionFromDom(document, currentTime?); callers inuseDomEditCommits.ts:320, 349updated. -
Nit — loose substring check in
htmlCompiler.ts: ADDRESSED. Now'data-hf-studio-motion="'(with opening quote), preventing spurious match ondata-hf-studio-motion-anythingprefixes.
Fresh look — new code
- Escape/unescape ordering inside
unescapeHtmlAttribute(sourcePatcher.ts:24-30) is the place this kind of fix typically goes wrong. Verified:&is the last replacement on unescape, so&quot;decodes to"(literal) rather than". Correct. - The migration write uses
JSON.stringify(...)without a trailing newline; the legacy producer wrote withJSON.stringify(..., null, 2) + "\n". Minor style mismatch but no functional consequence — file ends up empty-equivalent. Nit, optional. computeMotionKeyconcatenates with"\n"as a separator — fine for the cache hash since JSON values themselves never contain raw newlines (JSON.stringifyescapes them).
Verdict
Verdict: APPROVE
Reasoning: Blocker resolved with a clean symmetric escape/unescape pair backed by direct round-trip tests; perf cache shipped with a sound invalidation key; migration neutralizes the legacy path with an idempotent one-shot. The architectural direction was already right; the gaps that mattered are closed.
Review by Vai (re-review)
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict note: Intended APPROVE; stamp-harness gated the --approve write.
Re-review of f6e187ce against my prior REQUEST CHANGES (793c25b9). Single commit on top: fix(studio): address PR review — html-escape attrs, cache timeline, migrate sidecar, add tests. Verified the fixes against the actual code; required CI is green (regression-shards in progress — non-blocking optional).
Status of prior findings
-
Blocker — JSON attr values not HTML-escaped: ADDRESSED.
sourcePatcher.ts:14-30adds a symmetricescapeHtmlAttribute/unescapeHtmlAttributepair. Escape order is correct (&first); unescape order is correct (&last) — round-tripping&quot;stays as"then". Both write sites apply it (patchAttribute:365,patchAttributeByTarget:331) andreadAttributeByTarget:302unescapes on read. The matching regex(["'])([^"']*)\1still works because"contains no literal". The DOM path (writeStudioMotionToElement→getAttribute) goes via the browser which auto-handles entities. Covered bysourcePatcher.test.ts:212-291(literal-quote round-trip,&/</>escape, re-edit of already-escaped value, full motion JSON via patcher in the newmotion attribute round-trip via sourcePatcherdescribe block). -
Important #1 — legacy
studio-motion.jsonmigration: ADDRESSED (with caveat).useManifestPersistence.ts:143-158runs a one-shot mount-time migration: reads the legacy file, and ifmotions.length > 0writes back{"version":1,"motions":[]}. The legacy injection atpreview.ts/vite.studioMotion.tsshort-circuits viahasStudioMotionEntriesreturningfalsefor emptymotions, so the legacy render-runtime no longer fires once a studio session has touched the project. The chosen migration is neutralize not convert — a user who never opens studio between this PR landing and a render will keep the legacy path; if they then open studio, prior motions are silently dropped (no auto-convert to HTML attributes). Acceptable for a feature that landed days ago and is still iterating; not blocking. -
Important #2 — zero unit coverage: ADDRESSED.
studioMotionOps.test.ts(new, 445 lines, ~25 cases) coversreadStudioMotionFromElementsemantics (legacy"true"marker, malformed JSON, start<0, duration<=0, missing from/to, customEase), write→read DOM round-trip, original-style capture-and-not-overwrite,clearStudioMotionFromElementsymmetry,buildMotionPatchesshape,buildClearMotionPatchesround-trip viaapplyPatchByTarget.sourcePatcher.test.ts:212-491adds 9 cases for escape/round-trip including the exact failure mode the blocker called out. -
Important #3 — per-frame timeline rebuild: ADDRESSED.
manualEditsRenderScript.ts:84-191caches the GSAP timeline keyed by the concatenated motion JSON across all[data-hf-studio-motion]elements (computeMotionKey). On each seek with an unchanged key + existing live timeline, the runtime justtotalTime(lastSeekTime, false)on the cached timeline instead of kill+rebuild. Cache invalidates when an element appears/disappears or its JSON changes — correct. Edge case: same JSON content on different element references survives the cache, so a DOM-rebuild that doesn't go through a full script reload would animate stale references; in practice the studio reloads on HTML save and the render path's DOM is stable per frame, so this is fine. -
Nit — unused
activeCompositionPath: ADDRESSED.studioMotion.ts:233nowapplyStudioMotionFromDom(document, currentTime?); callers inuseDomEditCommits.ts:320, 349updated. -
Nit — loose substring check in
htmlCompiler.ts: ADDRESSED. Now'data-hf-studio-motion="'(with opening quote), preventing spurious match ondata-hf-studio-motion-anythingprefixes.
Fresh look — new code
- Escape/unescape ordering inside
unescapeHtmlAttribute(sourcePatcher.ts:24-30) is the place this kind of fix typically goes wrong. Verified:&is the last replacement on unescape, so&quot;decodes to"(literal) rather than". Correct. - The migration write uses
JSON.stringify(...)without a trailing newline; the legacy producer wrote withJSON.stringify(..., null, 2) + "\n". Minor style mismatch but no functional consequence — file ends up empty-equivalent. Nit, optional. computeMotionKeyconcatenates with"\n"as a separator — fine for the cache hash since JSON values themselves never contain raw newlines (JSON.stringifyescapes them).
Verdict
Verdict: APPROVE
Reasoning: Blocker resolved with a clean symmetric escape/unescape pair backed by direct round-trip tests; perf cache shipped with a sound invalidation key; migration neutralizes the legacy path with an idempotent one-shot. The architectural direction was already right; the gaps that mattered are closed.
Review by Vai (re-review)
Summary
Re-architects the studio motion panel to persist GSAP motion data directly in HTML element attributes instead of a
.hyperframes/studio-motion.jsonJSON sidecar file. Same pattern as position/resize/rotation edits.Before
After
What changed
readStudioMotionFromElement(),writeStudioMotionToElement(),clearStudioMotionFromElement()for attribute-based CRUDapplyStudioMotionFromDom()that reads motion from DOM attributes and builds GSAP timeline (keptapplyStudioMotionManifestfor render script compat)buildMotionPatches()/buildClearMotionPatches(), integrated motion intoreapplyPositionEditsAfterSeek()handleDomMotionCommit/handleDomMotionClearto use HTML patching instead of manifest persistencestudioMotionManifestRef,commitStudioMotionManifestOptimistically,applyStudioMotionToPreview, motion SSE handlerreadStudioMotionFromElement) instead of manifest refstudioPositionSeekReapplyRuntimeto rebuild GSAP motion timeline fromdata-hf-studio-motionattributes after each seek, including CustomEase supportdata-hf-studio-motion=attributesBenefits
Test plan
bun run buildpassesdata-hf-studio-motionattribute appears in HTML source