The `/projects/:id/renders/file/*` route joined attacker-controlled wildcard
input straight onto rendersDir with a bare join() + readFileSync and no
containment check — the only project-scoped filesystem route that skipped the
resolveWithinProject chokepoint every sibling route uses.
Literal/encoded `../` traversal is collapsed upstream by Hono's WHATWG URL
normalization (verified empirically), so the plain LFI is not reachable over
HTTP. But a symlink living inside rendersDir and pointing outside it was still
followed and served verbatim (verified: leaked an external secret, 200 OK).
Routing through resolveWithinProject canonicalizes with realpath before serving,
closing the symlink escape and making the route's safety independent of the URL
layer's normalization behavior.
Adds regression coverage: serves an in-dir file, rejects an escaping symlink
(403), and still serves a symlink that stays inside rendersDir.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a child element inside a sub-composition is selected, the timeline
replaces the parent scene clip with the deepest-level siblings. Deselect
or selecting outside collapses back. Expanded clips are fully editable —
move, resize, delete, and split — addressed by their real DOM id with
timeline time rebased onto the sub-comp they live in.
Runtime:
- New window.__clipTree API: a read-only hierarchical ClipNode tree
(id/parentId/children + backing element) so Studio can derive
parent/child relationships for inline expansion.
Studio:
- useExpandedTimelineElements derives the expanded view from
selectedElementId + clipParentMap (pure useMemo, no useEffect).
Each child rebases onto its immediate sub-comp host (start +
sourceFile), so multi-level nesting targets the right file.
- NLELayout routes expanded-clip edits through the same handlers
top-level clips use, in local coordinates — edits save to the
sub-comp source and reflect via reloadPreview (no separate DOM-patch
path). This is the canonical update; there is no reactive observer.
- findMatchingTimelineElementId resolves sub-comp children with no
top-level element to `sourceFile#id`.
- Razor tool enabled by default; studio_razor_split analytics event
fired on single and split-all.
- O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a
cached Set+WeakSet O(1) lookup.
Adds a Code Animations catalog section — 9 self-contained, installable blocks:
morph, snippet-flight, typing, diff, highlight, scroll (DOM/GSAP) and 3d-extrude,
shader-dissolve, particle-assemble (WebGL). Each block ships only its own effect and
renders deterministically (paused GSAP timeline seeked per frame, seeded RNG, no
render-time data fetch). Wires the catalog nav, registry.json, a new code-animation
Studio category, and preview assets.
Gate the runtime's per-frame transport re-seek to yield to an active Studio manual-edit drag, so GSAP x/y-controlled elements track the cursor instead of freezing until drop. Also adds the missing sdk-playground workspace member to Dockerfile.test, which unblocks the render regression suite for any runtime-touching PR.
* refactor(core): swap studio-api read path from recast to acorn parser (T6e)
* fix(core,sdk): code-review findings — 5 correctness bugs + 2 cleanup
- gsapParserAcorn: top-level variable targets now resolved via program-scope
null-key fallback in lookupBindingFromAncestors (const el = querySelector...)
- gsapParserAcorn: fromTo guard requires args.length >= 3, preventing undefined
args[2]/args[3] access when fewer args supplied
- gsapWriterAcorn: remove fuzzing fallback in removeAnimationFromScript that
silently deleted the wrong animation (from→to ID conversion)
- gsapWriterAcorn: valueToCode guards NaN → "0" to avoid broken tween props;
safeKey regex aligned to ASCII-only (matching gsapSerialize)
- mutate: handleSetGsapTween now includes stagger in extras (was in addGsapTween
but missing from setGsapTween)
- apply-patches: script case now mirrors stylesheet — op=remove calls
setGsapScript("") instead of silently ignoring the patch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(core): add trust-model header to T6d parity suite
Documents the recast-baseline trust relationship and clarifies that
motionPath parity tests live in the Phase 3b commit (PR #1379) since
the acorn motionPath parser is also added there.
Addresses #1370 R1-N1 (Rames).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
Replaces the regex-based GSAP script parser with an acorn AST parser for the read path. This is the first of three parser PRs (T6b → T6c → T6d) that together migrate hyperframes off fragile regex parsing onto a proper AST.
## Why
The existing `gsapParser.ts` regex-based parser silently misparses edge cases: chained `.to()` calls, template literal targets, `gsap.utils.toArray(...)` expansions, lexically scoped variables, and percent-keyframe arrays. These misparses produce wrong `animationId` values that downstream SDK write ops use as keys — write ops targeting the wrong node corrupt the script. The fix is to parse with a real JS AST.
## What changed
**`packages/core/src/parsers/gsapParserAcorn.ts`** (new, ~1100 lines)
- `parseGsapScriptAcorn(script)` — full-featured read-path parser. Walks an acorn AST to extract:
- Timeline variable detection (`gsap.timeline()` assignment)
- `resolvedStart` computation: handles absolute positions, label references, relative `+=`/`-=`, chained calls
- Property group classification (`transform`, `opacity`, `color`, etc.)
- GSAP keyframes: percentage-object, object-array, simple-array with three-level easing
- Variable target resolution: `querySelector`, `getElementById`, `querySelectorAll`, `gsap.utils.toArray`, array literals, forEach/map callbacks
- Timeline `defaults` inheritance
- Stagger / repeat / yoyo extraction
- All `animationId` values are content-addressed (`target-method-startMs-group`) for deterministic round-trips
- Note: `parseGsapScriptAcornForWrite` (the write-path slice used by T6c) lives in T6c (#1369), not this PR
**`packages/core/src/parsers/gsapParser.acorn.test.ts`** (new, ~220 lines)
- Differential corpus tests: same input run through both the old regex parser and the new acorn parser, asserting outputs are equal on the scenarios the old parser handled correctly
- Catches regressions during the transition without requiring tests to be rewritten
- `onComplete`/`onStart`/`onUpdate`/`onRepeat` dropped-key assertions added in Phase 3b commit (#1379) where `DROPPED_VAR_KEYS` is defined — the test file is in T6b but the extended assertions live one commit up-stack
**`packages/core/package.json`**
- Added `acorn` and `acorn-walk` dependencies
## Test plan
- `bun run test packages/core` → all tests pass (35 passing in the T6b suite alone)
- Stacked on: `main`
- Stack above: T6c (write path), T6d (parity suite)
Resize: proportionally scale all GSAP animation positions and durations
to fit the new clip duration via scalePositionsInScript. This preserves
clip-relative keyframe percentages — diamonds don't move during resize,
nothing disappears. Modeled after After Effects Time Stretch behavior.
Drag: shift all GSAP positions by the time delta (unchanged from before).
Diamond rendering:
- Clamp diamonds at 0%/100% so they stay fully visible at clip edges
- Filter out-of-range keyframes using predicted percentages during resize
- Clamp connection lines to clip boundaries
- PropertyRows: same edge clamping for SVG diamonds
Parser: scalePositionsInScript (proportional position + duration scaling),
shiftPositionsInScript (rigid shift), scale-positions + shift-positions
mutation types, 5 shift tests passing.
* feat(studio,cli): music beat detection with timeline guides + headless beats CLI
Beat detection for music tracks: the Studio draws beat guides on the active
track, beats are user-editable and persist to a project file, and a new
`hyperframes beats` CLI generates that file headlessly before the Studio opens.
Detection lives in @hyperframes/core/beats (shared by Studio + CLI): an energy
onset detector cross-validated with bpm-detective, regularized to an octave-
aligned grid, silence-gated, with per-beat loudness. Music-only — an
<audio data-timeline-role="music"> is analyzed; voiceover is excluded.
Studio: green beat lines + draggable dots on the selected track; add at playhead,
drag to move, double-click to delete (audio scrubs); edits persist to
beats/<audio>.json and are undoable (interleaved with file history).
CLI: `hyperframes beats [dir]` runs the same detection in headless Chrome
(prebuilt browser bundle in dist) and writes the beat file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): timeline beat-grid + zoom UX refinements
- Center-anchored magnify: zooming via the toolbar/slider keeps the time
at the viewport center fixed instead of anchoring at the left. Pinch
still anchors at the cursor.
- Move-snap to beats: dragging a clip snaps whichever edge (start or end)
is nearest a beat, matching the existing resize-edge snapping.
- Beat lines on track backgrounds: faint full-height beat lines now paint
behind the clips on every track lane (brightness scales with loudness);
the green dots stay on the active track's top bar.
- Waveform follows zoom: bars fill the full clip width and resample the
windowed peaks, so the waveform stretches with zoom instead of stopping
partway across a widened clip.
- Beat dots centered in the top bar: align the dot band to the clip top
(CLIP_Y) so the dots sit centered in the dark bar instead of being
bisected by the clip's top border.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio): preserve media sourceDuration across element re-derivation
Moving a non-music clip re-derived the timeline elements into fresh
objects whose sourceDuration the DOM scan hadn't loaded yet. The async
probe skips srcs already in its cache, so the value was silently
dropped — trimFractions then returned no window and the trimmed music
waveform reset to the full source pinned at the track start.
Re-apply the cached probe duration synchronously on every derivation
(applyCachedSourceDurations) and extract the async probe loop into
probeMissingSourceDurations to keep useTimelinePlayer within the file
size limit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): skip beat-snap on the music track, highlight move-snap target
The music track defines the beats, so moving or trimming it no longer
snaps to its own beats (isMusicTrack guard on both the move and resize
snap paths).
Moving another clip snapped only on drop with no cue. snapMoveStartToBeat
now also returns the beat it will snap to; BeatBackgroundLines draws that
beat's line as a bright neon-green glow while the clip's edge is within
the snap region, so the target is visible before drop.
Also drops .commitmsg.tmp, accidentally committed via git add -A.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): hide playhead while dragging a beat; default beat dots to music track
- Dragging a beat dot now hides the playhead guideline (new beatDragging
store flag set on beat pointer down/up) so its line doesn't track the
scrub and clutter the beat being moved.
- Beat dots render on the selected track, falling back to the music track
when nothing is selected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(core): remove polynomial-ReDoS regex from audioRelPathForSrc
CodeQL js/polynomial-redos: the lazy `.+?` followed by an optional
trailing `[?#].*$` backtracks polynomially on crafted `/preview/...`
inputs. Parse the preview-relative path with indexOf/slice instead, and
strip the query/hash with a single linear char-class search. Behavior is
unchanged for all preview/absolute/blob/data/bare inputs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio,core,cli): review hardening for beat detection + timeline UX
- playerStore.reset() now clears beat state (analysis, edits, undo/redo,
persist) so a project switch can't apply the previous project's beats,
undo stack, or file-writer to the new one.
- removeUserBeat returns the same reference on a no-op, and delete/move beat
actions skip committing when nothing changed — no more phantom undo
entries / debounced writes for no-op edits.
- regularizeBeats bails to raw onsets when the (octave-misread) tempo would
produce a sub-125ms grid, avoiding a tens-of-thousands-of-beats freeze.
- parseBeats clamps strength to [0,1] and rejects non-finite time/strength,
so a hand-edited file can't feed NaN into the gamma curve (Math.pow on a
negative base) and blank out beat markers.
- Start-edge beat-snap now also requires duration >= minDuration, matching
the end-edge guard, so a rightward snap can't collapse the clip.
- Center-anchor zoom effect always consumes its skip flag, so a pinch that
produced no pps change can't leave it stranded and skip the next zoom.
- Headless beats analyzer projects to {beatTimes,beatStrengths,bpm,confidence}
before returning, so page.evaluate no longer serializes the full decoded
PCM (channelData) across the CDP boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(core): gate parseBeats on schema version
parseBeats accepted any object with a beats array, so a future v2 beat file
(with changed semantics) would be parsed silently as v1. Reject anything whose
version is not 1, treating an unknown version like an absent/invalid file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
---------
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(player): bound the parent audio proxy to its clip window
When iframe autoplay is blocked, audible playback is promoted to a parent-frame
audio proxy. The proxy read the clip's data-start/data-duration once at adopt
time and mirrorTime() only skipped (never paused) the element outside that
window — so a trimmed/moved music clip kept playing the full source past its
on-timeline end, even though the iframe element was correctly paused.
Fix: the proxy keeps a reference to its source iframe element and re-reads
data-start/data-duration each mirror tick (live trims/moves apply), pauses the
proxy when the playhead leaves [start, start+duration), and resumes it when the
playhead re-enters during parent-owned playback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(core,studio): bound trimmed audio playback to the clip window
Trimmed audio played to the source file's natural end instead of
stopping at the clip edge, on every audio path:
- WebAudio (the audible path in Studio): schedulePlayback now passes
the clip's data-duration as the third start() arg, so the decoded
buffer stops at the trimmed edge instead of running to the file end.
- Runtime element gating: the duration resolver caps each clip by its
own data-duration (min of source length, host window, authored
duration), so a trimmed <audio>/<video> element pauses at its edge.
Studio trim UX:
- Resize live-patches the media-start/playback-start offset, so a
start-edge drag trims into the source instead of only repositioning
the clip.
- AudioWaveform windows the rendered peaks to the trimmed slice so the
waveform tracks the clip edges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(player,core): gate proxy playback to the live clip window
Review follow-ups on the parent-audio-proxy / WebAudio bound:
- seekAll now re-reads live source bounds (_refreshEntryBounds) before
gating, so a paused scrub right after a trim/move uses the current clip
window instead of the adopt-time one.
- playAll and clip adoption only start a proxy when the playhead is inside
the clip's window (_playEntryIfActive), so bulk starts / promotion no
longer blip audio for clips outside their window until the next tick.
- The WebAudio buffer is now bounded by the host-composition window too
(matching resolveDurationSeconds), so a sub-composition-nested clip stops
at the same edge on the WebAudio and HTMLMedia paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds
A bounded WebAudio source's wall-clock length is baked into start()'s duration
arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in
place on a later rate change does not rescale that bound, so a trimmed clip ends
early (fast) or late (slow). setRate now reports whether the rate changed and
exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active
clips at the new rate when any bounded source is live. The per-clip schedule
loop is extracted to a shared closure so play() and the rate path agree.
Also guard _refreshEntryBounds against a non-numeric duration attribute parsing
to NaN, which would make every window check false and let the proxy play past
its clip end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
---------
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(render): make WebGL video textures deterministic in headless render
WebGL compositions that sample a `<video>` as a texture (e.g. a faceted
crystal with clips mapped onto its facets) rendered with flickering,
non-deterministic facets: a video would intermittently show a stale frame or
go black, and the same frame differed between two renders.
Two gaps caused this:
1. No WebGL analog of the WebGPU `patchVideoTextureCompat`. Chrome's headless
compositor can't feed decoded `<video>` frames to the GPU, so the engine
injects a decoded `<img class="__render_frame__">` sibling per video each
frame. The WebGPU `copyExternalImageToTexture` path substitutes it, but
`texImage2D` / `texSubImage2D` did not — so WebGL uploaded a stale/black
frame. Add `patchWebGLVideoTextureCompat()` mirroring the WebGPU patch
(shared `resolveRenderFrameImage` helper).
2. Capture ordering. Per frame the runtime seeks (GPU adapters render on
`hf-seek`) BEFORE the engine injects the decoded frames, so the GPU render
read a frame that didn't exist yet. After injecting, the engine now calls
`window.__hfReseekGpu(t)` — a force-dispatch (`forceDispatchSeekEvent`) that
bypasses the same-time `hf-seek` dedup — so GPU compositions re-upload their
textures from the freshly-injected, decoded frames, deterministically.
Tests: unit tests for the texImage2D/texSubImage2D substitution and the
force-dispatch, plus a videoFrameInjector regression test asserting the
post-injection GPU reseek fires only when frames were injected. Verified
end-to-end: a WebGL prism with 8 live <video> facets renders byte-identical
across independent runs with no facet flicker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(render): add producer render-compat regression for WebGL video textures
A WebGL2 canvas samples a <video> as a texture every hf-seek (the natural
author pattern, distilled from the HeyGen prism). The render-compat harness
renders it and compares against the golden: with the video-texture fix the
render reproduces the decoded frames; revert the fix and the canvas renders
black, collapsing the comparison.
Golden verified to contain real, time-varying video content (not black), so a
regression is caught rather than passing vacuously.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Structural follow-up to the symlink-escape fix. The recurring miss (#465
fixed isSafePath but left render.ts; the sweep then turned up play.ts,
htmlBundler, ...) is because containment was enforced by convention —
"remember to call isSafePath after every resolve()" — which a new call site
can silently skip.
Add resolveWithinProject(base, relativePath) -> string | null (resolve +
containment in one call) and route the studio-api + bundler sites through
it, so a caller cannot resolve a project-relative path without the guard:
- studio-api routes/files.ts (read, rename, duplicate, upload-dir), preview.ts
(sub-comp + static asset), render.ts (composition) — all the
resolve()+isSafePath() pairs collapse to a single call.
- compiler/htmlBundler.ts: its local safePath helper was exactly this; drop
it for the shared one.
Left intentionally on isSafePath: files.ts upload (resolves a name against a
validated sub-dir but contains against the project root) and htmlBundler's
CSS @import (resolves against the CSS file's dir, contains against the root) —
these resolve and contain against *different* bases, which the single-base
chokepoint doesn't model.
Exported from @hyperframes/core and re-exported from studio-api/helpers for
back-compat. Adds resolveWithinProject unit tests; all existing studio-api
route tests pass unchanged (behavior is identical — same resolve, same
containment, same reject paths).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): block symlink-based path escape in studio-api isSafePath
path.resolve() collapses ./.. but does not dereference symlinks, so a
symlink living inside the project dir but pointing outside it (e.g.
project/link -> /etc) passed the prefix check, letting a downstream
read/write/stat follow it to a file outside the project root. The `..`
traversal case was already blocked; symlink traversal was the gap.
Canonicalize both base and target with realpathSync before comparing.
The target may not exist yet (new-file writes), so canonicalize the
deepest existing ancestor and re-attach the trailing not-yet-existing
segments, which cannot be symlinks at check time. Fail closed if base is
unresolvable.
Adds safePath.test.ts covering: in-base allow, not-yet-existing write
target, `..` escape, existing-file-through-symlink escape, write-target
under a symlinked parent, file-symlink escape, in-base symlink allow,
symlinked-base canonicalization, and base-missing fail-closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core,cli): route render + play composition paths through isSafePath
Review on #1397 found a third call site with the same vulnerable
startsWith pattern. Apply Rule 2: fix every site sharing the contract
(gate an attacker-influenced path before a symlink-following fs op).
- studio-api routes/render.ts: body.composition (from c.req.json()) was
checked with `resolved.startsWith(resolve(project.dir) + sep)`, which
doesn't dereference symlinks — an in-project symlink to an external
target escaped the project root. Now uses isSafePath().
- cli commands/play.ts: the `/composition/*` server route used
`filePath.startsWith(project.dir)` with no trailing-separator guard, so
both a sibling dir sharing the prefix (`<dir>-evil`) and symlink escapes
passed. Now uses isSafePath() via @hyperframes/core/studio-api (the same
lazy-import pattern commands/validate.ts already uses).
Tests: render.test.ts gains a "composition path safety" block (in-base
allow, `..` reject, in-project-symlink-to-outside reject, in-project
symlink staying inside allow). The shared render test adapter now points
at a real dir since isSafePath fails closed on an unresolvable base
(production project dirs always exist on disk).
Not in this change: compiler/htmlBundler.ts has the same class at two
sites (safePath helper + inline CSS @import check), but the compiler sits
below studio-api in the dependency graph and can't import isSafePath
without a backwards edge; that fix needs the helper promoted to a neutral
module and is tracked as a follow-up. renderArgs.ts / videoFrameExtractor.ts
carry the trailing-sep guard and a local-CLI/engine-internal threat model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): promote isSafePath to a shared module + harden htmlBundler
Per review on #1397: extend the symlink-escape fix to the compiler, and
remove the duplicated path-safety logic.
- Move isSafePath to packages/core/src/safePath.ts (a neutral package-root
module). studio-api/helpers/safePath.ts re-exports it for back-compat
(keeping walkDir), and it's now exported from the core entrypoint so
non-studio-api layers can use it. compiler/ sits below studio-api in the
dep graph, so it could not import the helper from its old home without a
backwards edge — the promotion removes that constraint.
- compiler/htmlBundler.ts: route both containment checks (the safePath
helper and the inline CSS @import check) through isSafePath. The bundler
reads+inlines these files, so an in-project symlink pointing outside the
root would otherwise bake external content into the output. All callers
already skip on a null/false result, so nothing is read on rejection.
Tests: safePath.test.ts moves with the impl; htmlBundler.test.ts gains a
case proving an in-project sub-composition script is inlined while a
script reached through an escaping symlink is not (positive control + leak
assertion).
Deferred (tracked for a dedicated follow-up, see PR thread): the
relative()-based isPathInside family (core/compiler/assetPaths,
producer/services/fileServer, producer/utils/paths and their callers in
the render pipeline) is symlink-blind in the same way, and engine
videoFrameExtractor's asset resolver needs a caller-side gate (its http
downloads land outside the project root, so a single-root check is wrong).
Both are regression-sensitive render-pipeline surfaces that warrant their
own focused, well-tested pass. renderArgs.ts is intentionally left: it is
filesystem-free by design (injected stat) and its threat model is the
user's own --composition CLI arg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(core): hedge symlink tests for Windows + copy before reverse (review nits)
Addresses Via's non-blocking review notes on #1397:
- Wrap every symlinkSync in the new tests with a tryCreateSymlink helper that
returns false (and the test early-returns) when creation throws, mirroring the
preview.test.ts convention. Non-symlink-privileged Windows runners no longer
risk crashing the suite on EPERM.
- safePath.ts: `[...trailing].reverse()` instead of mutating `trailing` in place —
harmless today (single return) but future-proof against a looping edit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main went red again at e2cc134c: my #1399 fix branched off the pre-#1366
state (where `.hyperframes` was wholesale-hidden via IGNORE_DIRS) and, when
it merged on top of #1366, overwrote #1366's corrected test with an
assertion that `.hyperframes/examples` is hidden.
#1366 is the authoritative behavior: walkDir now hides only
`.hyperframes/backup` (shouldIgnoreDir), so `.hyperframes/examples` — like
any other vendored dot-dir — stays visible in the file tree and is gated out
of composition discovery by isInHiddenOrVendorDir. That is the original #1384
intent.
Correct the file-tree test to match:
- `.cache/examples/preset.html` and `.hyperframes/examples/preset.html` are
both visible in `files` (kept the `.cache` case from #1399 — it exercises
isInHiddenOrVendorDir gating for a non-special dot-dir).
- `.hyperframes/backup/snapshot.html` is the only thing hidden from the tree.
- Compositions still exclude every dot-dir example.
Full non-producer suite green; walkDir "hides backups" test untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1385 ("exclude dot-directories from composition discovery", b952dc9c)
merged with a failing test, leaving main red. Its commit message assumed
"walkDir only skipped three exact names (.thumbnails, node_modules, .git)",
but `.hyperframes` had already been added to walkDir's IGNORE_DIRS by the
backup feature (with its own passing "hides internal backup files" test).
So the new test "keeps dot-directory files visible in the file tree" used
`.hyperframes/examples/preset.html` — the one dot-dir that walkDir hides —
and asserted it appears in `files`, which can never hold: `files = walkDir(...)`
filters `.hyperframes`. The implementation is coherent; the test picked the
wrong fixture and never exercised the isInHiddenOrVendorDir gating it meant to.
Fix the fixtures (test-only, no production change):
- Add a genuinely-vendored dot-dir `.cache/examples/preset.html` — walkDir does
not special-case it, so it stays in the file tree but must be gated out of
composition discovery by isInHiddenOrVendorDir. This is what #1385 actually
targets, now properly exercised.
- Keep `.hyperframes/examples/preset.html` and assert it is hidden from the file
tree (IGNORE_DIRS) — documenting the deliberate divergence so the two features
(Studio-internal backups vs. browsable vendored dot-dirs) don't collide again.
Full non-producer suite green; the walkDir "hides backups" test is untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics
Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.
- Retry code-editor saves with exponential backoff instead of dropping the
edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
queue with a user-visible error state instead of burning every queued
mutation against it.
- save_failure events now carry error_message, status_code, and source on
every emission path; style/attribute DOM-edit failures that previously only
logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
undo/redo, text fields) through a safe wrapper that reports failures via
telemetry instead of unhandledrejection.
Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.
* fix(studio): narrow save retry changes for fallow
Projects that vendor tooling assets under dot-directories ended up with
every example/preset HTML inside them listed and preview-rendered in the
comps sidebar, and the studio Lint badge inflated with findings from
files that are not part of the video. walkDir only skipped three exact
names (.thumbnails, node_modules, .git), so any other dot-directory
(.hyperframes/, .cache/, ...) was walked.
Add an isInHiddenOrVendorDir helper that rejects paths with a
dot-directory or node_modules segment and apply it to composition
discovery and the studio lint route. The file tree is deliberately left
unfiltered - this only gates discovery.
Fixes#1384
* fix(runtime): respect hidden ancestor clips in Studio preview (#1387)
Studio-stamped GSAP tween targets inside timed clips were getting
visibility:visible for the full composition, overriding hidden parent
panels. Skip stamping descendants of authored clips and suppress
visibility on children when an ancestor timed clip is hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(runtime): scope ancestor visibility walk to Studio iframe only
Address review feedback: the hierarchical visibility guard now runs only
when window.parent !== window, matching the Studio-only stamping fix.
Render mode keeps prior per-element visibility semantics. Adds a render-mode
regression test and documents the null rootComp case in findTimedClipAncestor.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(core): honor root data-duration when GSAP timeline ends short
The authored-duration floor only counted child composition clips, never
the root element's own data-duration. A composition whose GSAP timeline
ended even 0.1s short of its declared data-duration reported the shorter
timeline length from player.getDuration() — and the studio's adapter
selection (docDuration <= adapterDur) then silently rejected the
audio-capable runtime player, downgrading preview playback to the
seek-scrubbing adapter, which never starts media elements or WebAudio.
Result: total audio silence with zero errors anywhere.
- include the root's declared data-duration in
resolveAuthoredCompositionDurationFloorSeconds, making data-duration
the source of truth for playable length (per the documented contract)
- console.warn in the studio when playback falls back to the
seek-driven adapter, since the downgrade loses audio invisibly
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(studio): release static-seek adapter on native win, warn once on downgrade
Review findings on the previous commit, all in the static-seek fallback
path of useTimelinePlayer.getAdapter:
- A cached static-seek adapter was never paused when adapter selection
later resolved a native adapter (the early returns bypass the fallback
branch entirely), leaving its private rAF loop seeking the player while
the native transport also drives it. The core data-duration fix makes
this switch path much more common. releaseStaticSeekCache() now runs
at every native-adapter return and at unmount.
- The downgrade warning fired on every cache miss — and the cache key can
never hold for __timelines compositions because wrapTimeline() returns
a fresh object per call, so it fired every rAF tick. It now warns once
per downgrade streak (re-armed when a native adapter takes over).
- The warning interpolated adapterDur (the native __player duration,
0 when absent) instead of the selected adapter's duration, and used a
one-off "[hyperframes-studio]" prefix instead of the file's
"[useTimelinePlayer]" convention.
The fallback cache logic moved to playbackAdapter.ts (with unit tests for
warn-once, cache identity, and pause-on-replace/release), which also
keeps useTimelinePlayer.ts inside the studio 600-line limit. Also
corrected a stale "no DOM reads" comment on the runtime transport tick —
the duration floor has always queried the DOM per call, and now also
reads the root's declared data-duration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Problem
Windows renders commonly fail with environment errors before any real work starts:
- `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists.
- `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling.
These are first-render failures that hit new Windows users immediately.
## Fix
- Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path.
- New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints.
- Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH.
- Map opaque Windows ffmpeg exit codes to actionable messages.
## Testing
- New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary.
- CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean.
- Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
## Problem
The most common render failure in recent reports is:
```
Cannot destructure property 'firstElementChild' of 'documentElement' as it is null.
```
It appears when a `data-composition-src` file resolves to empty or unparsable HTML, and started showing up after the render pipeline change in 0.6.73.
## Root cause
When a sub-composition file is empty or unparsable, linkedom's `parseHTML` returns a document with a null `documentElement`, and the shared inliner (`packages/core/src/compiler/inlineSubCompositions.ts`) dereferences `.body`/`.head` on it, crashing inside linkedom internals with the cryptic destructure error instead of telling the user what's wrong.
## Fix
Guard the resolved sub-composition HTML and the extracted content HTML in the shared inliner: empty or unparsable input now fails with an actionable error naming the offending file.
## Testing
- New tests in core and producer reproducing the empty sub-composition case (previously crashed with the destructure error, now throws the actionable message).
- `bun run build` green, all tests pass in the changed test files.
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors
* test(sdk,ci): smoke test + explicit sdk-tests CI gate
Smoke test covers the full public surface:
openComposition → setStyle/setText/dispatch(moveElement) → serialize
applyPatches + ORIGIN_APPLY_PATCHES tagging
batch() coalescing + transactional rollback on throw
undo/redo round-trip
persist adapter write + persist:error surfacing
T3 embedded mode: override-set apply on open + getOverrides round-trip
Adds sdk-tests CI job so SDK coverage is explicitly named and required —
prevents a repeat of the demo-next vitest-never-ran incident.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sdk): export adapter types, awaitable flush(), never-coalesce mode
- Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package
root — callers can now write typed fakes without reaching into internals
- Add flush(): Promise<void> to Composition interface + CompositionImpl —
app-close handlers can await a clean drain of the persist queue
- coalesceMs <= 0 disables coalescing entirely in createHistory — enables
deterministic test scenarios without per-entry timestamp manipulation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke
- setText on element with no prior text node (firstTextIdx=-1 path)
- applyOverrideSet null removal on non-existent prop is a no-op (no throw)
- smoke persist test uses comp.flush() instead of setTimeout
- can() JSDoc clarifies Phase 3b false-return is intentional feature-detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* ci: trigger regression suite
* fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy
bun install --frozen-lockfile fails in the regression Docker build because
the lockfile references the sdk workspace member but its package.json was
not copied into the image before the install step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)
* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access
- index.ts no longer exports document/session/history/persist-queue (those
modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
arrive in #1325
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline
- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
parser-backed ops instead of silently no-opping — callers must never
believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
dist-only; top-level build previously produced no SDK artifacts).
publish.yml intentionally NOT updated — sdk stays unpublished until
Phase 3 completes.
Adversarial-review findings F3 + F4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs
Round-2 review (Rames/Miguel) on the engine layer:
- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
survive postMessage/structured-clone, which T3 embedded hosts may forward
patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
as a forced override of inline style (init.ts applyCompositionSizing).
Style is always written; the data-* attr is updated when already present
so the edit isn't clobbered on load. Absent attrs stay absent — inverses
stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
(add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.
NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): moveElement writes data-x/data-y, not left/top CSS
HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.
Adds inverse round-trip test for prior position restore.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: update bun.lock after sdk package registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete
* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup
- getElements/getElement/find now walk the live linkedom DOM via buildRoots
with a lazily-built cache invalidated on dispatch/applyPatches — no
serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): restore full public exports now session/document modules exist
index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): coalesce history by patch paths; replay override-set on open
Adversarial-review findings F1 + F2:
- history: coalescing now requires identical patch paths in addition to
op types + origin + window. Previously two rapid setStyle calls on
DIFFERENT elements merged into one entry carrying the second forward +
first inverse — undo then reverted the wrong element and stranded the
latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
override-set onto the freshly-parsed base before exposing the session
(new keyToPath inverse mapping + applyOverrideSet). Previously the
overrides were copied into the map but never applied — reopening an
embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
override round-trip (style/text/attr/timing/removal/restore-base).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify
Round-2 review (Rames/Miguel) on the session layer:
- batch() is now transactional: on throw, accumulated inverse patches are
replayed in reverse and the override-set snapshot restored — the model is
exactly as it was at batch entry. Previously a throwing batch left the DOM
partially mutated with no patch trail, no history entry, no recovery path.
2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
findRoot — dimension extraction and mutations agree on the root element
([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
verbatim.
Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting
* fix(studio,core): persist manual position edits for GSAP-owned elements
- sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom
properties and transform longhands via setProperty; patch the style
attribute string directly so --hf-studio-offset-* and translate survive
the server round-trip (positions never reached disk before this)
- gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens
ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS
translate into its cache once at init, zeroes the longhand once, and never
re-reads it
- applyStudioPathOffset: for GSAP-owned elements keep translate:none live and
sync the offset into GSAP's cache via gsap.set; writing the longhand
double-applied the offset (disappearing elements, scrub snap-back)
- buildPathOffsetPatches: emit the var() translate expression explicitly so
the persisted file re-folds on reload (live inline is none)
- StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response
probe mutates GSAP's cache, which inline-style restore cannot undo (click
made elements jump by the probe distance)
- reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop
seek-time double-apply
- STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is
opt-in until its recording path is hardened; commits take the CSS persist
path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(studio): remove duplicate flag declaration, trim useDomEditCommits to 600 lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)
* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access
- index.ts no longer exports document/session/history/persist-queue (those
modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
arrive in #1325
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline
- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
parser-backed ops instead of silently no-opping — callers must never
believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
dist-only; top-level build previously produced no SDK artifacts).
publish.yml intentionally NOT updated — sdk stays unpublished until
Phase 3 completes.
Adversarial-review findings F3 + F4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs
Round-2 review (Rames/Miguel) on the engine layer:
- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
survive postMessage/structured-clone, which T3 embedded hosts may forward
patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
as a forced override of inline style (init.ts applyCompositionSizing).
Style is always written; the data-* attr is updated when already present
so the edit isn't clobbered on load. Absent attrs stay absent — inverses
stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
(add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.
NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): moveElement writes data-x/data-y, not left/top CSS
HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.
Adds inverse round-trip test for prior position restore.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: update bun.lock after sdk package registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete
* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup
- getElements/getElement/find now walk the live linkedom DOM via buildRoots
with a lazily-built cache invalidated on dispatch/applyPatches — no
serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): restore full public exports now session/document modules exist
index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): coalesce history by patch paths; replay override-set on open
Adversarial-review findings F1 + F2:
- history: coalescing now requires identical patch paths in addition to
op types + origin + window. Previously two rapid setStyle calls on
DIFFERENT elements merged into one entry carrying the second forward +
first inverse — undo then reverted the wrong element and stranded the
latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
override-set onto the freshly-parsed base before exposing the session
(new keyToPath inverse mapping + applyOverrideSet). Previously the
overrides were copied into the map but never applied — reopening an
embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
override round-trip (style/text/attr/timing/removal/restore-base).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify
Round-2 review (Rames/Miguel) on the session layer:
- batch() is now transactional: on throw, accumulated inverse patches are
replayed in reverse and the override-set snapshot restored — the model is
exactly as it was at batch entry. Previously a throwing batch left the DOM
partially mutated with no patch trail, no history entry, no recovery path.
2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
findRoot — dimension extraction and mutations agree on the root element
([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
verbatim.
Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary
Exposes `hf-ids` as a dedicated subpath export from `@hyperframes/core` so `@hyperframes/sdk` can import ID-stamping logic without pulling in the full core bundle.
- Adds `"exports"` entry for `./hf-ids` in `packages/core/package.json`
- No change to the existing top-level export — no breaking change for existing consumers
## Why
`@hyperframes/sdk` needs `parseMutable`/`stampHfIds` from core. A subpath export isolates that boundary and keeps the SDK bundle lean.
## Test plan
- [ ] `bun run build` — both packages build without errors
- [ ] `bun test packages/sdk` — import resolves correctly
🤖 Generated with [Claude Code](https://claude.ai/claude-code)
* refactor(studio): extract shared timeline components and deduplicate code
Extract shared utilities to reduce duplication across timeline components:
- PlayheadIndicator: shared playhead rendering (was duplicated in
TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
* feat(core): GSAP-aware split engine for timeline clip splitting
Add splitAnimationsInScript to the GSAP parser — correctly re-times
animations when a timeline clip is split at an arbitrary position:
- Animations before split: kept on original, properties inherited via
tl.set inserted before other tweens for correct GSAP state recording
- Animations after split: retargeted via AST selector update
- Spanning animations: trimmed on original, continuation added for
new element with correct position and duration
- Keyframes: classified by total per-keyframe duration
- Reverse iteration prevents stale animation ID collisions
Enhance splitElementInHtml:
- CSS rule duplication via PostCSS for ID-based styles
- Server-side ID deduplication for repeated splits
- Media playback-start adjustment for video/audio
Add split-animations route to gsap-mutations endpoint.
Extract shared utilities to reduce duplication across timeline components:
- PlayheadIndicator: shared playhead rendering (was duplicated in
TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs