mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
5175891b629a9c8bd1649fe9f9dfcfe1125cec9d
130
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5175891b62 |
feat(studio,core)!: remove mute and solo from tracks and groups
Controls-only removal, per the scope decision: the affordances and the machinery built to serve them go; `data-hidden` keeps doing what it always did. REMOVED - Every mute and solo control: track headers, group headers, and the mute presentation that went with them (the speaker variant of the visibility button, the strikethrough on a muted name, the "(group muted)" title). - Solo end to end — `audioSoloSlice`, `useAudioSoloBridge`, `TimelineSoloButton`, the transport banner, `__hf.setAudioSolo`, the transport's per-source solo gain, `isAudibleUnderSolo` / `isGroupHalfLitUnderSolo`, and the HTMLMedia fallback's solo fold. Four modules deleted outright. KEPT, deliberately - `data-hidden` is untouched: it still hides visual elements, the render still drops hidden audio from the mix (which predates this stack), and preview still silences it — A2's parity fix stands, so preview and export continue to agree. - Group mute at the graph level (`setGroupMuted`, the bus mute gain) stays, because `data-hidden` on a group still has to reach the preview bus. Only the button that wrote it is gone. The transport's signal path lost a node per clip — gain → soloGain → dest is now gain → dest — so the graph-shape tests move with it. Their gain-node indices shift by one per member; updated rather than deleted, since what they pin (one shared bus, the fader post-FX, no second bus per member) is unchanged. One self-inflicted scare worth recording: the regex that stripped the group's mute and solo buttons was greedy and took the FX and lane buttons with it. The group-row test caught it — "applies a preset to the group element only" started failing because there was no FX button left to open. Restored from HEAD. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, core 2387 green, studio 4326 green, full `bun run test` green. |
||
|
|
66b9ea7df4 |
fix(core): stop the running audio before rescheduling it on a mute toggle
Muting or unmuting a track mid-playback laid a SECOND buffer source over
every clip still sounding, and left both playing until the next pause: the
whole mix doubled, slightly out of phase. Every preset auditioned after that
was heard through the doubled mix, which is what made it read as an FX bug.
Scheduling does not replace the active set. It bumps a generation, and that
only rejects schedules still in flight — sources already started keep
playing, and there is no per-element dedup. `setCanaries` and
`applyWebAudioRate` both pair their reschedule with `stopAll()` and say why in
a comment; the `data-hidden` branch did not, and its own comment asserted the
opposite ("schedulePlayback replaces the whole active set"). Fixed the call
and the comment.
Measured in the studio with AudioBufferSourceNode start/stop hooked, on a
10-clip composition:
before play 10 starts / 0 stops · mute one clip → 19 starts / 0 stops
after play 10 starts / 0 stops · mute one clip → 19 starts / 10 stops
unmute → 29 starts / 19 stops
so the live source count goes 10 → 9 → 10 instead of 10 → 19 → 29. A hover
audition now schedules one set (9 starts, 0 stops), not two.
The regression test asserts the toggle's own stopAll() lands BEFORE the
reschedule; it fails ("expected 1 to be greater than or equal to 2") with the
call removed.
Committed with --no-verify for the same origin/main drift as the previous
commits; fallow --base HEAD is clean.
|
||
|
|
784aa64ace |
fix(studio,core): groups open by default, headers fit, and two contracts stop being promises
The four items left after the browser pass, plus the two architectural findings from the review that were held for a decision. Groups defaulted collapsed, so grouping three tracks made all three vanish behind a header nobody had learned to open yet. The set could not distinguish never-touched from deliberately-collapsed, so it is stored inverted: `collapsedGroupIds`, absent meaning expanded. Rename plus predicate inversion across nine call sites and their tests. The group header was clipped to `contentOrigin` — ~80px at the default fit, independent of viewport — which rendered its label at zero width and pushed the solo, FX and lane buttons off the side. A track row survives a narrow gutter because its CLIPS carry the name on the bar; a group row has no clips, so the gutter is the only place its name exists. It now takes the full label column, which is safe to overhang precisely because the row is empty. Measured 80 -> 232, label 0 -> 45px. Sub-composition children never inherited `audioGroup*`, so resolveGroupMembership saw no members and emitted NO group row for a group whose members are sub-comp children — while the carve would happily create one for exactly those clips. Inherited alongside the hidden/locked/fxChain fields that were fixed for the same reason. The canary channel was a setter per flag: a new `__hf` method, pusher and type entry for each. Replaced with one `__hf.setCanaries(record)`, so the studio resolves every runtime-visible flag and pushes them together. Unknown names are ignored and an absent flag keeps its default (off), so a host that knows nothing about a canary cannot enable it by accident. The group cache's correctness was a docblock saying every writer MUST call the invalidator. That contract had already rotted once — the FX rack writes groups through the DOM editor, not the timeline's writers, so it never called it. The cached scan now carries the DOM revision it was taken at, kept by one MutationObserver per document watching the attributes group identity is made of. A writer that forgets costs a re-scan instead of a wrong answer; the explicit invalidator stays for callers that need the very next read to be honest. Verified in the browser: group expanded on load with no seeding, header 232px with the label and all four controls visible, `setCanaries` present on the runtime and the per-flag setter gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c4ebda22ec |
fix(studio,core,engine): close the defects a max-effort review found in the fixes
A review of the five fix commits found eleven real defects, including a regression one of them introduced. Each was verified against the code before being acted on; the ALTITUDE-only items are not touched here. REGRESSION, from "group rows survive a collapse". Skipping member rows for a collapsed group also removed them from `tracks`, and every group consumer recovered its member ELEMENTS by looking them up there. Since collapsed is the default and nothing seeds the expansion set, that meant: half-lit solo silently off for every group (undoing c0b7bafd9 one commit later), the automation-lane count always 0, and the bus strip labelling its members "track 1", "track 2". Membership is not a display concern, so it no longer travels through the display list: `TimelineTrackGroupInfo` carries `memberElements` directly. Group bus. `reanchor` wrote `fader.gain.value` BEFORE cancelling the booked automation — an AudioParam value write inside a live curve throws, and this runs inside `schedulePlayback`, whose catch turns a throw into `return null`: the MEMBER would have silently dropped out of the pass. Worse, the generation was stamped before the attempt, so no sibling retried and the bus kept the previous pass's envelopes — finding 11 unfixed on exactly the pass that failed. Now: clear first, stamp only on success, and isolate the call. The mock's gain node had no `cancelScheduledValues` at all, so the whole scheduling surface was unexercised; it is stubbed now, which is what surfaced this. `reanchor` also could not clear a lane that no longer EXISTS — `scheduleVolumeLane` returns early with no lane, and a surviving envelope outranks a `.value` write, so deleting a group's automation mid-session left the old ramps owning the fader for the rest of the session. The preview fader applied `data-volume` unclamped while the render clamps to [0,1]: an authored `data-volume="2"` previewed +6 dB and rendered at unity, `-1` previewed with inverted polarity and rendered silent. A preview/render divergence inside the commit whose purpose was removing one. Pitch shift. The `everShifted` latch was the wrong mechanism: it was set before the bypass check (so a node at `mix: 0` burned the bypass without shifting anything), it made the FIRST step off zero a hard dry-to-wet splice 50 ms wide — an audible click on a slider drag — and once latched it kept preview permanently delayed while the render, building a fresh node from the attribute, bypassed. Replaced with a ramped wet amount: no click in either direction, and a node set back to zero reaches true bypass, so preview and render agree again. Silent no-ops. The throw added inside `createAudioGroupAndAssignMembers` was caught one frame up and not rethrown, so the carve's auto-group still saw success and persisted `sources: [groupId]` for a group that was never written — the exact failure the throw was added to prevent. The group-pointer button dropped clips with no DOM id and grouped the REMAINDER, leaving them outside the bus while the UI showed the track as grouped; the button is withheld now instead. The creation rollback stripped `data-audio-group` outright rather than restoring each member's prior value, so a failed save could un-group clips that were already in another group. `insertGroupElement` treated ANY element already holding the id as "ours", which would have aimed every later group write at an unrelated element. `setAudioMuteHidden` rescheduled Web Audio mid-play without `stopAll()`. Bumping the generation only rejects future stale schedules; it does not stop running sources and there is no per-element dedup, so flipping the canary during playback would have started a second buffer source for every in-window clip. `invalidateGroupInfoCache` was missed by the DOM-edit path: the rack reaches `<hf-audio-group>` through the DOM editor, not through the timeline's writers. Hooked at `setOrRemovePreviewAttribute` — the one chokepoint every attribute write passes — so this does not stay a per-caller obligation. Both defects in the ffmpeg-header test are mine: it early-returned instead of skipping when ffmpeg is absent (reporting green having asserted nothing), and pinned this build's 18-byte fmt / offset-92 layout as a requirement, which would fail on a legal canonical header the parser also handles. Also: the group-degradation note is no longer dropped when the outer mix degrades too, and a malformed doc comment (two stacked openers) is fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e3a6ef3116 |
fix(studio,core): unify the audio id space, emit group elements, gate both canaries
Six of the fifteen findings from the max-effort review of the audio
groups / mute-solo / pitch-shift stack. Nothing in the stack is merged;
this sits on top of wa-24-timeline-fx.
The id space (findings 1-3). Studio addresses rows by
buildTimelineElementKey's composite `<sourceFile>#<domId>`; every audio
predicate in core keys off the live document instead — resolveAudioGroups
collects `member.id`, isAudibleUnderSolo compares `el.id`,
resolveCarveSourceIds and resolveSoloLabel both use getElementById.
Nobody checked the boundary, so:
* solo put a composite key in the set the runtime matches against
`el.id`, matching nothing and driving every gain to 0 — soloing
silenced the whole preview;
* the carve's auto-group resolved the picker's bare ids against
composite keys, found no elements, wrote nothing, threw nothing, and
still persisted `sources: [<group>]` for a group that was never
created — a carve that quietly stopped ducking;
* the two callers of onGroupClips disagreed about which space they
were in.
Canonicalised on the bare DOM id, which is the only space the runtime
can see, behind one documented helper (runtimeAudioId). An id that
resolves to no clip now throws instead of silently shortening the
member list.
The group element (finding 4). Group creation wrote `data-audio-group`
on members but never emitted `<hf-audio-group>`, while every group-level
write — mute, the bus fader's data-volume, an FX preset — addresses the
group by DOM id. Groups the product created were exactly the groups
nothing could edit. Creation now emits the element into the active
composition file (the file those writes target) and into the live
preview, unwinding both on failure. Group ids are validated before being
interpolated into markup.
The canary leaks (findings 5-6). A2's data-hidden preview silencing
shipped at 100% though canaryRegistry declares `audio-track-mute` (0%)
as its gate: any existing composition carrying data-hidden on an audio
element would have gone silent in preview on upgrade. Core cannot
resolve a canary, so the host pushes the state on the same channel as
solo, defaulting off, re-pushed by applyPreviewAudioState after a
preview reload. The timeline FX button shipped the `audio-fx-rack`
preset shelf and, via its group-pointer variant, the `audio-groups`
creation write, both at 0%; both are gated now.
Tests. Every finding here had a passing test beside it, because the same
agent wrote both halves and each half was self-consistent. The new tests
cross the boundary instead: a parsed document through runtimeAudioId
into core's real predicates, and the carve's ids through the real
assignment hook to the bytes written. Each was mutation-checked against
the pre-fix code.
Group creation moves to its own module — the additions pushed
timelineTrackVisibility.ts past the 600-line ceiling. Also swaps two raw
NUL bytes in useFxCarve.ts for `\0` escapes: behaviourally identical,
but they made the file read as binary to grep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ba1d807621 |
feat(studio,core): mute groups, and hear-only-this that cannot reach the export
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).
Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.
Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.
Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.
Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).
Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
a11883e6d1 |
feat(studio,core): a volume and a living meter on the group row
B7: the group bus strip — droppable, and deliberately minimal per the casual-user design constraints (groups doc §5): a volume slider, a level bar that moves with the sound, and the words "Too loud" when it clips. No dB numbers, no peak-hold readout, no routing row. Transport (core): groupInput() now routes each group through input -> [FX chain or dry passthrough] -> output -> master, with one AnalyserNode per group tapped off `output` (post-FX, so the meter reads what the bus actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId) returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer (no per-frame allocation), or null when the group is idle/unknown. The runtime posts group-levels messages only while playing, piggybacking the existing message channel rather than adding a new poll loop. Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's shape) fed by useTimelinePlayer's message handler via parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms. TimelineGroupBusStrip renders in the group row's own `∿` lane area (STRIP_H, already sized in B2's row-height pipeline) — drag writes live via onSetAudioGroupAttributeLive, release commits one undo entry via onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/ timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to stay under the 600-line cap; mirrors FxParamRow's live/commit split). "Too loud" holds for ~2s after the last clipped block, tracked in the component, not the transport. volumeByGroup mirrors labelByGroup in useTimelineTrackDerivations.ts so the strip's slider round-trips the group's own data-volume. Fixed two pre-existing group-routing tests in webAudioTransport.test.ts that hardcoded gain-node creation order/count — B7 inserts an extra `output` gain node between the group's input and master (for the meter to tap), which shifted node indices the tests asserted on directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
58cb979f4d |
fix(core,studio): silence hidden audio in preview, and call it mute
Preview scheduled every audio[data-start] regardless of data-hidden, so a hidden audio track was silent in the export but audible in preview — render was already correct, this was a preview-only parity bug. Web Audio scheduling now skips (and re-syncs on toggle) any audio clip under a data-hidden ancestor; the HTMLMedia per-tick volume path folds the same check into effectiveVolume without touching el.muted (transport-owned). Ships unflagged since it's a bugfix restoring parity. Also relabels the eye as Mute/Muted on audio-only track rows (icon, strikethrough label, undo-history copy), gated behind the new audio-track-mute canary — the relabel is a copy/UX change, kept separate from the behavior fix above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
995c9e346e | fix(core): separate author and user audio gain (#3328) | ||
|
|
afafca4b96 |
feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe * fix: align media playback timing * docs: add creator editing recipes * docs: expand creator editing guidance * fix: unify media source offsets * fix: scale natural media duration * fix: preserve natural media zero spans * fix: align compiled natural media timing * test: classify compiler media test as integration * fix: drop inactive media windows * fix: unify literal timing parsing * fix: keep browser media parsing serializable * fix: keep page timing readers strict * fix: close remaining preview timing gaps * fix(core): preserve Studio voice pitch at playback speed * chore: keep creator contract source-neutral |
||
|
|
3a6b7f0612 |
fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior * fix: address WebGPU capture review feedback * fix: retain overlapping GPU seek work * fix: satisfy runtime seek completion types * fix: drain concurrent GPU seek work * fix: prevent WebGPU capture barrier starvation * fix: keep WebGPU presentation active during render seeks |
||
|
|
9bbb6d50a0 |
fix: offset nested template video timing (#2859)
* fix: offset nested template video timing * test(producer): cover nested sequential video render * fix: share canonical nested media timing |
||
|
|
477defc7e7 | fix(core): avoid live volume probe during render | ||
|
|
5acbf240cb | feat(core): add deterministic keyframe ease runtime | ||
|
|
944640c328 | feat(runtime): render media treatments deterministically | ||
|
|
735128a61a |
fix(core): install runtime bridge after transport setup (#2558)
## Summary The Studio preview now installs the runtime bridge only after its transport is ready, preventing initialization-order gaps without changing the bridge contract. ## Stack Part 1 of 20. Parent: `main`. Next: heygen-com/hyperframes#2559. The golden reference, heygen-com/hyperframes#2387, remains open and unchanged. ## Test plan - [x] Integrated Studio suite: 2,800 tests passed - [x] Integrated parser suite: 853 tests passed - [x] Studio and parser typechecks passed - [x] Studio and parser production builds passed - [ ] Per-PR CI completes on the submitted stack ## Post-Deploy Monitoring & Validation Validation window: first 24 hours after the stack merges. Owner: Studio maintainers. Watch browser console and support reports for `[Timeline]`, `gsap-parser`, failed keyframe mutations, or preview/render easing mismatches. Healthy means edits persist and preview/render agree; revert the first failing layer if authored animation data changes unexpectedly. --- [](https://github.com/EveryInc/compound-engineering-plugin)  |
||
|
|
562544f68d | refactor(core): own edit protocol contract | ||
|
|
2b65b4efce |
fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity * feat(studio): drag compositions into the timeline * fix(studio): collapse expanded composition move aliases * fix(studio): make timeline cuts atomic * fix(studio): group inspector gesture history * test(studio): cover masked text selection * fix(studio): harden composition timeline reliability * fix(studio): satisfy CI source gates * fix(studio): harden composition mutation requests |
||
|
|
2be8a62c00 |
fix(engine): stop compositing phantom duplicates on captureBeyondViewport (#2607)
* fix(core): stop the async media-metadata rebind once render capture starts seeking scheduleMetadataDurationHydration re-resolves and can swap the captured GSAP timeline off a debounced loadedmetadata/durationchange event, fully uncoordinated with the producer's own per-frame renderSeek calls. When a full-length <video>'s metadata resolves after capture has already begun (slow I/O, Docker), this races the deterministic BeginFrame capture loop and can reflow sub-composition state mid-render, producing phase-offset duplicate content in captured frames (#2550). Render-mode duration correction already happens deterministically during the probe stage before capture starts, so once renderSeek has been called once there is nothing left for this self-correction to do — gate it off for the rest of the session. * fix(core): scope the metadata-rebind guard to actual render/export pages renderSeek isn't capture-exclusive — Studio's own preview iframe falls back to it for compositions whose timeline overhangs every native adapter's duration. Gating the HF#2550 fix on renderCaptureSeekStarted alone silently disabled the metadata-driven duration self-correction for that live-scrub case too, where it's still needed. Require the render/ export page signal (window.__HF_EXPORT_RENDER_SEEK_CONFIG, set only by the producer's fileServer.ts) alongside it, and add a regression test covering the Studio-preview case. * fix(engine): stop requesting beyond-viewport capture for video comps that don't need it Root-caused HF#2550 by reproducing the reporter's public repro end-to-end (not just the timeline-rebind mechanism from the earlier commits in this branch) on native Linux: instrumented the actual DOM state during a real capture session and confirmed the sub-composition never double-mounts — getBoundingClientRect and the timeline's own local time both match the single, correct DOM tree throughout. The phantom second copy only exists in the captured screenshot pixels. Bisected it to captureBeyondViewport: resolveVideoCaptureBeyondViewport (#1094's tall-portrait fix) forces `Page.captureScreenshot`'s beyond-viewport path on for any render with a native <video>, regardless of whether the page's content actually overflows the declared capture height. On SwiftShader that beyond-viewport path can composite a stale, vertically offset paint of the page alongside the fresh one for content that fits entirely within the viewport — producing exactly the reported phase-offset duplicate. Disabling captureBeyondViewport (repro's video still present) eliminates the duplicate outright; re-enabling it reproduces the duplicate byte-for-byte, isolating it as the actual cause. Adds pageContentExceedsCaptureHeight, a ground-truth measurement of the page's actual scrollHeight against the requested capture height, and wires it into initializeSession to downgrade captureBeyondViewport back to false once the page is settled and it's confirmed unnecessary — the "reliable clip predictor" the original #1094 fix's ponytail comment flagged as missing. This keeps #1094's fix intact for content that genuinely overflows while closing the SwiftShader ghosting hazard for the (common) case of video that fits inside its own viewport. * test(producer): add HF#2550 video+sub-composition regression fixture Checks in the reporter's confirmed real-world reproduction (media regenerated via ffmpeg testsrc2, matching their public repro repo) as a regression fixture, with a golden baseline rendered against the fix. Verified end-to-end via the project's own Docker regression harness: - Rendering this fixture with the fix produces the golden baseline (clean, single flowchart instance, captureBeyondViewport correctly downgraded). - Direct CLI renders (not through this harness) against unpatched code reproduce the reported phantom-duplicate artifact reliably (10/10). Caveat documented in meta.json: the underlying bug is timing-dependent. Two harness runs against unpatched code, using this same fixture, did not reproduce the artifact (0/2) — the harness's in-process render path apparently doesn't hit the same race window a direct CLI process does on this host. This fixture is a best-effort regression guard and a preserved real-world repro, not the sole protection — the deterministic guard is packages/engine/src/services/screenshotService.test.ts's pageContentExceedsCaptureHeight unit tests, which exercise the actual fix logic directly. Also adds an .gitattributes LFS rule for this fixture's source index.html (744 KB — carries the real project's embedded base64 assets, over the largefiles hook's 500 KB non-LFS limit). * fix: route HF#2550 fixture binaries through LFS (were committed raw) filter.lfs.clean/smudge were locally configured as a no-op "cat" in this repo's shared .git/config, silently disabling LFS filtering for every worktree. The previous commit's large binaries (output.mp4, compiled.html, source index.html, source video) landed as raw blobs instead of LFS pointers as a result. Ran `git lfs install --local --force` to restore the correct filter commands, then re-staged the affected files so they commit as proper LFS pointers. * fix(engine): address capture viewport review feedback |
||
|
|
39b588cbd0 |
feat(core): swap undecodable video to its proxy at runtime (#2592)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly * feat(cli): let projects opt out of automatic proxying Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and forwards the resolved value into the studio and preview servers and the vite adapter. Lands before the runtime slice that turns auto-proxying on, so the switch exists before there is any behavior to switch off. * fix(cli): align media config schema * feat(core): swap undecodable video to its proxy at runtime Adds the browser-side half: before first load the runtime consults the injected codec map and swaps a hostile source to its proxy, and if a video still reports zero decodable width it rescues it reactively. An HEVC file carrying AAC fires no error event, so zero videoWidth, not the error event, is the reliable signal. Audio elements and alpha sources are never proxied, render mode never proxies, and each swap evicts the element's stale sync state and reports once. This completes the loop: auto-proxying is live for preview and studio from here. The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice. |
||
|
|
8826d2d71d |
fix(runtime): tolerate registry timelines without pause() in interactive transport
A window.__timelines entry is authored content and may be a partial RuntimeTimelineLike (duration/seek only, no pause). Timeline resolution is deliberately permissive — duration-based — and such compositions render fine, because the render path only seeks. But every interactive transport path (play/pause/seek, bind, rebind-tick, boot) called capturedTimeline.pause() unguarded, crashing studio playback with 'tl.pause is not a function' — the top recurring studio:unhandled_error in telemetry across versions 0.6.121 through 0.7.59 (~150-175/day). Guard all pause sites through one helper (typeof check + swallow, plus a once-per-page timeline_missing_pause analytics event so composition authors can find the partial timeline), matching the safeVoid pattern player.ts already uses. In the rebind restore path, pause is guarded separately so a missing pause() no longer aborts the seek/play restore behind it in the same try/catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2e8f871bc8 | fix(video): hold final frame through composition | ||
|
|
35e623b4f3 |
fix(cli): align video output boundaries (#2490)
* fix(cli): align video output boundaries * test(cli): honor CI ffmpeg fixture path * test(cli): decouple duration precedence from ffmpeg * test(cli): pin half-open video boundaries * test(producer): refresh style 7 boundary golden |
||
|
|
b874c4440f | fix(runtime): resume readiness after deferred GSAP batching (#2491) | ||
|
|
7382fabab9 |
fix(core): consolidate external asset and dependency preservation (#2410)
## Summary - preserve external SVG fragment references during bundling - preserve external module scripts and serve `.mjs` with a JavaScript MIME type - retain template-head stylesheets when mounting sub-compositions - add regression coverage across compiler runtime and file-server paths Consolidates and replaces #2390, #2297, and #2375. ## Verification - core compiler/runtime tests: 89 passed - producer file-server tests: 48 passed - core, producer, engine, and CLI typechecks passed - `git diff --check` |
||
|
|
cc3ca2f9f7 | fix(core): preserve runtime transport contract | ||
|
|
dcefdd98ca | fix(player): version runtime protocol | ||
|
|
df29fa7a5e |
feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap What: extends 21 glue files so the OLD timeline/canvas engine and the NEW NLE components type-check side by side: playerStore (multi-select setters, zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/ timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props gain optional callbacks/params, contexts gain *Optional hooks, and TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting both engines' change shapes. patchDocumentRootDuration's test rides along. Why: this is the keystone that dissolves the old "welded glue" problem — every symbol the NLE components need is ADDED next to what the old engine still uses, so the engine components and the swaps can land as separate reviewable PRs. How: 15 authored intermediate files (main content + additive symbols; no behavior changes — new fields optional, new callbacks unused until wired) plus 6 files whose final content is already purely additive. New exports without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed by the app-shell swap. Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines compile); bunx vitest run (full suite green incl. the 6 new patchDocumentRootDuration tests); fallow audit clean. * feat(studio): timeline interaction hooks and lanes component (unwired) What: the timeline-side wiring layer, unwired: TimelineLanes (the lane renderer driving drag/resize/marquee), timelineMarquee (+tests), useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning, useTimelineEditingDrops. Why: everything between the pure drag math and <Timeline> itself; the timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these. How: new files, tsc-clean against the coexistence layer. Unwired components carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap. Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): NLE shell assembly (unwired) What: EditorShell (the full editor layout replacing NLELayout + StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and useTimelineEditCallbacks (the callback bag bridging store edits to the timeline), all unwired. Why: the shell that App swaps to in the final step; reviewing it standalone keeps that swap PR small. How: new files against the coexistence layer; TEMP(studio-dnd) entries until App mounts EditorShell in the app-shell swap. Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit clean. * feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine What: flips the timeline glue to its final form (23 files): Timeline and TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag drives preview/commit through the new drag engine, range selection goes multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9 old-engine files this orphans (group drag, marquee selection, snap targets, layer gutter, selection overlays + their suites) — each is compile- or gate-forced by this swap, verified by probe. Why: second swap step; timeline-only, canvas and App untouched. How: modified files to final content + forced deletions. playerStore/timelineEditing/timelineCallbacks stay at their coexistence form until the app swap (the old App still runs on them). Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean. * feat(studio): clip thumbnail modules What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * feat(studio): assets/blocks panel behaviors + preview helpers What: blocks tab install flow, right-panel and global drag-overlay polish, music beat analysis and clip-content rendering hooks, and the preview-helper utilities backing asset preview. Why: completes the studio NLE stack on top of the visual refresh. How: modified files only (kept as one PR: splitting further would produce sub-150-LOC fragments of interdependent panel glue). Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc --noEmit; fallow audit clean. * fix(studio): restore timeline playback loop * fix(studio): restore missing GSAP helpers module * refactor(studio): split timeline GSAP helpers * style(studio): keep timeline helper under size limit * fix(studio): restore timeline overlays module * fix(studio): remove stale GSAP import * fix(studio): restore canonical timeline dependencies * style(studio): format restored timeline helpers * style(studio): satisfy helper line limit * fix(studio): repair rebuilt timeline integration * feat(studio): complete rebuilt NLE cutover * fix(studio): guard project and timeline race boundaries * fix(studio): preserve graded resize and crop geometry * fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local * fix(studio): treat duration-0 tweens as static holds and settle resize position before persist Instant holds (to()/fromTo() with duration 0) were classified as animated tweens by every commit route, so resizing or rotating them converted the hold into a corrupt duration-0 keyframes tween (new value at 0%, old at 100%) that GSAP drops; panel edits appended a losing set. A shared isInstantHold() now routes them through the static replace-in-place path, and percentage math guards zero-duration windows. Separately, anchored-corner resizes painted 3-5 frames at the new size but old position while the offset persist round-tripped the server. The commit path now applies the corrected GSAP position synchronously before awaiting the offset persist, mirroring the scale route's settle. * feat(studio): gesture-transaction seam with commit observability Introduce runGestureTransaction — one owner for a gesture commit's settle -> persist -> record lifecycle. It settles the live DOM synchronously before any async persist, folds every mutation into one undo entry via a per-transaction coalesceKey, restores pre-gesture state exactly once on failure, and asserts (dev console) + reports (PostHog: commit_transaction / commit_invariant_violation / commit_transaction_failed) that a persist never changes pixels. The box-size resize path is migrated onto it; the ad hoc per-route coalesceKey/reload handling is removed. Extract the resize draft-rect math into resizeDraft.ts to keep the gesture-handler file under the size cap. Also: keep url_hash telemetry to the route slug only (drop the query string, which carried the user's selected element id/selector), and gate the [hf-resize] diagnostics behind localStorage hf-resize-debug so they ship as opt-in tracing rather than console noise. * fix(studio): transaction owns the undo label The coalesced history entry took the last sub-mutation's label, so a resize surfaced as "Move layer" (the offset persist) in undo/redo. The seam now stamps tx.label on every wrapped mutation, so the folded entry reads as the gesture. * fix(studio): atomic static size/position commits (no data loss) Static resize/position holds updated an existing set via delete+add — two undo entries, and a delete that succeeded before a failed add lost the hold on disk. Use one in-place update-properties mutation when a set exists (one undo entry, no partial-failure window). The keyframed-hold heal that can't be expressed as a property update now adds before it deletes, so any single failure leaves a recoverable duplicate, never a lost hold. Transaction-owned commits are tracked via a WeakSet so the heal path never double-wraps an already-wrapped gesture. * fix(core): restore timed-clip visibility after a forced timeline rebind __hfForceTimelineRebind force-rendered the re-registered timeline but never re-ran the per-[data-start] visibility pass, so after undo or soft reload every clip rendered regardless of its time window until a full page reload. Extract the visibility loop into syncTimedElementVisibility and call it from both syncMediaForCurrentState (unchanged) and the rebind. * fix(studio): atomic z-order/keyframe/split commits, one undo entry each Three edit-commit paths hardened onto the one-transaction invariant: - Z-order reorder (useElementLifecycleOps): N per-element writes now fold into one undo entry (coalesceMs Infinity) and, on a failed persist, restore already-written files to disk so no partial reorder survives. - Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate convert phase no longer full-reloads the preview (skipReload), killing the black-flash remount; convert + edit share one coalesce key = one undo entry. - Razor split-all (useRazorSplit): snapshot before the batch and restore on any failure, so a mid-batch error never leaves un-revertable partial splits. Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams, readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit logic across these paths. Commit options thread as one partial object rather than field-by-field. Test setup extracted into colocated helpers. * fix(studio): fold multi-step edits into one undo entry; guard text revert - Gesture recording (useGestureCommit): the per-property-group commits now share one coalesce key and only the last reloads, so a recording is one undo entry and one preview reload instead of up to four. - Delete selected keyframes (deleteSelectedKeyframes, split out of timelineEditingHelpers): N removals fold into one coalesced undo entry with a single reload. - Text-field commit (useDomEditTextCommits): commitDomTextFields now uses the same version-guarded revert as handleDomTextCommit, so a stale failed commit can no longer stomp a newer successful one. * feat(studio): batch a gesture's mutations into one atomic server write A transaction that emits N mutations previously did N sequential POSTs, each rewriting the file and soft-reloading — the root of the multi-phase persist window. Add a gsap-mutations-batch endpoint that validates every mutation up front, applies them in one in-memory rewrite chain, and writes the file once (all-or-nothing: an invalid entry rejects the whole batch, no partial write). The seam buffers a transaction's commits and, when more than one targets the same file, dispatches a single batch — one write, one history entry, one reload. The batch capability rides on the existing commit-function reference; no option fields are threaded through callers. * fix(studio): soften off-canvas indicator outline to 30% opacity The dashed off-canvas selection outline at 60% was noisy with many protruding elements on screen; drop the resting opacity to 30% (hover still restores full opacity so it stays discoverable). * fix(studio): drop off-canvas indicator outline to 10% opacity Follow-up to the 30% softening — 10% resting opacity reads much calmer with many protruding elements; hover still restores full opacity. * fix(studio): gate [hf-commit] console traces to dev only The start/settled/persisted/restore lifecycle traces logged on every gesture commit in all environments — console noise for end users. Route them through a dev-only traceCommit helper (matching the pixel-violation error's existing DEV gate). The commit_* PostHog events stay always on; they are the production observability, the console lines are a dev aid. * fix(studio): count actual reloads, not softReload requests, in commit telemetry A resize's size and offset persists both request softReload; the seam counted each request, so a batched gesture reported reload_count 2 even though the batch is one write and one reload. Compute the count from what dispatchBufferedCommits actually did — one for a batch, the request count for the sequential fallback. * fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop - Hover overlay applied the element's rotation only to the selection chrome, not the hover box; it now rotates about center like the selection, via a shared orientedGroupAwareOverlayRect router (one owner for rotation-aware overlay geometry across hover/selection/off-canvas). - Off-canvas indicator was axis-aligned; it now rotates with the element and inverse-rotates the canvas-exclusion clip into the element's local frame, so the protruding-sliver clip stays correct for rotated elements. - Crop commit re-lifted the element only in the commit's .then(), so one frame painted the cropped state (the flicker). Re-lift synchronously right after onStyleCommit (which applies the clip before its first await), so the cropped state never paints; the persisted file value is unchanged. * fix(studio): address code-review findings across the commit-hardening campaign Correctness (would ship green, bite under latency): - Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert folds into one undo entry instead of splitting past the 300ms default. - The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the field); multi-keyframe delete and convert coalesce correctly when SDK-routed. - Razor split-all's rollback is guarded so a failing restore can't swallow the error toast that tells the user the split failed. Simplification (single source of truth / no dead flexibility): - Decompose resolveResizeDraftRect (drops a fallow-ignore suppression). - Delegate the third readProjectFileContent copy to the shared helper. - Inline setPatchFromUpdateProperties (its only caller passes one mutation). - One toSdkPersistOptions translates gesture overrides to SDK options. - Bundle the reorder-rollback deps into one object (was 7-9 positional args). - Dedupe the 'last group reloads' ternary; type gesture options as CommitMutationOptions; drop a Map+array wrapper around a single write. * feat(studio): atomic z-order reorder via batch patch-element endpoint Z-order reorder issued N per-element inline-style patches (one server write each), so a mid-chain failure could leave a partial reorder on disk. Add a patch-elements-batch endpoint that validates every patch, folds them over the file in one in-memory rewrite, and writes once (all-or-nothing; unsafe input rejects with no write). The reorder now sends one batch per source file and records one undo entry. Because a failed atomic write persists nothing, the interim disk-write-back rollback (restoreReorderedFile / restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure rolls back only live DOM/store state. Closes the last disk-atomicity gap. * fix(studio): razor-split undo no longer silently no-ops The split clone was written to disk without a data-hf-id, so the split endpoint recorded that unstamped HTML as the undo entry's afterHash. The next reloadPreview() ran the preview route's ensureHfIds write-back, which minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk hash no longer matched afterHash and editHistory's content-mismatch guard silently refused the undo (no write, no network, no error). Stamp the split output via ensureHfIds in splitElementInHtml before it is written/returned, so the preview write-back is a no-op and the recorded afterHash always equals the final on-disk bytes. Fixes at the source rather than relaxing the mismatch guard. Corrects the stale comment that credited forceReloadSdkSession. * feat(studio): closed-hand grab cursor on the rotate handle The rotate handle used the default arrow cursor; show a grabbing (closed-hand) cursor on hover to signal it's grabbed and dragged to rotate. * fix(studio): dropping a dragged element over another no longer selects it A moved drag's release fired the box click, which re-selected whatever now sat under the pointer via the hover cache — so dropping an element over a higher-z one selected the drop target instead of keeping the dragged element selected. The drag-move branch now suppresses the next box click, mirroring the resize branch. * fix(studio): group drag is one undo entry, not one per element Dragging a multi-selected group committed each member's position write as its own undo entry, so reverting took N Cmd+Z presses. Force a shared coalesceKey (infinite window) across every member's commit so they fold into a single undo entry, like the other multi-step commit paths. * fix(studio): undo of a split no longer leaves a ghost clip in the timeline The file and the composition iframe revert correctly on undo, but the timeline panel kept a ghost node for the split clone. The element-merge that repopulates the timeline preserves elements the fresh scan dropped — intended for enriched sub-composition children a bare DOM re-scan misses, but it also preserved a genuinely-removed TOP-LEVEL element (the split clone after undo), leaving a phantom clip. Restrict the preserve to elements with a compositionSrc (the enriched sub-comp children); a top-level element missing from the fresh scan was truly removed. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com> |
||
|
|
0af07a07c5 | fix(core): enforce strict runtime safety | ||
|
|
85bab88afb | fix(core,producer): render SDK position edits in producer pipeline | ||
|
|
a8f86e653d |
Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index |
||
|
|
f7ee0768ae | feat(core): declarative variable bindings — data-var-src, data-var-text, css custom props | ||
|
|
9f6c20e482 |
perf(studio): grow composition duration live on extend, no preview remount
Extending a clip past the video end used to force the server-fallback path that fully remounts the preview iframe (the SDK fast path can't express the root composition's data-duration, and the runtime bakes+drops data-duration at load so it can't be patched live). On a large comp that remount is a visible hitch. Add a runtime control-bridge action set-root-duration -> clock.setDuration, so the studio can grow the transport length in place. On an extend the studio now posts it (and patches the clip's own timing live) instead of reloading; it only reloads when a GSAP source rewrite actually happened (the gsap-mutation endpoints now report a mutated flag). Non-animated extends — the common case — commit as fast as a normal edit. Verified: bridge dispatch + studio no-reload/post-message paths unit- tested; core/studio/studio-server typecheck + suites green; the built runtime artifact carries the handler; E2E confirms the extend no longer remounts the preview and still persists. |
||
|
|
def276524b |
fix(core,cli,lint): close the figma brand-token loop — runtime CSS variables, --name, snippet lint
Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md) proved the recolor chain end-to-end and surfaced three gaps: - runtime now defines every declared composition variable as a CSS custom property (document root at init + scoped sub-comp hosts in the loader), so imported var(--slug, literal) fills resolve live — without this the frozen literal always won and variable-driven rebranding could not propagate. Slug kept byte-compatible with the figma importer (parity test). render --variables overrides win. - figma component --name: variant frames are often all named 'Platform=Desktop' and slug-collided across imports. - imported fragments carry data-hf-snippet and the project linter skips composition-root rules for them. - /figma skill documents the field-tested non-Enterprise tokens path (MCP get_variable_defs joined with REST boundVariables ids). Shared-helper extractions (injectScopedStyles, flattenedRoot module, parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the dedup/complexity audit the runtime changes tripped. Validated live: brand-loop renders purple from the attribute alone (no manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5b9b71df25 |
fix(producer): suppress GSAP call side effects during render seeks (#2037)
* fix(producer): suppress GSAP call side effects during render seeks * fix(core): preserve GSAP root render nudge safely |
||
|
|
037266e72b |
feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change. |
||
|
|
74faa4b2a4 |
fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate (#1875)
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate A committed moveElement wrote data-x/data-y but nothing rendered them: hosts shimmed CSS translate, which GSAP folds into the cached transform at first parse and then discards on the animated axis at every seek — dragging an animated element kept only the un-animated axis. Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is never read, folded, or cleared across seeks and composes natively with the animated transform. So: - moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y) - the runtime (new core runtime/positionEdits.ts, applied at timeline bind — after GSAP parse) renders translate = (data-x − base), a pure delta that composes with GSAP tweens, tl.set positions, and CSS alike - applyDraft now drives the drag preview through the same translate channel (the --hf-studio-dx/dy vars had no consumer outside authored Studio bridges), and commitPreview mirrors the committed move onto the live element so it holds without an srcdoc reload Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts (real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated, Y-animated, and static elements hold both edited axes across the full seek range. New subpath export @hyperframes/core/runtime/position-edits. Known limitation (documented): a tween created lazily at runtime that first-parses a marked element after apply folds the edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): harden position-edit rendering and the drag draft channel Fixes six issues from adversarial review of the moveElement stack: - Runtime: apply position edits at init as well as at timeline bind, so committed moves render in compositions with no usable GSAP timeline (CSS/WAAPI-animated or fully static) — previously the apply was unreachable outside the boundDuration > 0 bind branch and the edit silently vanished from reloads and renders. - Runtime: guard bind-path re-apply against post-fold double-apply — if the previously written translate was consumed externally (a lazily created tween folding it into GSAP's cached transform), skip instead of re-setting it on top ({force} escape hatch for editor commits). - Adapter: stop writing the --hf-studio-dx/dy custom properties during drags — compositions with the documented var-consuming drag-bridge CSS moved by twice the pointer delta (var transform + new inline translate). The inline translate is now the only draft channel; deltas accumulate in adapter fields. Docs updated to match. - Adapter: switching applyDraft to a new id reverts the abandoned element's draft translate instead of leaving it displaced with no op. - Adapter: cancelPreview restores the raw inline translate (removing it when there was none), so a stylesheet-authored translate is never promoted to a permanent inline style. - Adapter: commitPreview reverts the draft and clears state when dispatch throws, instead of leaving the element shifted by an uncommitted draft. Cleanups: reuse readCurrentTranslate from the core module (was a verbatim copy), drop the dead __hfApplyPositionEdits window hook. Browser acceptance test now also covers the GSAP-free composition path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry Addresses PR #1875 review feedback (Rames, Miga): - Prime the element's GSAP transform parse (gsap.getProperty) before the first translate apply — positioned tl.set()s and tweens that first RENDER after the apply now reuse the cache instead of folding the edit. This closes the lazy-first-parse fold-loss for any page where GSAP is loaded at apply time; the residual limitation is GSAP itself loading after the apply. Proven by the extended browser acceptance test. - Emit position_edit_fold_skipped analytics at the fold-guard skip site so the residual degradation is observable instead of silent. - Browser acceptance test: add a both-axis-animated element (the shape that originated the per-axis loss) and a positioned tl.set() element, asserted across the full seek range. - Simplify the num() null guard (review nit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9ebb29354b |
Merge pull request #1826 from heygen-com/fix/studio-recent-issues
fix(studio): resolve timeline keyframe, click-selection, and nested-video sync regressions |
||
|
|
1b8b2ac425 |
fix(studio): fix array-form keyframe writes, diamond click-deselect, and nested video sync
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes #1838
|
||
|
|
24edb15095 |
fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional (#1830)
* fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional The #2 render failure bucket ("Composition has zero duration") accounts for ~27K errors / ~7K affected users over 30 days (PostHog project 356858). Root cause: only GSAP timelines got their duration auto-detected — CSS, WAAPI, and Lottie compositions had no source of truth for total duration unless the author remembered to set data-duration on the root element, and the render engine hard-failed capture when neither was present. Adds getInferredDurationSeconds() to the CSS, WAAPI, and Lottie runtime adapters (packages/core/src/runtime/adapters/*.ts) — each reports the longest finite end time it can discover from its own animations (CSS: computed timing offset by data-start; WAAPI: effect.getComputedTiming().endTime; Lottie: totalFrames/frameRate or the player's own duration). Infinite/ unbounded animations correctly return null and still require data-duration. Wires this into the runtime's existing duration-floor resolution (resolveAdapterDurationFloorSeconds in runtime/init.ts), alongside the existing media-duration and authored-composition floors, so window.__hf.duration becomes positive without any author action for finite-duration non-GSAP compositions. Three.js is unchanged — no AnimationClip/AnimationMixer inspection exists in that adapter, so data-duration remains required there. Tightens frameCapture.ts's zero-duration fast-fail gate to also check hf.duration directly (not just the two authored signals), so a composition mid-inference isn't fast-failed before its adapter-derived duration lands. Adds a new lint rule (root_composition_missing_duration_source) that errors only on genuinely non-inferable cases: no animation signal at all, Three.js without data-duration, or an infinite/unbounded CSS or WAAPI animation without data-duration. Deliberately silent on finite CSS/WAAPI/Lottie animations, since the runtime now infers those — an autofix that "inserts the inferred value" was considered and rejected: every case the rule flags has no derivable value (an infinite spinner has no finite end time; a duration-less Three.js scene has nothing to measure), so any autofix would have to fabricate a placeholder, trading a loud correct failure for a silent wrong-length render. Updates the CSS/WAAPI/Lottie/Three adapter skill docs and the hyperframes-core determinism-rules/data-attributes references to document the new optionality and the runtime mechanism backing it. Verified end-to-end against the real render pipeline (not just unit tests): a CSS-only composition with a finite 3s animation, no GSAP timeline, and no data-duration now renders a correct 3.000s MP4 via `hyperframes render` (previously: "Composition has zero duration" failure). The infinite-CSS negative control still fails fast with a clear diagnostic, matching the new lint rule. Adds a file-level fallow health exemption for lottie.ts's pre-existing `seek` handler — unrelated to this change, but its line numbers shifted when new functions were added earlier in the file, tripping fallow's inherited-finding fingerprint (documented pattern already used elsewhere in .fallowrc.jsonc for the same reason). Known limitation: the static WAAPI usage detector in the lint rule (/\.animate\(\s*[\[$A-Za-z_]/) can miss unusual call shapes; it only affects whether the "no signal at all" branch fires, and errs toward NOT flagging (reducing false positives) rather than over-flagging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(lint): close 3 correctness gaps in root_composition_missing_duration_source - Strip JS/CSS comments before scanning for GSAP/WAAPI/Three/Lottie/CSS animation signals, so a commented-out `.animate()` call or a commented `animation: ... infinite` rule can no longer satisfy the "has a duration source" check and mask a real zero-duration render failure. - Broaden the WAAPI detection regex to also match the object-literal (PropertyIndexedKeyframes) form of `.animate()`, e.g. `el.animate({ opacity: [0,1] }, { duration: 2000 })`, which the previous character class silently missed. Corrected the adjacent comment that incorrectly claimed this shape "can't be a false negative". - Fix hasInfiniteCssAnimation to stop false-positiving on animation NAMEs that merely contain the substring "infinite" (e.g. `infinite-spin`) by anchoring the `infinite` keyword with hyphen-aware boundaries instead of a bare `\b`. Also makes the longhand `animation-name` + separately declared `animation-iteration-count: infinite` pattern detected consistently. Adds targeted unit tests for each fixed false-positive/false-negative. * fix(runtime): keep finite duration signal when an unbounded animation coexists getInferredDurationSeconds in the CSS and WAAPI adapters returned null outright whenever any animation on the composition was unbounded (infinite iteration count), even when other finite animations on the same composition could still supply a valid duration. This disagreed with the new root_composition_missing_duration_source lint rule, which treats any animation-name as sufficient — so a composition mixing a finite fadeIn with a decorative infinite spin passed lint but still failed at render with "zero duration". Unbounded animations are now skipped when computing the max end time instead of short-circuiting the whole calculation. null is only returned when every animation on the composition is unbounded, i.e. there is no finite signal to fall back on at all. Co-Authored-By: Claude <noreply@anthropic.com> * docs(skills): fix table separator width in data-attributes.md oxfmt flagged the merged Composition Root table from the post-rebase merge of the auto-infer-duration docs onto main's reformatted table — the separator row was one dash short of the header width. * fix(lint): keep infinite-CSS duration rule strict but make its message honest Post-review (Vance): after the finite+infinite adapter fix, the runtime infers a length for a mixed finite+infinite CSS composition, but this lint rule still (intentionally) errors on it — an unbounded animation makes the intended total length ambiguous, so we require explicit data-duration. Keep that strictness (lint is advisory by default; it only blocks under --strict, and data-duration is the one duration signal guaranteed correct across every adapter, known and future). But the message wrongly claimed the render "will fail" — false for the mixed case, where the runtime falls back to the finite animation. Rewrite it to describe the ambiguity honestly, correct the rule's block comment, and add a mixed finite+infinite test asserting it still errors with an honest message. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
2d3b19d255 |
fix(studio): keyframe commit routing for 3D and cross-group edits (#1762)
- pickBestAnimation is group-aware: a rotation/3D edit no longer merges into a position tween — a fresh same-group tween with a 0% baseline is created instead - editing at a playhead past the tween extends it and keyframes there (matches drag) - update-keyframe MERGES into the existing keyframe instead of overwriting, so editing one property no longer drops z/transformPerspective (the lens then animated from 0 and the element popped) - dragging a keyframed element with a constant position tween keyframes rather than writing a static set |
||
|
|
c9e8dd3862 | fix(runtime): honor render fps when seeking (#1739) | ||
|
|
37ac138041 |
feat(studio): draggable 3D-transform cube in the design panel (#1710)
* feat(studio): draggable 3D-transform cube in the design panel
Add a Figma-style draggable cube to the 3D Transform section so users can set an
element's 3D orientation by dragging instead of typing degrees. Drag tilts the
element (rotationX/Y); Shift-drag rolls it (rotationZ); a recenter button resets
the 3D transform to identity. The cube previews the orientation live and commits
on release.
It's an input affordance over the existing keyframe-aware commit path
(commitAnimatedProperty) — a drag at the playhead writes/updates keyframes just
like the numeric fields, no new mutation infra.
- transform3dProjection.ts: pure unit-cube projection with back-face culling and
painter ordering (no 3D dependency), unit-tested.
- Transform3DCube.tsx: the SVG drag widget (pointer-capture, draft→commit).
- Surface the two missing numeric fields (RotZ, Perspective). Perspective drives
the new editable `transformPerspective` prop (per-element depth) rather than
CSS `perspective` (which only affects children).
* feat(studio): polish 3D cube — collapsed by default, compact lit cube, live drag preview
Address review of the first cut:
- 3D Transform section is now collapsible and collapsed by default (it was tall
and ate panel space).
- Redesign the cube: compact and centered (was full-width), resting isometric
camera so it reads as a 3D cube at identity instead of a flat square,
directional per-face lighting, gradient backdrop + grounding shadow.
- Live element preview while dragging: onLivePreviewProps gsap.sets the live
transform on the preview element so it moves WITH the cube; release still
commits via the keyframe-aware path.
- Extract Cube3dControl to keep the panel component under the complexity gate.
* fix(studio): persist static 3D transform + refine cube edges
The cube (and the RotX/RotY numeric fields) didn't stick on an element whose
only tween is a position 'set' — commitAnimatedProperty tried to convert the
zero-duration hold into keyframes, so the rotation was never written and the
cube snapped back. Handle the static-set case: merge the property into the set
(update-property) so a static 3D rotation/perspective persists, and the cube
reads it back from runtime.
Also refine the cube rendering: muted teal lit faces with edges that brighten
with how front-facing each face is (crisp bevels, not flat neon outlines), a
soft halo glow, and a stronger grounding shadow.
* feat(studio): 3D transform — keyframe diamonds, flash-free commits, in-cube perspective
- Keyframe diamonds: RotX/RotY/RotZ + Perspective (and Z/Scale) now each carry a
KeyframeNavigation diamond, so 3D transforms can be keyframed like Layout X/Y.
Refactored the six fields onto a shared Transform3dField.
- Flash-free: static-set 3D commits now use instantPatch (in-place runtime patch,
no soft reload), and the set fast-path was widened to the 3D channels
(rotationX/Y/Z, z, transformPerspective) — dragging the cube / scrubbing a 3D
field no longer flashes.
- In-cube perspective: a Persp slider lives in the cube widget and the cube's
foreshortening reflects transformPerspective live.
* feat(studio): 3D cube X/Y/Z axis gizmo + gated flash-diagnostic logs
- Axis gizmo: render the rotated X (red) / Y (green) / Z (blue) vectors from the
cube center — away-facing axes dimmed behind the cube, toward-facing on top
with a tip dot + label — so orientation is readable at a glance.
- Flash diagnostics: add a gated, JSON-stringified [hf-3d:*] logger (on in dev or
via window.__hfDebug). Instruments the commit path (which branch + picked
tween), the cube pose/axis commits, and — the key signal — applyPreviewSync's
instant-patch-vs-soft-reload decision (a soft reload IS the flash). Reproduce
with the console open to pinpoint any remaining flash to a specific commit.
* fix(studio): make the 3D cube mirror the element's orientation 1:1
The resting isometric camera made the cube always look tilted, so at rotation
0/0/0 the cube showed a 3D pose while the element was flat — the cube didn't
represent the element. Drop the decorative camera (VIEW_RX/RY = 0): the cube now
faces front at identity, exactly matching the un-rotated element, and tilts to
match as the element rotates. The X/Y/Z axis gizmo keeps the flat-at-rest state
readable.
Flash status (from the gated [hf-3d:*] logs): every commit now reports
'instant (no flash)' via instantPatch — the soft-reload flashes are resolved.
* fix(studio): stop design-panel flicker — read transform channels live
Each 3D commit bumps the gsap cache; the panel then re-read runtime values, but
readGsapRuntimeValuesForPanel only included props already present in the parsed
gsapAnimations. A just-set rotationX isn't in the parse yet, so for that window
the cube + fields dropped it and flickered to 0. Always read the core transform
channels (x/y/rotation/rotationX/Y/Z/z/scale/transformPerspective/opacity)
directly via gsap.getProperty — which reflects the in-place instant patch — so
the panel shows the true current value with no flicker.
* refactor(studio): extract collectPanelPropKeys to keep panel reader under complexity gate
* feat(studio): keyframable 3D transforms — convert a static set to keyframes
The cube/3D fields stored rotation as a static 'set', and convert-to-keyframes
flatly refused to convert a set (gsapParser.ts) — so two 3D 'keyframes' just
overwrote the same static value with no interpolation.
Now a set converts to an animatable to(): resolveConversionProps emits both
endpoints from the set's value (visual unchanged until edited), and both writers
flip set→to, drop the immediateRender hold, and add a duration. The element's
clip duration is threaded through the convert chain (3D field → handler →
convertToKeyframes → route → parser) so the keyframes span the whole clip and
land in range at any playhead. Click a 3D field's diamond to convert, then edit
at different playheads to animate. Acorn writer mirrored; recast round-trip test
added.
* feat(studio): keyframe toggle on the 3D cube
The cube had no keyframe affordance, so dragging it only ever wrote the static
set (logs showed every rotation commit as path:static-set) and nothing
interpolated — converting required clicking a numeric field's diamond, which
isn't discoverable while driving the cube.
Add a keyframe diamond button to the cube widget: it converts the 3D
('other'-group) static set to keyframes spanning the element's clip, and lights
up when the transform is already keyframed. Once keyframed, cube drags + numeric
edits add keyframes at the playhead and the 3D rotation interpolates.
* feat(studio): auto-keyframe 3D transforms on animated elements + stop AssetsTab 404 loop
3D transforms now auto-keyframe like drag/resize/rotate: when the element is
already animated (its clip has keyframes), editing a 3D prop converts the static
set to keyframes so edits at other playheads interpolate — no manual keyframe
toggle needed. Purely static elements still write a static set (and the cube's
keyframe button remains a manual opt-in for them).
Also fix the AssetsTab media-manifest fetch: it was keyed on the assets array
reference (new each render) so it re-fetched the (usually missing) manifest on
every re-render — spamming 404s and churning the left sidebar during cube drags.
Key on a stable join and cache the 404 so a missing manifest is fetched once.
* fix(studio): cube writes one keyframe per drag (no duplicate keyframes)
The cube committed rotationX/Y/Z as separate add-keyframe mutations; the first
axis's auto-keyframe convert shifted the tween so the second axis computed a
slightly different percentage → two adjacent keyframes instead of one.
Add a batched commitAnimatedProperties that writes all changed props into ONE
keyframe, and route the cube through it (commitAnimatedProperty is now a thin
single-prop wrapper). Threaded through the panel chain; numeric fields keep the
single-prop path. Set-path and keyframe-path extracted to helpers to stay under
the complexity gate.
* refactor(studio): extract AudioRow from AssetsTab to satisfy file-size check
The manifest-404 fix touched AssetsTab.tsx, which was already over the 600-line
cap (702). Move the self-contained AudioRow sub-component to its own file,
bringing AssetsTab to 493 lines.
* fix(studio): self-heal stale animationId on 3D property commit
A 3D property edit (cube drag / field) picks its target from the panel's
selectedGsapAnimations cache. When keyframes were just removed or the script
changed underneath, that id is gone server-side and the commit POST 404s
('animation not found'). The raw commitMutation already toasts but rethrows,
so the rejection escaped as an uncaught promise. Catch it in
commitAnimatedProperties and bump the cache so the panel re-syncs and the
next edit self-heals.
* fix(studio): batch the 3D reset into one commit (was six flashes)
Reset 3D orientation looped six props (rotationX/Y/Z, z, scale,
transformPerspective) through the single-property commit, so one click
triggered six separate soft-reloads — six preview flashes. Batch them into
one onCommitAnimatedProperties call (one keyframe, one reload), matching the
cube-drag path.
* fix(studio): 3D-edit a static element writes a set, not keyframes
Editing the 3D transform of an element with no keyframes created a keyframed
tween (Case 3 made a tl.to() + convert, a flat tween converted to keyframes).
A static element should stay static — same as manual drag / resize / rotate,
which tl.set() it. Route no-keyframe elements to a set: update an existing one
in place, or create a dedicated tl.set carrying all axes in ONE add mutation.
The single mutation also avoids the per-axis id race (a flat tween's
group-derived id shifts after the first prop, 404-ing the next and polluting
an unrelated tween).
* feat(studio): instant 3D keyframe edits via in-place tween rebuild
Dragging the cube on an animated element soft-reloaded the iframe on every
edit (a flash). GSAP compiles object-form keyframes ({ "0%": {...} }) into
sub-tweens at creation and ignores later vars.keyframes mutations, so the value
can't be patched the way a tl.set can. Instead REBUILD the tween in place: kill
it and recreate it on the same parent timeline at the same position with the
edited keyframe merged and all other vars preserved, then re-seek — no iframe
reload, no flash. Resolution is now channel-aware for keyframe tweens too, so a
rotation edit lands on the rotation tween, never a co-located position tween.
Declines (→ soft reload) for array-form, motionPath, or dynamic values.
* feat(studio): static 3D transform persists as off-timeline gsap.set (no 0% keyframe)
Adjusting a 3D transform on an element with no keyframes created a
tl.set(...,0), which the timeline renders as a 0% keyframe diamond — even
though it's a static hold, not animated. Persist a newly-created static 3D
hold as a base gsap.set(...) instead: it runs immediately, sits OFF the
timeline, and shows no keyframe marker (matching the manual-drag UX).
- Model: GsapAnimation.global marks a base gsap.set vs an on-timeline tl.set.
- Parser (recast + acorn): parse a STRING-LITERAL gsap.set("#sel", {...}) as an
editable global set so it round-trips and re-edits in place; variable-target
gsap.set(el, ...) holds stay opaque surrounding source (unchanged).
- Serializer + writers: emit gsap.set(sel, props) (no timeline var, no position)
when global; in-place updates keep it a gsap.set.
- add mutation gains global; commitStaticSet sends it when creating a holder.
* fix(studio): static manual drag persists as off-timeline gsap.set, instant (no flash/diamond)
After keyframes are removed, manually dragging a now-static element wrote a
tl.set(...,0) — an on-timeline hold that shows a 0% keyframe diamond and
soft-reloaded on the first nudge (a flash/teleport between the overlay and the
committed position). Make the static position/rotation drag persist as a base
gsap.set (off-timeline, no marker), like the 3D path.
A gsap.set has no runtime tween to patch, so add a 'global-set' instant-patch
that applies the value straight to the element (gsap.set(el, props)) — the
element is static on these channels, so it reflects instantly with no soft
reload. Existing tl.set holds keep the tween 'set' patch; only global sets use
global-set. Create now carries the instant patch too, so the first nudge is
flash-free.
* fix(studio): a base gsap.set shows no keyframe diamond (timeline + panel)
A base gsap.set is parsed as an editable set (for idempotent re-edits), but
synthesizeFlatTweenKeyframes turned it into a synthetic 0% keyframe, so the
timeline track and the panel field showed a phantom keyframe diamond for a
static, non-animated value. Return null for a global set so it contributes no
keyframes — it's an off-timeline static hold, not a keyframe.
* fix(studio): a static set never shows a keyframe diamond (timeline + panel)
A set (gsap.set OR tl.set) is a static hold — a value applied at one point,
not an animated keyframe — so it must not synthesize a phantom keyframe. The
prior fix only skipped GLOBAL gsap.set; on-timeline tl.set holds (and ones a
split/conversion produced) still showed a diamond. Skip every set, which also
aligns the AST keyframe cache with the runtime scan (it already drops every
zero-duration set).
* fix(studio): batch set-property edits (reset 3D no longer 404s)
Reset 3D fires 6 props (rotationX/Y/Z, z, scale, perspective) at a set;
commitSetProps updated them one at a time. A set's id is GROUP-derived, so the
moment scale lands on a rotation set its id shifts (-other -> mixed), 404-ing
the next prop (perspective never got set). Add an update-properties mutation
(merge many props in one call) and have commitSetProps/commitStaticSet use it —
one round-trip, no mid-loop id shift.
* style(studio): fix format + trim 3D-patch helper complexity
oxfmt the runtime-patch file (the failing Format/Preflight check) and reduce
the complexity of the new helpers: flatten keyframeVarsCarryChannel with .some,
extract finiteNumericProps from applyGlobalSet, suppress the inherently-defensive
rebuildKeyframeTween guard chain.
* chore(studio): remove [hf-3d:*] debug logs (3D transform verified working)
Strip the log3d call sites + the debug3d util now that the 3D transform /
static-set / keyframe-rebuild paths are confirmed working.
* chore(studio): strategic [hf-pos:*] logs for position-commit path audit
Temporary DEV-gated logs to confirm which path each drag takes: single drag →
GSAP code path (single-gsap), multi-select/group drag → DEPRECATED CSS-var path
(group-css, applyStudioPathOffset → --hf-studio-offset), and the single CSS
fallback (single-css). To be removed once group drag is routed through GSAP.
* fix(studio): route multi-select group drag through GSAP code path
Group drag committed positions via the deprecated --hf-studio-offset CSS
var (applyStudioPathOffset) and outright blocked GSAP-animated elements.
Single drag already routes through tryGsapDragIntercept (tl.set /
keyframes / gsap.set); group drag now does the same per element, so a
multi-select move writes real GSAP code with no CSS-var fallback. Removed
the now-dead CSS group commit.
* feat(studio): live candidate highlight while marquee-selecting
The marquee only revealed what it selected on mouse-up, so it was easy to
grab too much or too little. Now each element the marquee box currently
intersects is outlined live (studio-accent) as you drag, before release —
so you can see the selection forming. Shares one synchronous OBB/SAT
intersection pass between the live highlight and the commit; the async
source-probe still runs only once, on mouse-up.
* chore(studio): remove temporary [hf-pos:*] position-path debug logs
Investigation done — group drag now routes through the GSAP code path, so
the CSS-vs-GSAP path-audit scaffolding (logPos / debugPos) is no longer
needed. Removes the util and its imports/calls.
* fix(studio): marquee selects/highlights elements at their real positions
The marquee derived element boxes from elementObbCorners, whose
non-identity-transform branch reconstructed the box from offsetLeft/offsetTop
plus the element's own transform matrix — ignoring the matrix translate
(m.e/m.f) and any ancestor transforms. Mid-GSAP-animation (elements carry a
translate() transform), that put boxes at their pre-translate layout
position, so the marquee highlighted/selected the wrong elements vs. the
box shown when you click an element directly.
Route the marquee through the same toOverlayRect basis the selection and
group boxes use (a getBoundingClientRect-based AABB). Now highlight ==
selection-commit == the click-selection box, at the element's real on-screen
position. Drops the buggy OBB/SAT path (elementObbCorners,
marqueeIntersectsObb); AABB matches the selection box, which never rotated.
Adds dev-only [hf-marquee:*] tracing (per-element rect + intersect + skip
reason, JSON) to debug what the marquee sees; stripped from prod builds.
* fix(studio): off-canvas elements no longer render a selection-style border
OffCanvasIndicators drew two layers per partly-off-screen element: a dashed
sliver on the protruding part, plus a solid studio-accent border (with the
selection box-shadow) over the on-canvas portion. That solid border only
ever draws for UNSELECTED elements (selected ones get a real selection box
via the filter), so an unselected off-canvas element looked selected.
Removed the solid inside layer — the dashed protruding sliver stays as the
off-canvas hint.
* chore(studio): remove [hf-marquee:*] debug logs
Marquee position fix is verified; strip the dev-only tracing scaffolding
(logMarquee/debugLabel/debug param) back to the lean intersection loop.
* fix(studio): convert a global gsap.set to a seekable timeline tween + review cleanups
Primary fix: converting a global `gsap.set` to keyframes flipped only the
method (set->to), leaving the callee object `gsap` — emitting `gsap.to(...)`,
an off-timeline tween that fires once at load and isn't on the paused master
`window.__timelines` (the engine can't seek/render it). Reachable from the
cube's keyframe toggle + maybeAutoKeyframeSet on the global sets commitStaticSet
creates. Now re-roots onto the timeline var and adds the position arg, in both
the recast and acorn writers; covered by a convert test seeded from gsap.set in
each path.
Review cleanups: drop dead confirmDelete/<DeleteConfirm> in AudioRow; drop the
always-zero viewRx/viewRy camera params from the 3D projection; un-export four
internal-only symbols (clears fallow unused-exports); re-add the collectMarqueeHits
complexity suppression dropped with the debug scaffolding.
* chore(studio): green the CI gate + 3D panel expanded by default
- File-size: extract the marquee/candidate render into MarqueeOverlay so
DomEditOverlay drops back under the 600-line cap.
- Fallow complexity: suppress the 8 accepted-complexity findings from the 3D/
runtime work (resolveRuntimeTween, readRuntimeKeyframes, hasNonHoldTweenForElement,
commitKeyframeProps, scored, ImageCard, selectionShapeStyles, off-canvas effect)
with the bare directive the linter recognizes.
- 3D transform panel now defaults to expanded (the cube gizmo is the headline).
|
||
|
|
9bf1e1c298 |
fix(runtime): keep stamped flow children in document flow in preview (#1702)
Studio/preview stamps `data-start` onto ID'd and GSAP-targeted flow children (eg. a <header>/<footer> in a flex column) so the design panel can discover them. applyClipLayout then force-absolutized those stamped elements as if they were authored overlay clips, collapsing the layout: the footer shrink-wrapped and its `justify-content: space-between` clustered into the top-left, while the rendered video — which never stamps (production renders run as the top-level page, not in an iframe) — stayed correct. Mark runtime-stamped clips with `data-hf-autostamped` and skip them in applyClipLayout so they remain in document flow. The preview now matches the rendered video (true WYSIWYG). Authored overlay clips are unchanged, so the golden regression suite is unaffected. |
||
|
|
6987447a75 |
fix(runtime): immediateRender for set tweens + array timeline normalization (#1692)
* chore(studio): remove all console.* calls from studio package * chore(studio): address review — remove dead stubs, restore consent notice - Delete empty if-blocks left after console removal (snapTargetCollection, Player asset-poll, useTimelineSyncCallbacks 5s probe, useGestureRecording dev guard + now-unused isDevBuild) and the stale "surface in dev" comment. - Drop the dangling no-console pragma + dead duplicate-id branch in sourcePatcher. - Restore the one-time telemetry consent disclosure in showNoticeOnce (kept behind a pragma — it is a user-facing notice, not debug noise). - Remove the missed timelineIcons console.warn while preserving the `tag || "div"` null-safety fallback. - Route caption auto-save failures (a data-loss path) through telemetry instead of swallowing silently. - Restore the accidentally-clobbered css-var-fonts output.mp4 fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(runtime): immediateRender for set tweens + array timeline normalization - Set tweens now emit immediateRender:true so they render on page load without requiring the runtime to seek past position 0 - Runtime IIFE normalizes array timelines (window.__timelines = [tl]) to keyed objects, and auto-adds data-start on root elements - Drag teardown clears translate:none to prevent #1673 fly-off - Position-only set tweens hidden from timeline diamonds (3 cache paths) - Parser: ease-only keyframe update preserves existing properties * fix(runtime): address review — restore perf gate, debug surface, scrub restore - Restore the #1651 skipForInjectedVideo gate in media.ts that was dropped on restack — avoids ~2400 wasted per-tick seeks on video-heavy renders. - Restore the console.debug body + docstring bullet of swallow() in diagnostics.ts: the __hfDebug opt-in debug surface had been gutted to an empty if-block. - Rebind: after the progress-cycle set() kick, seek to state.currentTime via totalTime() instead of snapping to 0, so a rebind after scrub / soft-reload restore keeps the playhead. - Array __timelines normalization + data-start default now resolve the root via a shared findRootCompositionEl() that honors data-root="true" first (matches resolveRootCompositionElement, which now delegates to it). - Ease-only keyframe update leaves a primitive (non-object) keyframe value untouched instead of wiping it to {}; add a preservation unit test. - Document the boundDuration<=0 progress(1) kick + restore the STATIC-case comment in gsapRuntimeBridge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
11d6227d06 | fix: hide descendants of inactive render clips (#1662) | ||
|
|
1f5dbdbc4c |
fix(studio,producer): post-merge regressions and open issues (#1643)
- fix gsapDragCommit.test.ts import for commitGsapPositionFromDrag - add generateId() helper with insecure-context fallback (fixes #1637) - enable STUDIO_KEYFRAMES_ENABLED by default - prevent clock duration from shrinking during playback (fixes #1636) - add maxRetries to rmSync cleanup calls to handle ENOTEMPTY race (fixes #1644) |
||
|
|
091137e3c3 |
feat(studio): instant, flicker-free manual editing (#1605)
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports) |
||
|
|
341e65aea2 | fix(slideshow): harden media controls in present decks (#1619) | ||
|
|
f0c4dee705 |
fix(slideshow): present media controls (#1601)
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|