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.
Pressing Delete with keyframes multi-selected removed keyframes from the
wrong element. selectedKeyframes holds "<elementId>:<percentage>" keys and
can outlive the element it was built on (a clip click, keyframe click, layers
selection, or keyframe context menu changes the active element without clearing
it, and a shift-selection can span elements). deleteSelectedKeyframes parsed
only the percentage from each key and applied it to the active animation,
ignoring which element each selected keyframe belonged to, so a stale selection
deleted keyframes the user never targeted on the active element.
Extract selectedKeyframePercentagesForElement, which keeps only the percentages
whose key matches the active element id, and route the delete through it. The
common case (all selected keyframes on the active element) is unchanged; stale
cross-element keys are skipped instead of mis-applied.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
* feat(studio): drag keyframes with beat snapping
Keyframe diamonds are draggable with live preview and snap to the music
beat grid (requires VITE_STUDIO_ENABLE_KEYFRAMES=1).
Drag model: a tween start point trims the front (end fixed), an end point
resizes (start fixed), an intermediate keyframe moves within the tween
(adjacent segments resize, others untouched; start/end moves remap the
intermediates to preserve their absolute times). The keyframe snaps to the
nearest beat within ~8px, centered exactly on the dot.
Reliability: the commit resolves the dragged element's selection + parsed
animations on demand (awaited) instead of relying on the async DOM-edit
session, picks the tween whose window contains the keyframe's original
time among same-group tweens, and holds the dropped position optimistically
until the cache round-trip lands. Cache clip% precision raised to 0.001%
so the marker lands exactly where dropped.
Pure match/plan logic + unit tests in editor/keyframeMove.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio): harden keyframe drag commit (review follow-ups)
- pickKeyframeTween no longer falls back to ALL animations on a selector
mismatch — it only picks among the dragged element's own tweens, so a
class/compound-selector mismatch can't edit a different element. No
match → no-op.
- computeKeyframeMovePlan bails to a no-op when a keyframe-array tween's
dragged keyframe can't be located (stale cache / precision drift) instead
of falling through to an end-point resize that silently rescaled the
whole tween and re-timed every keyframe.
- usePopulateKeyframeCacheForFile clipPct now uses 0.001% precision
(matching useGsapAnimationsForElement) so beat-snapped keyframes from the
file-wide cache also center on the dot and the two caches agree.
- The optimistic drag hold only releases once the cache reflects the
committed position (a keyframe near the held %), so an unrelated cache
rebuild no longer flashes the diamond back to its old spot.
- A drag's document listeners are cleaned up on unmount, so an unmount
mid-drag (clip delete / comp switch / zoom-out) no longer leaks them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(studio): shrink + lower keyframe diamonds under the beat strip
When a clip's track shows the beat-dot strip (the top band), its keyframe
diamonds and connecting lines render at 45% size and centered in the
region below the band, so they don't collide with the dots. Full size and
vertically centered otherwise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio): ignore keyframe re-drag during the optimistic-hold window
After a drop, the diamond is held at its dropped position (via effPct) until the
file round-trip lands, but `pct` passed to handlePointerDown still comes from
props (the pre-drop position). Re-grabbing the same keyframe in that window
would track the drag from a stale origin and commit against the wrong tween (or
no-op via the stale-cache guard). Skip starting a drag while a hold is pending;
it clears on the cache match (≤2s fallback). Click selection is unaffected.
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>
* 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(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak
- Guard setIsPlaying to skip set() when value unchanged (eliminates 60
notifications/sec during reverse playback)
- Guard caption store selectGroup to bail before set() when group missing
(prevents empty Zustand notifications)
- Guard clearSelection to skip when already empty
- Fix useConsoleErrorCapture: restore original console.error, remove error
event listener, and delete __hfErrorCapture flag on cleanup
* fix(studio): delete dead files and unused exports
Remove 7 dead files (audioBeatDetection, keyframeSnapping,
timelineInspector, DopesheetStrip, StaggerControls,
TimelineLayerPanel, TimelineEditorNotice) and their test companions.
Delete unused computeFitToChildrenSize export from propertyPanelHelpers.
Fix re-export indirection: useDomEditCommits and studioMotionOps.test
now import patch builders directly from manualEditsDomPatches instead
of the re-export passthrough in manualEditsDom.
* fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch
Move lint findingsByElement sync from App.tsx into useLintModal where
the value is produced, removing the mirroring useEffect. Consolidate
4 hover-clearing effects in useDomSelection into 2 (one unconditional
on context change, one conditional combining caption mode, selection
match, and disconnected element checks). Fold the GSAP retry effect
into the fetch effect in useGsapTweenCache, scheduling a single retry
via setTimeout when the initial fetch returns 0 animations.
Eliminates 3 unnecessary render cycles from effect chains.
* fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade
- Wrap renderQueue object in useMemo so StudioContext consumers don't
re-render on every App render
- Memoize timelineToolbar JSX so NLELayout memo isn't defeated
- Move canvasRect getBoundingClientRect() from render-time IIFE to a
useLayoutEffect-backed ref, eliminating layout thrashing
- Track and clear setTimeout handles in refreshPreviewDocumentVersion
to prevent stale timer accumulation on rapid calls and unmount
* refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing
Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe
accessors (getIframeGsap, queryIframeElement), percentage keyframe
parsing, and toAbsoluteTime into a single gsapShared.ts module.
Removes ~120 lines of copy-pasted logic across 8 hook files, reducing
drift risk between the duplicate implementations.
* fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions
* refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access
* fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders
* refactor(studio): decompose useGsapScriptCommits into focused mutation hooks
* refactor(studio): decompose useFileManager into focused file operation hooks
Extract useFileTree (tree loading, refresh, derived assets/compositions)
and useEditorSave (debounced save with history tracking) from the 508-LOC
useFileManager. The parent hook composes both and retains file I/O,
click-to-source, upload/import, and CRUD — preserving the same public
interface so no consumers change.
* refactor(studio): decompose useDomEditCommits into focused commit hooks
Extract geometry (path offset, box size, rotation) and element lifecycle
(delete, z-index reorder) into useDomGeometryCommits and
useElementLifecycleOps. Parent keeps persistDomEditOperations as core
and composes all sub-hooks — public interface unchanged.
* refactor(studio): simplify useAppHotkeys with declarative command table
* refactor(studio): simplify useAppHotkeys with declarative command table
Replace 15 individual useRef callback refs with a single cbRef object.
Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey
functions. Merge duplicate undo/redo logic into shared applyHistory.
Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener.
Hook body: 204 LOC (down from 445). Public API unchanged.
* fix(studio): remove unused getDomEditTargetKey import
* refactor(studio): decompose useDomEditSession into focused editing hooks
Extract GSAP-aware geometry intercepts (move/resize/rotation) and
animated property commit into useGsapAwareEditing, and selection
wiring, GSAP cache management, preview sync, and selection handlers
into useDomEditWiring. The parent remains a pure composition shell.
* style(studio): fix formatting in 5 files
* fix(studio): trim App.tsx to 598 lines (under 600 limit)
---------
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.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
## Problem
Studio manual drag had two bad target paths in `hf-keyframes-test`:
- Drag could start from a cached hover selection instead of the selected overlay, so moving `#cta` could write an offset to the wrong element while the overlay appeared correct.
- `#stage` is the visual 1920x1080 canvas, but the composition metadata lives on `<html data-composition-id ...>`. Studio treated `#stage` as a normal movable layer, so dragging it moved the coordinate basis the overlay depends on and left Undo with no reliable saved edit to reverse.
## Fix
- Manual drag now starts only from the selected overlay bounds; canvas pointer-down no longer starts movement from `hoverSelectionRef`.
- Added a structural root-layer heuristic: a direct body child matching the composition root's declared `data-width` / `data-height` is selectable and style-editable, but cannot receive manual offset/size/rotation edits.
- Manual geometry commits now return/rethrow the save promise so failed writes roll back optimistic DOM changes instead of silently sticking.
- GSAP-targeted CSS fallback edits are blocked before applying manual geometry when the GSAP drag intercept is unavailable.
## Tests
- New regression coverage for stale-hover drag start and full-canvas root-stage capability resolution.
- Focused DOM-edit suite: 119/119 pass.
- Full Studio suite: 800 pass, 18 todo.
- Studio typecheck clean; oxlint/oxfmt clean; lefthook pre-commit clean.
- Studio build passes with the existing Vite chunk-size warning.
- Browser verified with `agent-browser`: attempted `#stage` drag writes no offset and keeps Undo disabled; `#cta` drag writes exactly one offset to `#cta`; Undo returns the fixture to zero offsets.
Local browser artifacts: `artifacts/studio-cta-drag/stage-cta-drag-fix.webm`, `artifacts/studio-cta-drag/stage-cta-drag-fix-final.png`.
The motion panel was behind a feature flag defaulting to false and never
shipped. Remove the tab button, MotionPanel component, feature flag,
and all associated wiring (EaseCurveEditor, SpringEaseEditor,
MotionPanelFields, MotionPathOverlay). The underlying motion data
infrastructure (studioMotion, studioMotionOps) remains intact for the
GSAP panel.
Co-authored-by: Miguel Ángel <miguelangelsisi098@gmail.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>
* 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.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* fix(studio): property panel group-aware keyframe routing
Add animIdForProp helper routing keyframe diamonds to correct property-group
animation. Wire StudioPreviewArea delete/move/toggle handlers to use
propertyGroup for routing. Fix per-property epsilon in rdpSimplify.
* fix(studio): gesture recording replaces existing position keyframes
Gesture recording uses replace-with-keyframes mutation to replace existing
position-group tween. Fix N1 sign inversion and N9 wheel startPointer
with pointerElementOffset subtraction.
* 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.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* fix(studio): property panel group-aware keyframe routing
Add animIdForProp helper routing keyframe diamonds to correct property-group
animation. Wire StudioPreviewArea delete/move/toggle handlers to use
propertyGroup for routing. Fix per-property epsilon in rdpSimplify.
* 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.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* 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.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* 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.
* 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): watch external project dirs so preview ETag invalidates
Project dirs are symlinked into data/projects from anywhere on disk, but the
preview signature cache was only invalidated by Vite's watcher, whose roots
don't cover external paths. Edits hit disk while the cached ETag kept
serving 304s — the browser showed a stale preview after refresh and edits
looked lost. Register each project dir with the watcher when its signature
is first cached.
Co-Authored-By: Claude Fable 5 <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
* 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>
Add VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT env var (default: false) to
gate the GSAP drag/resize/rotation intercept in useDomEditSession. When
off, dragging GSAP elements falls through to the standard CSS path
instead of committing via script mutation.
Manual dragging (STUDIO_PREVIEW_MANUAL_EDITING_ENABLED) remains on.
Wire the razor tool into Studio's timeline UI:
- B enters razor mode (crosshair cursor + red vertical guide line)
- Click any clip to split at the click position
- Shift+click splits all clips across every track at that time
- V or Escape exits razor mode
- Toolbar shows selection arrow / scissors toggle
Add useRazorSplit hook for split orchestration (HTML + GSAP mutation).
Add activeTool state to playerStore. Add preview reload after timeline
move/resize operations so the composition re-renders with updated timing.
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
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
stripStudioEdits flag on the delete mutation type so they only fire on
user-initiated deletes, not on internal delete-then-recreate drags.
- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
elements with CSS opacity:0 stay visible after collapsing keyframes.
- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
precision for visual properties (opacity, scale, rotation) instead of
Math.round which corrupted mid-fade values to 0.
- Guard VISUAL_BASELINE against cross-tween contamination by querying
__timelines for properties animated by other tweens on the same element.
- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
containing opacity, guard against relative values (+=/-=/*=), and add
Number.isFinite check.
- Fix falsy-zero doubling in drag commit: replace || fallback with
Number.isFinite so a base GSAP position of 0 is correctly preserved.
- Fix gesture recording sign inversion: remove pointerElementOffset
subtraction from dx/dy formula and instead apply it once to basePosition
so the element center tracks the pointer.
- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).
- Strip all diagnostic logs from production code.
* docs(readme): swap hero media to hyperframes-logo-motion
Replaces the prior hfgif-1280.webp hero with a new logo-motion clip
Bin trimmed for the launch. Converted the source MP4 to animated webp
(the existing hero's format) so it auto-plays in the GitHub README the
same way the old one did - MP4 sources don't render inline or autoplay
in <img> tags.
- New asset: static.heygen.ai/hyperframes-oss/docs/images/
hyperframes-logo-motion-1280.webp (1280x720, 85 frames, 199KB)
- ffmpeg conversion: scale=1280, libwebp_anim, q=80, loop=0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(studio): format 5 hooks files (oxfmt)
* style: remove unused imports in studio hooks (pre-existing lint failures)
CI Lint on main was already failing with 5 unused-import errors in
packages/studio/src/hooks/. Removed the unused symbols to unblock the
README hero PR's CI:
- gsapRuntimeBridge.ts: resolveTweenStart, resolveTweenDuration
- useGsapScriptCommits.ts: usePlayerStore
- useTimelineEditing.ts: PatchTarget (type-only)
- gsapDragCommit.ts: readGsapProperty
Bundled into the README PR per James's request to fix CI in-place.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(studio): add childRects: [] to DomEditOverlay test mock
useDomEditOverlayRects' return type added a childRects: OverlayRect[]
field; the DomEditOverlay test's mock didn't get updated and was
returning an object without it, so DomEditOverlay.tsx's
'childRects.length > 0' check threw TypeError on undefined.
One-line mock-vs-hook contract realignment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(studio): drive player-store currentTime in selection-hydration test (#1311 follow-up)
The 'hydrates seek first, preserves the initial url state, then restores
selection' test was failing because PR #1311 (keyframes feat) changed
useStudioUrlState to read currentTime from the player store via
usePlayerStore((s) => s.currentTime), removing it from the hook's prop
shape. The test was still trying to drive currentTime via the harness
prop, which is now a no-op — so the selection-hydration useEffect's
time-stability guard
Math.abs(currentTime - stableTimeRef.current!) > 0.05
never passed (store currentTime stayed at 0 while stableTimeRef caught
the 4.2 seek target). buildDomSelectionFromTarget was never reached,
applyDomSelection was never called, and the assertion got 0 calls.
Fix: setState the store's currentTime to 4.2 ahead of the rerender so
the hook's selector picks it up and the time-stability guard passes.
Harness prop kept as-is — it's a no-op but doesn't hurt.
Pre-existing failure on main HEAD 81416ab3; surfaced as CI gate on the
unrelated docs/readme-hero-motion-update PR.
* test(studio): stub getBoundingClientRect + flush RAF in DomEditOverlay test
The 'renders selected bounds right after clicking a movable selection'
test asserts the selection box appears after pointerdown, but happy-dom
returns 0 for newly-created elements' getBoundingClientRect. The
overlay's compRect updates via a RAF loop that early-returns when iframe
width is 0; the keyframes PR a468550f added a compRect.width > 0 guard
to the selection-box render path, so compRect=0 silently gates the box
off and the assertion fails.
Stub Element.prototype.getBoundingClientRect to return 800x450 for the
test, and flush two RAFs after render so the compRect state update lands
before the pointerdown assertion. Restore the prototype at test end.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Miguel Sierra <miguel.sierra@heygen.com>
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b)
* refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review)
- Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined`
normalisation; guards against empty-string data-hf-id reaching findTagByTarget
- Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site
that still used `?? undefined` instead of `|| undefined`)
- Re-export readHfId through domEditing.ts public API
- Add readHfId unit tests: present, absent, empty-string, whitespace-only
- Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only
- Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports
brought into fallow scope by the T5b hfId changes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review)
cloneNode(true) copies all attributes including data-hf-id. Without clearing it,
both halves of a split share the same hf-id; the server's findByHfId picks the first
match and silently patches the wrong clip. Remove the attribute from the clone so
write-back re-mints a fresh id on the next preview load.
Adds a test: splitElementInHtml — hfId clone isolation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review)
- Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item
construction in collectDomEditLayerItems compiles
- Widen getDomLayerPatchTarget return type to include hfId + populate it from
data-hf-id attribute (domEditingElement.ts)
- Widen findDomEditSelectionTarget to check hfId-first when no id/selector
- Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts
- Add hfId to buildMissingCompositionElements element construction
- Add hfId-targeted test coverage in domEditing.test.ts,
domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts
- Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>