mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 23:36:59 +00:00
sync/hyperframes-codegen-58de81bf
23
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dda0e757b1 |
fix(studio): the host can name the Split key shown in the clip menu (#4985)
* fix(studio): let the host name Split's key in the clip menu * fix(studio): editor shell and timeline pane forward the Split key label * fix(studio): drop two added doc comments the comment ratchet refuses |
||
|
|
438ea9d59f |
perf(studio): manual edits stay under 8 ms per frame (#4898)
* ci(studio): measure any edit cases on quiet runners, five runs in parallel * perf(studio): an undo or save stops re-rendering every panel * test(studio): the edit bench types one key per frame, as a person does * fix(studio): the edit bench measures frame work when a task outside the drag has no thread time * refactor(studio): hooks hand back stable handlers themselves, keeping App.tsx under 600 lines * test(studio): the edit bench says why a case's frame work is unknown * fix(studio): the edit bench counts a task without thread time at its wall time * perf(studio): draw toasts on an opaque surface without backdrop blur * fix(studio): keep stable handlers bound to the project they were made in * fix(studio): every stable handler names the project it belongs to * style(studio): format the stable handler call sites * refactor(studio): name the timeline editing handler objects before wrapping them |
||
|
|
0559be0b13 |
feat(studio): link a video and its audio with data-link (6/8) (#4818)
* feat(core): add data-link media link helpers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(lint): warn on out-of-sync and orphaned linked clips Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): select, drag and trim linked clips together Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): detach, merge, link and unlink source transforms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): link-aware split, delete, alt-edit unlink and link edit actions Split and delete fan out to link partners. Alt drag/trim edits one member and unlinks it. onLinkEdit runs unlink, link, detach and merge as one undo step each. Single-clip timing edits of a linked clip skip the SDK path so its linked default cannot move the partner behind the store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio-server): give split right halves of a linked pair their own link Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): clip menu detach, unlink, link, merge back and delete-one items Items live in clipMenuLinkItems.tsx with a one-line hook in ClipContextMenu; the same resolver drives the Cmd+L, Opt+Shift+D and Opt+Delete shortcuts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(sdk): setTiming applies to data-link partners by default { linked: false } edits one member and unlinks it, matching Studio's Alt-edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(skills): document data-link linked clips in hyperframes-core Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(studio): keep comment share at or below base in link plumbing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(studio): audio pill link follows data-link, not a shared file name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): alt-click selects one linked member even inside the pair selection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(lint): drop the rule-list comment for the linked clip rules Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sdk,lint): keep a link group inside its own composition Link ids are minted per file, so lk-1 is normal in the root and in a child composition. The SDK's linked-timing query descended into nested hosts and addressed every match with the initiating scope, so moving a root clip moved the child's pair too; lint grouped them into one drifting group. Both now group only members of the same composition scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): match linked media by full asset path, scope links, keep hidden sound silent Merge and Link compared only a lowercased basename, so assets/one/talk.mp4 and assets/two/talk.mp4 enabled a destructive Merge. Clips now compare the src resolved against each clip's own source file, and Merge re-checks the two srcs in the source before it runs. Link membership in the timeline (linked members, gesture expansion, unlink orphans) is scoped to a source file. Detach and the keep-sound cutout carry a hidden video's data-hidden to its audio, and Merge refuses when only one member is hidden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(studio): move the timeline move-edits handler into its adapter App.tsx sat at 600 lines on this layer and 602 once merged with main. useTimelineMoveEditsHandler now lives beside persistTimelineMoveEditsAtomically, same callback and dependencies, bringing App.tsx to 592. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sdk,studio,lint): keep link groups inside inline compositions A link group now stays inside the nearest data-composition-id or data-composition-file ancestor. The SDK used to recognise only file hosts, and Studio compared only sourceFile, so an inline child composition that reused a link id was edited together with the root group. core/media-link linkScopeOf is the shared rule for the SDK and Studio. Lint cannot import core, so it uses the same two attributes directly. Studio rows record their compositionScope, and link and merge refuse pairs that cross it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sdk): refuse a link edit whose partner address names another clip A partner addressed in the caller's scope can resolve to a different element when an inline composition reuses a root clip's data-hf-id. setTiming now checks that each partner's address resolves back to that partner and throws before mutating when it does not, instead of editing the root clip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): a remote media url keeps its query when matching an asset Two different videos at one remote path with different queries were treated as the same file, so Merge could delete the audio and use the wrong video's sound. Project-local srcs still drop query and hash. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(studio): keep a remote url's query exactly as written when matching assets Decoding the whole url folded %26 into & so two different media urls matched. Decode only the path; the query is compared as authored. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(studio): compare a remote url as serialized, decode only local paths A remote pathname with an encoded reserved character (%3F) matched the same path with a literal ?, though they can return different media. Remote urls keep origin, path and query exactly; local srcs are still decoded and stripped of query and hash. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
000ccd426a |
feat(studio): speed presets, voice, look, crop presets and freeze frame in the clip menu (3/8) (#4815)
* feat(core): add gentle ramp-in, ramp-out and slow-mo-middle speed presets Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): add voice, look and crop clip-menu items and clip badges Voice and Look are single-choice submenus writing data-fx-chain and data-color-grading; None clears. Crop opens the canvas crop preset bar. ClipBadges reads the live preview node and follows attribute edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio): drop the unstyled badge classes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): add crop aspect presets to the canvas crop handles Free, 16:9, 9:16, 1:1, 4:5, Reset and Done float above the clip opened from the clip menu. Presets commit a centred inset clip-path; Reset removes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(studio): read the committed crop from the inline clip-path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio-server): add freeze-frame file mutation Extracts the frame under the playhead with ffmpeg (media time honours data-media-start, playback rate and a rate lane) into assets/freeze/, then in one write splits the video, inserts a 2 s <img> still with the video's box styles, shifts the rest of the track and splits a data-link partner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio-server): import replaceFileAtomically from core atomic-file Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(studio): add freeze frame to the clip menu Freeze frame on a video under the playhead posts the studio-server freeze-frame mutation and records its before/after as one undo step. onFreezeFrame is threaded through the timeline edit callbacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio-server): keep the freeze still inside assets/freeze The clip id from the project HTML went straight into ffmpeg's -y output path, so an id with ../ segments wrote outside the project. The id is now reduced to one safe filename component, the output is pinned inside assets/freeze before extraction, and the still's src is built from that name rather than from a filesystem path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(studio-server): give every freeze still its own immutable name Two clip ids that sanitise alike (a.b / a_b), ids sharing an 80-character prefix, or a second freeze of one clip at the same playhead all produced the same still path, and ffmpeg's -y replaced the first still's pixels. The name now carries a hash of the raw id and a per-extraction token, the route refuses a name that already exists, and ffmpeg runs with -n so it can never overwrite. The freeze media time reads data-playback-start before data-media-start, as playback does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5b79038ca4 |
feat(studio): hosts can add their own items to the clip right-click menu (#4784)
* feat(studio): hosts can add their own items to the clip right-click menu * refactor(studio): let the clip menu item names carry their meaning * fix(studio): a clip menu item that moves the focus keeps it after the menu closes * test(studio): the clip menu focus hand-off holds under StrictMode * docs(studio): state the menu focus rule without history |
||
|
|
a57e095468 |
feat(studio): panel tabs take the decided Current design (#4266)
Studio's panels become cards on a dark shell, and each panel's tabs take the "Current" design: the open tab is a fill in the card's colour that slides to the tab you pick, only the open tab shows its icon and close glyph, and a strip that is too narrow scrolls with a soft edge instead of hiding tabs behind a menu. This replaces the pill, underline and close treatment that was on this PR before. ## Before   ## After   Switching tabs (the fill slides in about 140 ms and the new icon fades in over 80 ms; the panel body switches at once): https://github.com/user-attachments/assets/8d359ada-c9e1-4425-9c95-61d5d4f5271b ### Every label whole, and the tabs use the whole strip (2x) An earlier head clipped the left strip's fourth tab next to empty strip space. That space was a 72 px slot (plus 4 px) held on every strip for the strip actions, even on strips whose panel is not focused and so draws no actions. Dockview sizes that slot by its content; this PR had pinned it. Now the slot takes room only where the actions are drawn, so an unfocused strip gives its tabs the whole width. Tab padding is 6 px with no gap between tabs, so the tabs also fit next to the actions. Labels, icons and close glyphs sit on the tab's centre in every strip. Left strip at 1440 x 900, tabs width against the room before the action slot: | | macOS | Linux (these captures) | |---|---|---| | Earlier head | 322 in 280, clipped | 312 in 280, clipped | | Now, panel unfocused | 278 in 358 | 268 in 358 | | Now, panel focused (actions shown) | 278 in 282 | 268 in 282 |    At 1280 x 800 the left column is narrower, and the pinned slot clipped Catalog next to the empty space. Room for the tabs there went from 241 px to 317 px:   Captured at the PR head ( |
||
|
|
85a1a8f789 |
fix(studio): swap the preview via a shadow iframe instead of blanking on reload (#4207)
* fix(studio): reloading the preview swaps in a ready copy instead of blanking * fix(player): report painted once assets are ready and the loader has faded out assetsready fires when the loader fade starts, so a consumer promoting on it can expose a raised or fading panel. painted fires once per document load after the fade completes, with a getter for late listeners; it resets on each load. * fix(studio): promote the shadow preview only when painted, under one wait budget Studio waited for ready plus two frames, which can land while the player's loader panel is still up or fading. It now also waits for the player's painted event. The shadow no longer arms the 5s adapter give-up, so a slow but live load fails only at the single 15s budget. Set-destination availability follows the live iframe across a swap. |
||
|
|
84e6531668 |
feat(studio): a host can make the preview read-only (#4200)
* feat(studio): a host can make the preview read-only Gates the shared geometry commit funnel (offset/size/rotation), so the inspector's manual X/Y/W/H/rotation fields refuse the same as canvas drag/resize/rotate. The flat inspector's fields render disabled with a host-supplied reason. Fixes a hooks-order crash on deselect found in review. * refactor(studio): derive the read-only preview flag from context, not a synced store EditorShell synced its readOnlyPreview prop into a zustand store via a raw useEffect behind an eslint-disable of no-restricted-syntax. Replace it with a PreviewReadOnlyProvider context computed fresh from the prop every render: Tier-1 consumers (genuine EditorShell descendants) read it via useContext; Tier-2 consumers (useAppHotkeys, useGestureCommit, the useDomEditSession/useDomEditCommits/useDomGeometryCommits chain), which run in App.tsx above EditorShell, take it as an explicit parameter threaded from StudioApp's own prop instead. Adds a host-mount test proving EditorShell gates its descendants from its own prop with no StudioApp/App.tsx involved. * fix(studio): keep preview hotkeys context explicit * fix(studio): close read-only interaction escapes * fix(studio): preserve timeline ownership in read-only preview * fix(studio): rollback read-only preview work * fix(studio): keep hotkey ownership on the active surface * fix(studio): format preview overlay and strengthen split test * fix(studio): clear preview gate findings * refactor(studio): share preview test fixtures |
||
|
|
98e8372a0a |
feat(studio): all panels move onto the dockable, persisted layout (#4192)
* feat(studio): every Studio panel is a dock panel and the shell moves onto it
EditorShell, App.tsx, the left and right panel columns, and the
inspector hooks now render through Dock.Panel instead of the old
fixed-width sidebars and split-pane inspector. usePanelLayout becomes
a thin facade over the dock store; fitPanels, the resize divider and
the split-inspector hook are dead and removed.
* refactor(studio): drop dead inspector flags and stale fallow entries after dock migration
Collapse inspectorButtonActive into inspectorPanelActive, remove unread
layers/design active fields, un-export unused prop types, flatten a nested
ternary, and point fallow ignores at the renamed/deleted files.
* fix(studio): keep right-panel behaviours the dock migration dropped
Block params dismiss on selection or when Design is hidden, the slideshow
panel can be closed, the selection outline follows the inspector intent,
caption editing focuses Design, and a URL slideshow tab opens once the
panel exists.
* refactor(studio): name the inspector-open state once in the header
* fix(studio): right-panel behaviours survive selection, restore and reopen after the dock migration
Block params stay open over an element that was already selected, caption mode wins over block params, the slideshow panel waits for the dock, the Renders tab title survives a reopen, each side column remembers the tab it last showed, tab switches are reported only when the tab changes, and the e2e thumbnail selector follows the dock.
* docs(studio): describe the dock layout and point comments at the renamed right panels
* fix(studio): block params survive a preview reload of the selected element, and panel titles are covered
The dismissal compares the selected element by its target key instead of object identity, because a preview reload rebuilds the selection for the same element. A closed and reopened panel keeps its custom title, with a test, and the e2e selector is formatted.
* fix(studio): block params leave with any other tab pick, and a restored slideshow placement survives loading
Block params are now dismissed by the tab setter the way their old tab was, including a re-pick of the same element, instead of by comparing selections. The slideshow panel is only closed after a file that was a slideshow stops being one, so a restored layout is not discarded while the file loads.
* refactor(studio): drop the duplicate inspector-open computation
The rebase onto main left two identical inspectorButtonActive/inspectorOpen
constants after a conflict resolution; only the first was still referenced.
* chore(studio): allow the dock migration deletions in the guard
StudioLeftSidebar.tsx, LeftSidebar.tsx and its storage test,
TimelineResizeDivider.tsx, useInspectorSplitResize.ts and fitPanels
are replaced by the dock; the CI deletion guard needs each named
with its reason.
* style(studio): oxfmt the deletion-guard entries
* fix(studio): rename Timeline's aria-label so it does not collide with its dock panel wrapper
Every dock panel wrapper now carries aria-label=<title>, matching the "Timeline"
title for the timeline panel. Timeline.tsx also set aria-label="Timeline" on its
own content div, so `document.querySelector('[aria-label=\"Timeline\"]')` found
the dock wrapper first and the timeline viewport gate CI check timed out waiting
for an attribute the real content div actually carried. Renamed to "Timeline
track view" and updated every consumer: the production diagnostics selector,
its tests, and the e2e viewport-gate script.
* fix(studio): restore inspectorButtonActive as a real prop after a rebase regression
Rebasing PR2 onto main replayed an older local commit whose diff did not
textually overlap main's newer inspectorButtonActive prop, so git applied
it with no conflict marker and silently reverted the header to computing
the value itself instead of receiving it. StudioHeader.dom.test.tsx already
asserts the prop is respected; restoring it makes that test meaningful
again. App.tsx now computes it once (panelLayout.rightCollapsed is already
the dock's effective collapse state, no separate railed-by-width case) and
passes it down.
* chore(studio): wrap a long doc comment in useRightPanelIntents
|
||
|
|
ef64323174 |
feat(studio): host apps can place overlays on the preview and restyle its stage (#4187)
* feat(studio): host apps can place overlays on the preview and restyle its stage * feat(studio): stage background also recolours the preview panel; document the host layer |
||
|
|
c2ca2291ca |
fix(studio): dropping an asset between or above timeline rows opens a new row (#4160)
* fix(studio): dropping an asset between or above timeline lanes opens a new track there * refactor(studio): reuse the source patcher and shared drop-placement type for track inserts * fix(studio): the dropped clip must carry its peers' sourceFile, not the resolved path planDropTrackInsert stamped the synthetic dropped element with the resolved target path string, but real elements on the main document carry no sourceFile at all. sameSourceFile compared the two directly, so the dropped element always read as belonging to a different file and every peer was skipped as unwritable, silently landing the drop on an existing track instead of opening one. * refactor(studio): trim drop-track comments and share one can-insert-track predicate Drop the TimelinePlacement alias, compute the file-or-asset check once for dragover and drop, and shorten two comments that narrated history. * fix(studio): plan a dropped-in track against the visible rows, not clips the timeline hides The insert row counts the rows the timeline shows, but the plan was built from every same-file clip. A clip the timeline does not show as its own row shifted the boundary and renumbered lanes it should have left alone. * test(studio): assert the dropped clip lands on the opened lane * fix(studio): a row drop measures its boundary on the same row order a clip drag does The drop path indexed the insert row against the rows the timeline shows, which include audio-group anchor rows, but planned it against a plain sorted track list. An audio group above the boundary shifted the new row by one. The placement now carries the displayed row order and the planner uses it, as the clip drag already does. |
||
|
|
68b61675ed |
feat(studio): upgrade Studio to Tailwind CSS v4 (#3618)
* chore(studio): upgrade Tailwind CSS to v4 through the Vite plugin Studio moves from Tailwind 3.4 to 4.3.3, loaded by @tailwindcss/vite instead of PostCSS. The JS config becomes an @theme block in studio.css carrying the same studio.* and panel.* colors, with explicit @source globs so the gitignored data/projects tree is never scanned. The official upgrade tool renamed the utilities, but it rewrites bare words inside any string, so its edits to event names, CSS property names, filter function names, status enum values and prose were reverted by hand. Nothing in Studio relied on the v3 default border color, ring width or ring color, so the tool's compatibility shim was dropped rather than kept. * chore(studio): note the duplicated palette between theme css and preset * fix(studio): keep the pointer cursor on buttons after the Tailwind v4 upgrade v4 preflight dropped the pointer cursor on enabled buttons, so add it back in one base rule. Also migrate the last outline-none to outline-hidden. * fix(studio): keep the Renders Export button at its shipped 14px size * chore(studio): allow the tailwind v4 config deletions in the deletion guard |
||
|
|
a25da94b77 |
refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch * refactor(studio): fold the thumbnail output branches left after the storyboard mode went * docs(studio): drop leftover storyboard references after the view was removed * ci: allow the storyboard view deletions in the no-deletions guard * docs(studio): wrap an over-long comment line in the caption overlay helper * style(studio): reindent App.tsx after the view-mode wrapper was removed |
||
|
|
0b6d1039ed |
feat(studio): copy, paste and duplicate clip groups on the timeline (#4064)
* feat(studio): copy, paste and duplicate clip groups on the timeline Cmd+C on a multi-clip selection now copies all of them, not just the first. Cmd+V pastes the group at the playhead keeping each clip's offset from the earliest one and its original track when free. Cmd+D duplicates the selection in place. Context menu gains Copy/Paste/Duplicate, and a failed copy now toasts why instead of silently returning false. Root cause was a DOM lookup that couldn't address a composition-instance clip's root; group copy reuses the composition-aware resolver every other timeline editor already uses. Also fixes a duplicated data-hf-id on pasted/duplicated clips found during live testing, and reorders the post-paste selection to land on the new clips instead of stale ones. * fix(studio): scope the data-hf-id strip to tag brackets, not text content A clip's visible text can contain the literal string data-hf-id="..."; the previous whole-string regex stripped it as if it were the attribute, corrupting the copied text. Reproduced and confirmed the fix with a test. * fix(studio): extend root duration and coalesce cut on group paste/duplicate Paste and duplicate could silently truncate a group from export: they inserted clips past the composition's data-duration without growing it, so content existed in the file but never played or rendered. Both now route through the same grow-only ratchet blockInstaller already uses. Cmd+X on a multi-clip selection now deletes the whole selection in one call (the existing batched delete already used by the Delete hotkey), so one Cmd+Z restores all of them instead of needing one per clip. The data-hf-id strip and id-capture on paste now go through DOMParser and a tighter regex respectively, closing two escape hatches: a > inside an earlier attribute value, and a data-id attribute preceding the real id. * refactor(studio): share the id-attribute regex and hoist the clip DOMParser Simplify pass on the previous commit: the id-capture lookbehind was a second copy of the same pattern deduplicateIds already uses, now a single exported ID_ATTR_RE both call. The per-clip DOMParser instance in pasteTimelineClips is now created once per call, not once per clip. Adds a test pinning stripHfIds's no-parseable-root fallback. * fix(studio): close the stripHfIds hoist gap stripHfIds silently returned html unstripped when DOMParser found no body-level element (a tag the parser hoists into <head>, e.g. title or meta). Closes it with a regex fallback and a test reproducing the exact hoisted-tag case, not just the no-tag-at-all case from the prior commit. * refactor(studio): dedupe clip menu item classes into one helper The new Copy/Paste/Duplicate rows in ClipContextMenu each got their own static enabled/disabled className string instead of the parameterized itemClass(enabled) pattern the sibling TrackGapContextMenu already uses for the same job. Consolidated to one function; rendered classes are unchanged for every state. * refactor(studio): reuse timeRangesOverlap for clip paste placement tracksOverlap re-derived the exact half-open interval check timelineCollision.ts already exports and uses elsewhere on the timeline. |
||
|
|
94da403d6d |
feat(studio): expose Studio's live state to an agentic browser (WebMCP) (#3511)
* feat(studio): expose Studio's live state to an agentic browser Registers a `studio_look` tool on `document.modelContext`, so an agent in a browser that supports it can read what Studio knows: the open project and composition, the playhead, the human's current selection with its capabilities, and the timeline's elements with a handle for each. The API is `document.modelContext`, not `navigator.modelContext`. The latter is a polyfill compatibility shim rather than a spec member, so feature detecting it is wrong even where a published sample appears to work. Three decisions worth knowing: Registration happens ONCE per mount, with the dependencies held in a ref that every render refreshes. Depending on the handlers instead re-runs on nearly every interaction, because the DomEdit actions object changes identity with the selection and the element list. Each re-run aborts the registration signal and unregisters everything, and the spec warns that a quick unregister-then- reregister can apply an old call's arguments against the new schema. The test for this is the important one in the unit; breaking the empty dependency array fails it and nothing else. Tools resolve with a tagged result, they never reject. That is forced by the spec: a rejected `execute` has its reason discarded and the caller sees a bare UnknownError, so rejecting would guarantee the agent cannot learn why an edit failed. Elements are addressed by a minted handle, not by `TimelineElement.id`. That id is a synthesised identity, so `getElementById` misses most elements; the handle carries `data-hf-id`, else the DOM id, else a selector plus occurrence. Mounted from `EditorShell` rather than `App`, because the DomEdit contexts are only readable below `DomEditProvider` and `App.tsx` is three lines under the 600-line cap. The undo signal is reported as the shell actually exposes it, `canUndo` and a label, rather than as a revision counter. The depth lives in component-local state and is not reachable without plumbing it through the shell context, so the field says what it is instead of implying precision it does not have. Writes are not in this change. `canWrite` is optimistic and the comment says so; the write tools need a real guard against the paused-save and external- conflict states, which are not on any context this component can reach yet. * fix(studio): bound WebMCP look filters * fix(studio): remove premature WebMCP write state * docs(studio): name WebMCP singleton assumption * fix(studio): surface WebMCP registration failures |
||
|
|
6a92d21401 |
feat(studio,core): reach presets and the rack from the timeline (#3292)
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
||
|
|
254de3d1c4 |
fix(studio): captions UX — mode exit, undo, autosave surfacing, honest gating (#1968)
Caption-editing fixes from the studio UX review. This surface held five of the thirteen criticals; the theme is that the editing UI shipped ahead of its apply/persist pipeline, so several controls mutated an in-memory model with no downstream effect, and the mode itself could never be exited. Mode trap: caption edit mode auto-activated on detection and had no exit — `setEditMode(false)` and `reset()` had zero call sites, so the caption overlay replaced normal element editing for the rest of the session, even after switching compositions. The store now resets on composition change (flushing the last debounced edit first), an "Editing captions · Exit" pill sits on the preview, and a re-enter button appears once dismissed. Honest gating of dead surfaces: the Animation tab (31 presets × duration/ease/stagger/intensity) edited state that was never applied to playback nor serialized — wiring it needs a CaptionOverride schema extension in packages/core plus a runtime engine, so the tab is now visibly disabled with an amber "isn't applied to playback or saved yet" notice instead of silently discarding work. Timing edge-drags moved a block that never changed playback and never saved; the handles are gone and the blocks remain as select/seek targets. Double-click split desynced the overlay↔DOM index mapping, so split is out until regeneration exists. Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target) across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is active and reapplied to the live iframe. Previously ⌘Z reverted an unrelated file edit while the bad caption drag persisted. Autosave: save failures, including non-2xx, raise a persistent "not saved — Retry" banner; the code's own comment called this a data-loss path and it was telemetry-only. Debounced saves flush on unmount instead of being discarded, `beforeunload` flushes and warns while pending, and corrupt overrides JSON is distinguished from a missing file. Input safety and a11y: arrow-key nudge no longer hijacks arrows inside form inputs; numeric fields commit finite values only (typing "-" used to inject NaN into gsap and persist null); "Mixed" shows on multi-select divergence; Escape cancels an in-flight drag and restores the pre-drag transform; ⌘A selects all; caption blocks are keyboard-selectable with a playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed but nothing passed it); 24px hit areas around the 8px handles; a hint when no boxes are visible; visible input focus styles; tablist semantics. Perf: the 66ms getBoundingClientRect polling loop is replaced with event-driven updates (player-store subscription, preview messages, ResizeObserver, rAF-coalesced); the interval now runs only during playback. Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio revamp (#2291), so the mode pill, the sync-error banner and the re-enter button move to its successor, nle/PreviewOverlays.tsx, and the caption track's onSeek is wired in EditorShell. The per-keyframe onChangeKeyframeEase change that also lived in that file is dropped: main removed the prop, and #1967 now routes the diamond menu's ease action to the focused-ease-segment editor instead. Restacked onto main now that PRs 1962-1967 have squash-merged, so this carries only its own changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5fd84c395b |
feat(studio,core): a volume and a living meter on the group row (#3290)
* 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> * fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared Two CI gates, both from this branch's own additions. `File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the group-levels branch pushed it to 605 (cap 600). Extracted the `window.message` router — which already carried a `fallow-ignore-next-line complexity` admitting it had outgrown its home — into `previewMessageRouter.ts`, with the fixture lease, sender check and protocol accept-gate collapsed into one `acceptedPreviewMessage` so the listener is a flat dispatch and the suppression is retired rather than moved. Same branches, same refs, no behaviour change; the file lands at 561. `Test: runtime contract`: `levelBuf: Float32Array` resolves to `Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and `getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345). Pinned the field to `Float32Array<ArrayBuffer>`, which is what `new Float32Array(analyser.fftSize)` already produces. Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")` — main deleted that component in favour of `feedback/StudioFeedbackCard`, and touching this file for the group prop put the dangling path in fallow's scope. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
20d915938b |
feat(studio): ask for feedback when a render ends, not on a session counter (#3205)
## What Studio's feedback prompt now fires when a render finishes or fails, instead of on a session counter, and the reports it collects carry enough context to act on. - Replaces the 32px inline bar with a card in the existing toast stack - Adds `studio_feedback_shown` / `studio_feedback_dismissed` / `studio_feedback_interview_click`, so the funnel is visible - A failed export and a crash skip the 0-10 score and ask what happened - Adds a crash prompt to the error boundary - Attaches a breadcrumb trail, render settings and outcome, and how the project was created ## Before / after <img width="1500" alt="Before: a 32px feedback strip pinned under the preview. After: the feedback card in the toast stack, with one-tap answers, and the red variant for a failed export." src="https://github.com/user-attachments/assets/29e2373f-ee96-42c4-b006-ab9a0e56a60a" /> The old bar's rating numbers are `neutral-600` on `neutral-900/80`, which is why it reads as a disabled row rather than a control. **In the running Studio** — bottom-right, sharing the toast stack, hovering a chip explains it on the line above the input: <img width="1500" alt="The feedback card in the bottom-right of the running Studio, with the rotated follow-up question and a hovered chip explained inline." src="https://github.com/user-attachments/assets/3969da50-cc7a-466a-bbf9-b151a1bc1d5d" /> **On the crash screen** — the prompt the error boundary renders, asking what the user was doing rather than what went wrong, since the stack trace already covers the latter: <img width="1200" alt="The Studio crash screen with the feedback card below the Try again and Reload Studio buttons." src="https://github.com/user-attachments/assets/4d287011-9c19-4b94-99d0-07a0388fb39c" /> | | Before | After | |---|---|---| | **Trigger** | Every 10th session | A render finishing, failing, or a crash | | **Placement** | 32px inline bar, pushes preview up | Card in the toast stack, no layout shift | | **Rating targets** | 11 bare buttons, `neutral-600` | Native radios, resting fill, `neutral-400` | | **Follow-up** | Free text or nothing | One rotated question, four to seven one-tap answers | | **Option help** | None | Inline hint on hover and focus | | **Press feedback** | None | `active:scale-[0.97]`, 150ms ease-out | | **Keyboard** | No exit path | Escape closes, Enter sends, arrows move the rating | | **Auto-dismiss** | 20s, always | 30s, cancelled the moment you interact | | **Failure case** | Same NPS question | Its own question, no score, error quoted back | | **Crash case** | Nothing | Prompt on the crash screen | | **Visibility** | Submissions only | Shown / dismissed (with reason) / submitted / interview click | | **Report content** | Rating, comment | Plus breadcrumbs, render settings and outcome, project provenance | ## Why The old bar fired on a session count, so it interrupted at a moment with no subject: nothing the user had just done, nothing to have an opinion about. It also emitted nothing when it appeared or when it was dismissed, which made the collection rate impossible to diagnose. A prompt nobody answers and a prompt that never renders looked identical from the outside. Visually it read as a disabled row: 11 buttons at 11px in `neutral-600` on a dark strip, with no resting affordance. And appearing mid-task pushed the whole preview stack up, which the old code carried a comment apologising for. Separately, the reports it did collect were not actionable. A comment says what went wrong; it almost never says how to get there. ## How **One trigger, one owner.** `feedbackTrigger` owns eligibility and nothing else does: once per tab, thirty days after an answer, seven after a dismissal, never when telemetry is off (prompting someone whose response we would then drop wastes their attention). `VITE_HYPERFRAMES_NO_FEEDBACK=1` still disables it entirely. **One hook, every failure path.** The trigger watches the render job list rather than each of the four places a render can finish (server rejection, unreachable server, SSE terminal event, SSE connection drop), so paths added later are covered without touching the trigger. Renders loaded from disk history never fire it. **Reuses what exists.** The card wears `StudioToast`'s glass treatment and joins its stack, so there is no second visual language and no new CSS. The rating row is native radios, which gives arrow-key navigation, grouping and labels for free. **One question each, rotated across users.** A corner card that asks three things gets answered by nobody. Each person gets one follow-up with one-tap answers, explained on a reserved line rather than a floating tooltip (the card is 340px in a corner; a bubble above the chips lands on the question, below lands on the input). Detractors are never given a rotated question, because they already have a specific complaint. Every option was checked against the code: an option naming a feature Studio already has would collect taps meaning "I could not find it", which is indistinguishable afterwards from "it does not exist". **Breadcrumbs cost one line.** Every studio event already flows through `trackEvent`, so recording the trail there needs no new instrumentation and stays correct as events are added. **Provenance lives outside React.** A crash unmounts the tree, so it is captured when the project loads and read from module scope when the crash prompt renders. ### Privacy Breadcrumbs and provenance carry names, enums and counts only. Values are copied from a fixed allowlist of short keys, and anything longer than a slug is dropped rather than truncated, so comments, file paths, stack traces and project titles cannot reach them even if a future event carries one. Tests assert this. ### Where these responses land Studio feedback goes to PostHog and nowhere else, which is what it did before this change too. Worth stating because the CLI behaves differently: `hyperframes feedback` also forwards to the backend feedback endpoint via `submitFeedback`, on top of its PostHog event. Studio has never used that path, before or after this PR, so if you read CLI feedback anywhere other than PostHog, Studio responses will not show up there. Nothing here changes that either way. Whether the two surfaces should share a delivery path is a product question, not a defect in this change, and closing it would need a field on the backend DTO: it is shaped around `cli_version`, and Studio reports from a crash or a failed export deliberately carry no rating. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) **Unit** — 39 new tests: trigger eligibility and cooldowns, the detractor override, rotation, preset shape and the no-brands rule, breadcrumb rolling and privacy, provenance parsing and its failure modes, and the crash boundary rendering the prompt with no rating input. **Live** — both render paths driven end to end against a running Studio on a production bundle, with real renders. Every PostHog request was intercepted and dropped, so nothing reached the project. Verified the emitted payload for a finished render, a failed export, the rotated follow-ups, each chip's hint, and the interview link. **Not covered** — no live capture of a spontaneous crash. Three attempts to force one failed because Studio's guards held and it kept rendering, so the crash path is verified by component tests rather than by driving it. Touch devices see chip labels without hints, since the hint is revealed on hover and focus. |
||
|
|
96861cbafc |
perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails * perf(studio): bound thumbnail decoding resources * perf(studio): virtualize timeline thumbnail media * perf(studio): prioritize timeline thumbnail work * perf(studio-server): coordinate cancelable thumbnail generation --------- Co-authored-by: Codex <codex@local> |
||
|
|
4a12eb9d9c | feat(studio): wire expanded keyframe timeline lanes | ||
|
|
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 |
||
|
|
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> |