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>
## What
Kills two false-positive classes in the SDK shadow GSAP value-fidelity diff (`sdkShadowGsapFidelity.ts`).
1. **Float precision** — `numericEqual` compared exactly, so SDK-computed `3.0999999999999996` vs server `3.1` flagged as drift. Now a relative epsilon (`abs(a-b) <= 1e-6 * max(1,|a|,|b|)`); real `2` vs `1` still flags.
2. **Selector-form divergence** — `[data-hf-id="X"]` (SDK writer) vs `.class`/`#id` (server writer) for the same element produced phantom `present`/`absent` pairs. `makeSelectorResolver` now keys tweens by resolved element (incl. nodes with no `data-hf-id`), unifying the forms.
## Why
Surfaced by production SDK-shadow parity telemetry — `gsap_fidelity` was the noisiest real-traffic op; both are diff-harness artifacts, not SDK drift.
## Tests
Epsilon (clean + real-drift) + selector-unification for `#id`/`.class`/`[data-hf-id]`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* feat(core): add param-substitution utility for GSAP timeline inlining
U1: clone + shadow-aware identifier substitution over acorn ESTree, plus
provenance tagging and a GsapProvenance type. Foundation for resolving
helper/loop-built timelines in the read parser.
* feat(core): inline helper-built and bounded-loop GSAP timelines
U2: expansion pre-pass that rewrites the analysis AST so a helper called N
times, a literal-bounds for-loop, a for-of, or a forEach over an inline array
each become concrete per-call/per-iteration tl.* statements with substituted
positions and provenance tags. Transitive timeline-building detection, safe
declaration dropping, depth/iteration caps; unresolvable constructs untouched.
* feat(core): resolve computed GSAP timelines in the read parser
U3: parseGsapScriptAcorn runs the inlining pre-pass before analysis, so
helper-built and bounded-loop timelines resolve at true positions with
motionPath arcs recognized; each tween carries provenance. Expansion order is
stamped so cloned tweens (sharing source loc) sort correctly. Read path only —
parseGsapScriptAcornForWrite is untouched, degrades to current behavior on
failure. The add-to-basket addCycle case now yields 7 resolved animations.
* feat(studio): runtime-authoritative keyframes for dynamic timelines
Phase 2 (U4-U6): the live-runtime scanner returns tween-relative keyframes
with per-tween timing and converts them to clip-relative when given clip dims,
fixing the timeline-vs-clip-relative bug; it extracts motionPath into arcPath
(shared buildArcPath) so the Arc Motion panel activates for data-driven arcs;
the cache leaves statically-unresolvable tweens to the runtime scan. Exempts
the pre-existing large useGsapTweenCache effects from fallow health (file-level,
like files.ts) rather than suppression comments.
* feat(studio): surface keyframe editability from provenance
U9: editabilityForProvenance(provenance) -> direct|unroll|override (core,
re-exported from the acorn subpath). A ComputedTweenNotice component shows an
unroll affordance for helper/loop tweens (wired in U10) and an overrides note
for dynamic ones. Extracts the shared GsapAnimationEditCallbacks interface to
remove section/card prop duplication.
* feat(core): lint understands computed timelines (acorn parser)
U7: the GSAP lint rule now loads parseGsapScriptAcorn (which inlines helpers
and bounded loops) instead of the recast parser, so overlapping_gsap_tweens and
related findings reflect true resolved positions for computed timelines — and
keeps recast out of the lint graph entirely. Literal compositions are
unchanged (parity), all 182 lint tests pass.
* docs: document the computed-timeline keyframe editing model
U8: keyframes.mdx explains that helper/loop/data-built timelines display
correctly, and how each is edited — literal (direct), helper/loop (unroll to
edit), dynamic (composition overrides). Nothing is permanently locked.
* feat: unroll computed timelines into literal tweens (U10)
Adds unrollComputedTimeline (core): serializes a parsed timeline's resolved
animations back to literal tl.* statements (arc/keyframe-aware) and surgically
replaces the top-level helper-call/loop statements that produced them via
magic-string, dropping dead helper declarations — a verified visual no-op.
Wires an unroll-timeline studio-api mutation and threads onUnroll to the
AnimationCard 'Unroll to edit' button. Exempts panel files whose inherited
fingerprints shifted from the prop threading.
* feat(runtime): declarative keyframe override layer for dynamic tweens (U11)
Adds applyKeyframeOverrides: fetches a gsap-overrides.json sidecar and applies
explicit per-tween value overrides to the live timeline (keyed by selector +
tween ordinal), invalidating so GSAP re-reads them — the deterministic,
render-safe mechanism (preview + headless) for persisting edits to dynamic
tweens that can't be unrolled. Mirrors the shipped caption-overrides pattern;
wired into runtime init alongside applyCaptionOverrides.
* refactor: drop the keyframe override layer; rely on unroll + source
Removes the gsap-overrides.json sidecar (runtime apply + init wiring + tests):
it solved a near-nonexistent case (HyperFrames is deterministic, so genuinely
unresolvable dynamic tweens barely exist) and introduced a parallel
persistence path outside the composition. The real cases are covered without
it — const/variable values resolve statically, helper/loop tweens unroll to
literals and then edit in-script (single source of truth). Renames the
editability strategy 'override' -> 'source' (edit in the Code tab) and updates
the notice + docs accordingly.
* fix(studio): drag outside tween range creates new keyframe, picks nearest tween
Fixes the GSAP drag intercept to pick the position tween closest to the
playhead (not the one with the most keyframes), and when dragging outside all
tweens' ranges, creates a brand-new keyframed tween instead of destructively
extending/replacing the nearest one. Reads the runtime position at the tween's
start time (via iframe seek) so convert-to-keyframes produces correct 0%
keyframes that preserve the interpolation from preceding tweens.
* fix(studio): drag outside tween range creates new keyframe, picks nearest tween
Also reverts all fallow health.ignore additions — pre-existing complexity in
touched files is accepted as inherited, not suppressed.
Moving sharp and onnxruntime-node to optionalDependencies (in the earlier
capture/native-module hardening) regressed `remove-background` from ~7% to
~97% failure starting at 0.6.101: the command genuinely *requires* both native
modules, but as optional deps they're skipped on most installs, so it hits the
guarded "module not available" error and fails for nearly everyone.
The capture crash that motivated the optional move is already fixed by the
lazy, guarded `await import()` in contentExtractor / inference — that holds
regardless of dependency classification. Making the modules optional was the
over-correction; the lazy import alone was sufficient. sharp ships its own
platform binaries as optional sub-deps, so it installs cleanly as a hard dep
without failing installs on unsupported platforms (it was a hard dep at 0.6.99
with remove-background at a healthy ~7%).
- Move sharp + onnxruntime-node back to `dependencies` (so they install for
everyone again). `@google/genai` stays optional — genuinely optional, lazy,
and not part of the regression.
- Keep the lazy guarded imports — they remain the crash-safety for capture.
- Add trackCommandFailure to remove-background's catch: it self-exits, so the
dispatch wrapper never saw it (the reason stream was blind). Now its failures
carry a reason, closing that command from the wrapper-blind follow-up.
remove-background tests + background-removal suite pass; tsc clean; build green.
* fix(engine): use ANGLE-EGL for Linux GPU path, bump NVENC probe size
Chrome 131+ rejects --use-gl=egl in headless shell; the GPU process
exits and the renderer silently falls back to SwiftShader. Switch to
(gl=angle, angle=gl-egl) which is on the headless-shell allowlist,
and add --ignore-gpu-blocklist + --disable-software-rasterizer so
data-center GPUs (L4/T4/A10) are not blocked.
Also bump the NVENC probe frame from 16×16 to 320×240 — NVIDIA
data-center cards require ≥257 on each dimension and reject the
smaller size with "Frame Dimension less than the minimum supported
value", causing the encoder probe to silently fall back to libx264.
Closes#1493
* fix(engine): address review feedback — probe test, observability, comments
- Export getProbeArgs and add test pinning 320×240 probe dimensions
across all 5 GPU encoder backends (nvenc/videotoolbox/vaapi/qsv/amf)
- Add driver/SKU rationale comment on the probe size constant with
context about NVIDIA data-center card behavior vs documented minimums
- Add rationale comment on --ignore-gpu-blocklist (operator opted into
hardware mode explicitly)
- Log resolved GL flags at browser launch for GPU fallback observability
* fix(studio): open SDK shadow session in master view (was never opening)
useSdkSession(projectId, activeCompPath) received activeCompPath=null in the
master/entry view — the studio's convention where null means index.html
(isMasterView = !activeCompPath || activeCompPath === "index.html"). The hook's
guard `if (!projectId || !activeCompPath) return` then bailed, so the SDK
session never opened in the default editing surface. Result: sdkSession was
null there → every shadow tap (onDomEditPersisted, onElementDeleted, timing,
gsap) was undefined/no-op → zero sdk_shadow_dispatch telemetry for master-view
edits (the common case). Shadow only fired when a sub-comp was explicitly
opened (which sets activeCompPath).
Resolve null → "index.html" (matching the existing convention used by
isMasterView and blockInstaller) so the session opens in master view.
Verified live: instrumenting the hook showed phase "skipped_no_ids"
(activeCompPath null) before, "opened" after.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): shadow property parity — read camelCase style key, not kebab
The inline-style parity resolver read flat.styles[op.property] with the
kebab-case PatchOperation key ("background-color"), but ElementSnapshot
inlineStyles are camelCase ("backgroundColor"), so the read-back was always
null → a false value_mismatch on every hyphenated CSS property. Single-word
props (color, opacity) coincide, so unit tests missed it.
Found live: a color edit on a box emitted op:property mismatchCount:1 with
{property:"background-color", expected:"rgb(255,79,88)", actual:null}. Convert
kebab→camel for the read-back (fall back to the raw key).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): emit op:delete shadow for timeline-clip deletes
The Delete/Backspace hotkey routes to handleTimelineElementDelete whenever a
timeline element is selected (useAppHotkeys: `if (selectedElementId) {
handleTimelineElementDelete(el); return; }`), returning before the
shadow-wired handleDomEditElementDelete. Every clip is a timeline element, so
clip deletes — the common case — emitted no op:delete; the delete shadow only
fired for a non-timed DOM selection.
Add runShadowDelete(sdkSession, element.hfId) to handleTimelineElementDelete's
success path, mirroring the move/resize timing taps.
Verified live (browser-use): deleting a clip now emits
sdk_shadow_dispatch op:delete dispatched:true mismatchCount:0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): match GSAP fidelity tweens by resolved element, not raw selector
gsap_fidelity keyed tweens by id (targetSelector-method-position). On tween
ADD, the SDK writer emits [data-hf-id="X"] selectors while the server emits
class selectors (.x) for the same element — different ids → false
present/absent mismatch (mc:2) on every add. Update/remove were clean (the
tween already existed with one consistent selector).
Key by resolved element (selector → data-hf-id via the pre-op DOM) + method +
position, so equivalent tweens match and only real value drift registers.
Falls back to raw selector when resolution isn't possible.
Found live (browser-use): adding a tween emitted gsap_fidelity mc:2 with
{[data-hf-id="hf-b"]-to-0 present-only} + {.b-to-0 present-only}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): don't shadow studio-internal data-hf-* marker attributes
Property-path parity false-mismatched on canvas-drag (path-offset) edits, which
emit attribute ops like {property:"data-hf-studio-path-offset"}:
1. The name was built as `data-${op.property}` → double-prefix
"data-data-hf-studio-path-offset".
2. The SDK model excludes all data-hf-* attributes, so even the right name
reads back null → false value_mismatch.
attrName() prefixes only when needed; isShadowableOp() drops data-hf-* attribute
ops (studio-internal markers the SDK can't represent), filtered in
sdkShadowDispatch before dispatch + parity.
Code-confirmed via handleDomPathOffsetCommit → commitPositionPatchToHtml →
persistDomEditOperations → onDomEditPersisted; live repro blocked because the
test comp's elements were GSAP-animated (drags route to the GSAP path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(studio): document tweenKey + selector-resolver ceilings (PR review)
Per review (Rames, non-blocking): name the two silent fail-modes in the GSAP
fidelity diff rather than build speculative disambiguators (not observed in
studio-emitted templates).
- tweenKey: coincident tweens (same element+method+position) collapse, last
wins. Props can't join the key — a matched pair must share a key for the
field-diff to run. Upgrade path: property-name hash.
- makeSelectorResolver: first-match heuristic; ambiguous shared-class selectors
may misunify. Upgrade path: querySelectorAll + uniqueness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>