* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops
Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.
- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
(new, 50 lines) — guard function + happy-path assertions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(studio): force-reload sdk session after undo/redo bypasses suppress window
writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch)
Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.
KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired)
Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.
Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): route element delete through SDK removeElement (§3.1)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): route timeline trim/move through SDK setTiming (§3.2)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): route GSAP keyframe add through SDK (§3.5 PR2)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio,core): resolve SDK-cutover review findings
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops
Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.
- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
(new, 50 lines) — guard function + happy-path assertions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(studio): force-reload sdk session after undo/redo bypasses suppress window
writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch)
Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.
KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired)
Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.
Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): route element delete through SDK removeElement (§3.1)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): route timeline trim/move through SDK setTiming (§3.2)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Stage 7 s7.5 removed the feature flag and declared cutover 'always-on',
but onTrySdkPersist was never actually passed to useDomEditCommits — the
sdkCutoverPersist function was dead code in production.
Thread sdkSession through useDomEditSession params, build the
onTrySdkPersist closure there (all CutoverDeps are already in scope),
and pass sdkSession from App.tsx. Style/text/attribute/html-attribute
commits now route through SDK dispatch instead of the server patch path.
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.
KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
writeHistoryFile arms the 2 s self-write suppress window, so the
file-change event for an undo/redo write is swallowed and the SDK
in-memory doc stays on pre-undo content. Expose forceReload() from
useSdkSession (s7.4) and call it in useAppHotkeys after a successful
undo/redo that touched the active composition path.
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.
- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
(new, 50 lines) — guard function + happy-path assertions.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Fifth PR in the Studio storyboarding stack. Click a contact-sheet tile to
open a full-area focus on that frame.
- StoryboardFrameFocus: large poster, prev/next nav, full narrative, and an
editable voiceover *guide* (textarea) saved back to STORYBOARD.md. Status
can be advanced outline → built → animated inline.
- "Open in Preview" jumps to the timeline focused on the frame's
sub-composition (setActiveCompPath + view-mode timeline).
- core/storyboard: setFrameField / setFrameVoiceover / setFrameStatus —
surgical in-place writers that update one frame's metadata without
re-serializing (markdown stays canonical). Tested.
- Extract shared FramePoster (used by tile + focus); tiles are now buttons
that open focus.
Voiceover here is the editable guide; SCRIPT.md remains the locked narration
that drives TTS.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fourth PR in the Studio storyboarding stack. Adds an in-context way to view
and edit the storyboard's canonical files.
- Board | Source sub-toggle inside the storyboard view (StoryboardLoaded).
- StoryboardSourceEditor: raw CodeMirror markdown editor + live rendered
preview (marked), with a file switcher for STORYBOARD.md and SCRIPT.md.
- Loads raw file text and saves via the existing files API
(GET/PUT /projects/:id/files/*); on save the Board re-parses (reload), so
markdown stays the single source of truth. Cmd/Ctrl+S to save.
- Deliberately raw, not WYSIWYG, so the structured frame fields can't be
mangled.
- SourceEditor gains markdown language support (@codemirror/lang-markdown);
adds the marked dependency for preview.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A sub-composition mount whose data-duration ends before the host
composition's window leaves its slot blank for the remainder. The
runtime behavior is correct (data-duration is the slot's visible window
and takes precedence), but a full-bleed sub-composition shorter than the
composition is almost always an authoring mistake that fails silently
(issue #1540).
Add the subcomposition_blanks_before_host rule, scoped narrowly to the
high-signal shape — a sole/dominant external mount starting at ~0 whose
window ends before the host's — so it stays silent on intentional short
clips. Document the slot-window semantics in the sub-compositions
reference, distinguishing the hold-through-slot case (#911/#917) from
the blank-when-shorter-than-host case.
Third PR in the Studio storyboarding stack. Renders the frames as a live
contact sheet inside the storyboard view.
- StoryboardGrid: ordered, responsive grid of frame tiles.
- StoryboardFrameTile: number badge, scaled non-interactive live preview
iframe (via /api/projects/:id/preview/comp/<src>), title, duration,
transition, and a status chip (outline / built / animated).
- Frames that are outline-only or whose src is missing render an explicit
placeholder instead of an iframe.
- StoryboardView swaps its placeholder for the real grid.
With PR1-PR3 the storyboard view is end-to-end viewable against the
storyboard-sample fixture for UI/UX feedback.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second PR in the Studio storyboarding stack. Adds the top-level toggle
between the storyboard and the timeline/preview stage, behind the flag.
- STUDIO_STORYBOARD_ENABLED flag (VITE_STUDIO_ENABLE_STORYBOARD, default
off) now gates the UI.
- ViewModeContext: timeline|storyboard state mirrored to the ?view= query
param, so it survives reloads and an agent can deep-link ?view=storyboard.
- Segmented Storyboard|Preview control in StudioHeader (flag-gated).
- StudioApp swaps the whole center stage for a full-width StoryboardView
when storyboard mode is active.
- useStoryboard hook + StoryboardView shell: global-direction header,
loading/error/empty states. The frame contact-sheet grid lands in PR3.
- Extract StudioOverlays from App.tsx to stay within the 600-line studio
decomposition budget.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First PR in the Studio storyboarding stack. Establishes the parseable
contract the storyboard UI reads from; no UI yet.
- core/storyboard: StoryboardManifest/Frame/Globals types + a lenient
STORYBOARD.md parser (frontmatter + status/src/duration/transition_in,
freeform narrative tolerated, never throws, records warnings). Exposed as
@hyperframes/core/storyboard (browser-safe).
- studio-api: GET /projects/:id/storyboard returns the normalized manifest
with per-frame srcExists; missing file -> exists:false, not 404.
- fixture: packages/studio/fixtures/storyboard-sample for dogfooding the
storyboard view in later PRs (built/animated frames + one outline).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
font_family_without_font_face (and system_font_will_alias) collect
@font-face blocks with `@font-face\s*\{[^}]*\}`, which stops at the first
`}`. A CSS comment inside the block that contains a brace —
`@font-face { /* 400 } regular */ font-family: 'X'; ... }` — truncates the
match before the font-family, so the family is never recorded as declared
and a later `font-family: 'X'` usage is wrongly flagged as used without an
@font-face. Strip CSS comments before scanning so a brace inside one cannot
split a block.
Refs #1534
Add gsapWriterParity.corpus.test.ts: a reusable recast-vs-acorn differential
harness (runParity/modelOf, exported for the WS-3 op-PR workflow) plus a broadened
corpus (3 real registry scripts + 10 synthetic) covering to/from/fromTo, multi-tween,
keyframes, labels, numeric/label-relative/symbolic positions, stagger/repeat/yoyo
extras, and sub-composition selectors. Extends true differential coverage to the five
previously standalone-only acorn ops (update/add/removeAnimation, update/removeKeyframe)
and adds correctness tests for the acorn-only label ops.
Fix three acorn-writer divergences the suite surfaced:
- updateAnimationInScript now REPLACES the editable property set (and fromTo
from-vars) instead of merging, matching recast's reconcileEditableProperties;
non-editable keys (duration/ease/stagger/…) are preserved.
- removeKeyframeFromScript now collapses keyframes back to a flat tween when
fewer than two keyframes remain, matching recast's collapseKeyframesToFlat.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The render pre-flight check shells out to `which ffmpeg`, which only
searches the server process's PATH. When Studio is launched from a
GUI/Dock/launchd context that PATH lacks /opt/homebrew/bin, so `which`
fails even when ffmpeg is installed — and POST /render returns 503
"FFmpeg not found".
Fall back to probing well-known install dirs (Homebrew on Apple Silicon
and Intel, plus system/Linux locations) when the PATH lookup fails.
Also drop the [kf:static]/[kf:runtime] keyframe diagnostics that were
spamming the Studio console in prod, and fix two unrelated CI breakages
the branch inherited: a Windows-sensitive ffmpeg test (pin platform) and
a stale player test mock missing onRuntimeReady.
The single-clip razor path guards splits with isSplitTimeWithinBounds,
which keeps a SPLIT_BOUNDARY_EPSILON_S margin from each clip edge so a
cut never produces a degenerate near-zero slice. The split-all path
filtered with raw `splitTime > start && splitTime < end` instead, so it
accepted cuts inside that margin (and on clips shorter than two epsilons
that the single path always rejects), producing the very degenerate
slice the epsilon exists to prevent.
Extract the shared predicate canSplitElementAt and a selectSplittableElements
helper, and route both razor paths through them so the two stay consistent.
Adds unit coverage for the new helpers, including the regression where a
sub-epsilon clip with an interior split time must not be selected.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
width/height attributes went through parseInt with no validation, so a
typo like width="abc" reached scaleIframeToFit as NaN (invalid
scale(NaN) transform) and width="0" as a division by zero — both
blank the player with no signal. The stage-size message check had the
sibling gap: `> 0` alone lets Infinity through, which scales the
iframe to 0.
Reuse the composition probe's readPositiveDimension guard for the
attribute path (the probe path already rejected these) and add the
same finite-check the adjacent timeline branch uses for stage-size.
Mirrors the clampPlaybackRate hardening from #1120.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
A sub-composition ROOT is addressed by its data-composition-id, but the SDK's
whole element<->tween attribution is data-hf-id based, so the prior fix's
[data-composition-id] selector was invisible to three readers (validateOp/can,
selectorMatchesId -> setTiming + removeElement cascade, buildAnimationIdMap ->
getElement.animationIds), diverging can from apply and orphaning tweens.
Root fix: make composition ids first-class resolvable addresses and emit the
canonical selector everywhere.
- resolveScoped (model.ts): for a bare id with no data-hf-id match, fall back to
[data-composition-id]. data-hf-id keeps precedence; scoped-path and canonical
behavior intact. Fixes validateOp gating, findById/getElement, and every op
handler for comp-root targets in one place.
- gsapTargetSelector (mutate.ts): resolve the target and emit
[data-hf-id="<resolved host hf-id>"] (canonical). Normal targets unchanged;
comp-root targets resolve via comp-id -> host -> host hf-id. Defensive
[data-composition-id] only when the resolved element has no hf-id.
- setTiming syncs the GSAP tween via the resolved element's data-hf-id so a
comp-root target matches its host tween; removeElement cascade already covers
the host hf-id via collectSubtreeHfIds.
- export escapeHfId; escape both the querySelector probe and the emitted
selector string.
Tests: comp-id resolveScoped fallback + precedence (session.subcomp), canonical
selector, validateOp accept, setTiming sync, removeElement cascade, and
getElement.animationIds for comp-root tweens (mutate.gsap). The prior test only
called applyOp, masking all of this.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`hyperframes capture <url>` (no -o) used to dump into `./captures/<hostname>/`,
which buries the project two levels deep and silently merges re-runs into the
previous dir — file-by-file, so leftover screenshots / assets from the prior
run stay mixed in and any later `glob` sees both.
Switch the default to `./capture/`. When it already exists, auto-suffix to
`./capture-2/`, `./capture-3/`, … (up to -99). Each capture is its own clean
directory — no crud, no friction, no clobber. The CLI prints a one-line note
when the suffix kicks in so the user sees which dir actually got written.
Explicit `-o <name>` is unaffected (still overwrite-tolerant).
* fix(lint): promote rules to errors with registry exemptions and false-positive fixes
- Export isRegistrySourceFile/isRegistryInstalledFile from composition.ts
- Add registry exemptions to google_fonts_import and font_family_without_font_face
- Add registry exemption to requestanimationframe_in_composition
- Fix timed_element_missing_clip_class: data-track-index alone no longer triggers
- Fix caption_transcript_parse_error: balanced-bracket scanner replaces non-greedy regex
- Fix missing_timeline_registry: skips sub-compositions and template-wrapped files
- Fix scene_layer_missing_visibility_kill: strip JS comments before pattern matching
- Fix gsap_css_transform_conflict: exempt from() alongside fromTo()
- Fix gsap_from_opacity_noop: only fires when opacity value is actually 0
- Add regression test for data-track-index-only elements
* test(lint): add regression tests for false-positive fixes
Covers the 7 missing negative-case assertions flagged in PR review:
- registry marker suppresses google_fonts_import + font_family_without_font_face
- registry marker suppresses requestanimationframe_in_composition
- isSubComposition suppresses missing_timeline_registry
- scene_layer_missing_visibility_kill: fires, commented-kill fires, real kill suppresses
- gsap_css_transform_conflict: from() exempt alongside fromTo()
- gsap_from_opacity_noop: non-zero opacity (e.g. 0.5) is a valid reveal, not a noop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(examples): fix warm-grain template to pass promoted lint rules
- index.html: remove undeclared "Lexend" from font-family stack
- intro.html: replace Google Fonts @import with bundled Inter font
- captions.html: quote TRANSCRIPT keys for valid JSON + use Inter font
Fixes CLI smoke CI failure after google_fonts_import, font_family_without_font_face,
and caption_transcript_parse_error were promoted from warning to error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(cli): resolve warm-grain from repo registry in dev mode + bundle at build
getStaticTemplateDir now falls back to registry/examples/<id> in dev mode
so CI smoke tests use the PR-branch copy instead of fetching from main.
build-copy.mjs copies warm-grain to dist/templates/warm-grain at build time
so packed CLIs can scaffold it offline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(examples): remove trailing comma from warm-grain TRANSCRIPT array
JSON.parse rejects trailing commas (valid JS, invalid JSON).
caption_transcript_parse_error was still firing because of the comma
on the last entry after quoting all keys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The acorn addKeyframeToScript mixed ms.overwrite + ms.appendLeft on the
same _auto endpoint node, crashing MagicString ("Cannot split a chunk
that has already been edited") whenever an interior keyframe adjacent to
an _auto 0/100 endpoint introduced a new backfilled prop — the common SDK
path. It also replaced (not merged) existing keyframes, dropped ease and
_auto markers, corrupted commas on multi-prop backfill into empty {},
used a <0.001 percentage tolerance instead of recast's PCT_TOLERANCE=2,
and silently no-op'd on flat (non-keyframe) tweens.
Rebuild the node model to mirror recast: compute the FINAL property record
for every changed keyframe value node (target merge, _auto endpoint sync,
backfilled siblings) against the original AST, then emit exactly one
ms.overwrite per changed node (one insert for a brand-new key). No node is
ever both overwritten and appended into, so splices can never overlap.
- Merge: re-touching an existing keyframe merges new props over the
existing record, preserving untouched props, existing ease, and _auto.
- Convert-flat: first keyframe-add on a flat to()/from()/fromTo() tween
rebuilds its vars object to percentage keyframes (ease->easeEach,
ease:"none", from/fromTo->to) matching recast, then re-locates via the
-from-/-fromTo- -> -to- id fallback.
- Tolerance: PCT_TOLERANCE=2 for existing-keyframe detection.
- Shared serializeValue/safeJsKey for keyframe values (recast parity); the
tween-statement path keeps its local serializer for object/boolean extras.
- keyframeBackfill: only backfill props with a real numeric default; skip
unknown/string props so color:0 / filter:0 are never emitted.
- setGsapKeyframe move-path threads the same backfill defaults as the add
path so both entry points behave identically.
Differential tests (acorn vs recast parsed keyframe arrays) cover the
crash (2-endpoint + 0/25/100), empty-{} multi-prop backfill, merge with
extra props + ease, flat to()/fromTo() convert, "50.0%" non-byte-equal
key, near-% tolerance, and _auto-marker preservation onto an endpoint.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): serialize GSAP script commits per file (shadow request race)
Rapid GSAP edits (ease/duration/keyframe/property) fired overlapping
read-modify-write POSTs to one script file — coalesceKey only dedupes edit
history, not requests. The gsap_fidelity shadow then diffed an op against
whichever POST's scriptText resolved, which could predate that op → false
"expected null, actual power2.out" mismatches. Server persists correctly; a
pure client request-pairing race.
Adds createKeyedSerializer (per-key promise chain, rejection-safe, self-
cleaning). commitMutation now serializes every GSAP-script commit per target
file by default (key `gsap-file:<path>`) — covering all op types and all
animations, not just one meta family — so same-file POSTs can't interleave.
Distinct files run concurrently; an explicit serializeKey still overrides.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(studio): dedup shadow numeric-equal + GSAP script extraction
Code-review cleanup (no behavior change):
- Extract the relative-epsilon float compare into shared sdkShadowNumeric.relEqual,
used by both timing parity (sdkShadow) and GSAP value fidelity (numericEqual) —
was duplicated verbatim, risking divergent tuning.
- Export extractGsapScript from sdkShadowGsapFidelity and import it in the keyframe
shadow instead of the byte-identical clone (the regex + marker set must stay in
sync with document.ts; one copy is safer).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rapid GSAP edits (ease/duration/keyframe/property) fired overlapping
read-modify-write POSTs to one script file — coalesceKey only dedupes edit
history, not requests. The gsap_fidelity shadow then diffed an op against
whichever POST's scriptText resolved, which could predate that op → false
"expected null, actual power2.out" mismatches. Server persists correctly; a
pure client request-pairing race.
Adds createKeyedSerializer (per-key promise chain, rejection-safe, self-
cleaning). commitMutation now serializes every GSAP-script commit per target
file by default (key `gsap-file:<path>`) — covering all op types and all
animations, not just one meta family — so same-file POSTs can't interleave.
Distinct files run concurrently; an explicit serializeKey still overrides.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): setStyle removes hyphenated properties (was kebab/camel key mismatch)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk): agree removeElement/getElement on duplicate bare ids
A bare hf-id duplicated across a sub-composition element and a top-level
element resolved to different instances: removeElement → resolveScoped →
querySelector (document-order-first, the inner sub-comp dup) while getElement
preferred the canonical match (scopedId === id, the top-level dup). So
removeElement(bareId) removed the inner instance and getElement(bareId) still
found the surviving top-level one — they disagreed.
resolveScoped now resolves an ambiguous BARE id to the canonical (top-level)
instance via isCanonicalScope (walks ancestors for isNewHostBoundary), falling
back to document order when no canonical match exists — matching getElement.
Fully-scoped paths (hf-host/hf-dup) and non-duplicated bare ids are unchanged.
Surfaced by SDK shadow parity (op:delete expected removed, actual present).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): suppress shadow-parity false positives in timing + text
runShadowTiming: compare start/duration with a relative epsilon (1e-6)
instead of exact equality so float-precision drift (3.1 vs
3.0999999999999996, 21.36 vs 21.360000000000014) no longer flags; a real
difference (3.1 vs 3.5) still flags. trackIndex stays exact.
property:text resolver: trim both sides (snapshot.text is already trimmed)
and collapse empty-string vs absent (null) text so trailing-whitespace and
empty-vs-null no longer flag. Genuine text differences are unaffected; the
per-keystroke length lag is a caller-side debounce concern.
Adds tests for both fixes plus regression tests documenting two REAL SDK
divergences the shadow correctly surfaces (transform-origin removal no-op;
duplicate-bare-id delete resolution) — flagged, not fixed here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(studio): shadow telemetry for GSAP keyframe ops (gsap_keyframe)
Wire the SDK shadow-parity telemetry to cover GSAP keyframe add/remove,
the primary unwired cutover signal, plus a defensive unmapped-PatchOperation
guard.
New packages/studio/src/utils/sdkShadowGsapKeyframe.ts:
- ShadowKeyframeOp + keyframeOpToEditOp: maps studio percentage-based keyframe
ops to SDK EditOps. add -> addGsapKeyframe{position:percentage}; remove ->
removeGsapKeyframe{keyframeIndex}, resolving percentage -> index against the
pre-op script with ~0.001 tolerance and a no-op-on-ambiguity guard for
duplicate-percentage keyframes (PR #1498 landmine).
- gsapKeyframeFidelityMismatches: reuses gsapFidelityMismatches for the
tween-level diff and layers a keyframe-array comparison (which the base diff
doesn't inspect), matched by GSAP animation id.
- runShadowGsapKeyframeFidelity: serialize-diff runner emitting op tag
gsap_keyframe (no keyframe reader on ElementSnapshot, so no existence path).
useGsapKeyframeOps synthesizes shadowKeyframeOp for addKeyframe /
addKeyframeBatch / removeKeyframe; the commit chokepoint dispatches the
keyframe-fidelity diff alongside the existing tween-fidelity path.
sdkShadow.ts: runShadowDispatch now emits dispatched:false reason:unmapped_type
if a future PatchOperation type ever escapes patchOpsToSdkEditOps, so the gap
surfaces in telemetry instead of vanishing.
Tests: sdkShadowGsapKeyframe.test.ts (18) covers index resolution, op mapping,
the ambiguity guard, the keyframe-aware diff, the runner, and the unmapped-type
guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runShadowTiming: compare start/duration with a relative epsilon (1e-6)
instead of exact equality so float-precision drift (3.1 vs
3.0999999999999996, 21.36 vs 21.360000000000014) no longer flags; a real
difference (3.1 vs 3.5) still flags. trackIndex stays exact.
property:text resolver: trim both sides (snapshot.text is already trimmed)
and collapse empty-string vs absent (null) text so trailing-whitespace and
empty-vs-null no longer flag. Genuine text differences are unaffected; the
per-keystroke length lag is a caller-side debounce concern.
Adds tests for both fixes plus regression tests documenting two REAL SDK
divergences the shadow correctly surfaces (transform-origin removal no-op;
duplicate-bare-id delete resolution) — flagged, not fixed here.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>