mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 22:08:03 +00:00
sync/hyperframes-codegen-de53072b
1759
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
b09619faa8 |
refactor(studio): preview consumers follow the live iframe from one shared owner (#4182)
* fix(studio): preview consumers read the live iframe from one shared owner * fix(studio): clear the shared preview iframe when the studio unmounts The store outlives StudioApp, so a remount would start from the previous mount's detached iframe. The owner hook now writes null on unmount. * fix(studio): skip the preview-iframe notify when setIframe repeats the current value setIframe always merged a new state object, so zustand notified raw subscribers even when the iframe reference had not changed. Bail out by returning the same state reference on a no-op set. |
||
|
|
9bec64a293 |
feat(studio): header and inspector tabs on the shared primitives (#3624)
* feat(studio): header and inspector tabs on the shared primitives The header's view-mode toggle and the inspector's tab strip become real Tabs, so both gain the roving tabindex, arrow keys and Home/End they never had; undo, redo and Inspector become IconButton and Button; Export becomes the primary Button at the medium size. Capture stays an <a download>, wearing Button's recipe, because a button cannot save a file. Colour literals in StudioHeader.tsx drop to zero: the brand gradient moves into theme.css as two tokens and the Export's hand-written text colour becomes text-bg-0. Ratchet total 475 to 470. * docs(studio): shorten the header and inspector tab comments to fit the comment gate * chore(studio): bank the lower colour-literal baseline for StudioHeader * fix(studio): arrow keys only move focus when the inspector strip toggles panes * chore(studio): allow deleting PanelTabButton, replaced by the shared Tabs primitive |
||
|
|
fd6049f356 |
refactor(studio): one hook owns file undo and redo (#4189)
* refactor(studio): move file undo/redo into one history-actions hook * refactor(studio): simplify history-actions hook * test(studio): cover history-actions ordering and motion-file reads * test(studio): type the history-actions test mock |
||
|
|
3ec26b5e27 |
feat(studio): shared menu, popover and value-control primitives (#4191)
* feat(studio): shared Menu, ContextMenu and Popover primitives One menu chrome and one popover chrome, on Base UI, wearing the theme tokens. Dismissal is plain Base UI with no capture-phase wrapper: its outside-press listeners are registered on document with capture set, so the canvas overlay's bubble-phase stopPropagation cannot starve them. Menu items carry an optional mono shortcut hint, a disabled state and a danger tone. ContextMenu opens at the pointer. Popover shares the surface, radius and open motion but owns no item semantics, so a text field inside it keeps its own keys. Also adds [role='menuitemradio'] to the playback-shortcut ignore list. The hand-rolled speed menu spelled its rows as buttons, which that list already matched; the shared radio item is a div with the same role, and nothing else in the list matched it, so an open speed menu would have leaked arrow keys and Space to the player. * feat(studio): shared Input, NumberField, Select, Slider and Toggle primitives The five value controls exist once, on Base UI, wearing the theme tokens. Each is boxed with a visible border and a focus ring so an inspector value reads as an input rather than as its own label. Value writes happen at a commit boundary, never per keystroke or per pointer move: one drag is one onCommit and one telemetry call. Right-click and Escape abort a drag in flight and put the pre-drag value back. The switch role joins the typing-target selector list, so a Toggle keeps the keys the native checkbox it replaces owned. No consumer moves onto these yet. * refactor(studio): shorten the primitives' comments and stop a default string reading as a class Main's comment gate rejects long comment blocks, and the class gate read the MenuItem tone default as a Tailwind class. The prop is optional instead. * feat(studio): export the shared primitives from the package entry A host mounting Studio pieces in its own tree imports them from the package: cn, Menu family, Popover, Input, NumberField, Select, Slider and Toggle. |
||
|
|
e505dcc5c5 |
feat(studio): dock dividers and tabs work from the keyboard and side columns fit narrow windows (#4186)
* feat(studio): dock sashes and tab strips work from the keyboard and side columns yield on narrow windows Every dockview sash is a focusable separator (role, aria-orientation, aria-label); arrow keys along the split axis resize the neighbouring groups by 16px (Shift 64), clamped by the panels' minimum sizes, by replaying a pointer drag so dockview's own clamping applies. Tab arrows now activate the tab they land on and wrap; Home/End jump to the first and last tab. Narrow-window rule: the preview keeps its 360x200 floor first; side groups' minimum width is clamp((viewport - 360) / 2, 120, 200) and they shrink to their defaults when it drops below 200. Below 600px the dock overflows and clips instead of squeezing the preview. Measured at 560px: before, the inspector column sat fully off-screen at x 560-760; after, columns are 120/360/120 and reachable. * refactor(studio): simplify side group zone lookup in dock layout * fix(studio): dock side-fit spares the preview group, follows panel moves and is covered through the shell A side panel tabbed into the preview group no longer lowers the preview minimum width, moving a panel re-applies the side minimums, a project switch disposes the previous observer, and the dock shell tests now cover the sash and resize wiring. * fix(studio): pin the preview floor on every group that holds the preview A group that held the preview kept a stale side-column minimum after a drag, so the preview could shrink below its floor. Every group with a preview tab is now constrained to the preview minimum explicitly. * fix(studio): every dock group gets a width minimum, shortcuts keep their meaning on tabs and sashes The side-fit now rewrites the minimum of every group each time, so a group that stopped holding the preview or gained the timeline never keeps a stale value. Alt, Ctrl and Meta arrows are left to the browser. The shell test now proves the resize observer watches the dock. * test(studio): the dock shell tests cover panel drags and sash labels * refactor(studio): share the dock test harness and drop an unused export The sash decorator is internal, the bare dockview setup and the persisted-group lookup used by several dock tests live in one place each. * refactor(studio): shorten the side-minimum comment to one line |
||
|
|
01aa446edf |
feat(studio): shared Button, IconButton, Tooltip and Tabs primitives (#3622)
* feat(studio): shared Button, IconButton, Tooltip and Tabs primitives Four primitives that consume design tokens only, plus the configured class merger they all share. - ui/cn.ts: one `cn` instance whose config knows the two class families theme.css invents, the `text-step-*` type scale and the `ctl` control heights, so a caller's class replaces a primitive's instead of stacking with it and letting stylesheet order decide. - Button and IconButton: native buttons on the three control heights, the radius scale, the semantic palette and the motion tokens. Four classes that resolved to nothing (rounded-button, shadow-btn-primary, bg-accent-red, ease-standard) are gone, which is why the Renders Export now has a radius. - Tooltip: Base UI's tooltip, so the portal, the viewport flip and the collision shift stop being hand-rolled. role="tooltip", the id wiring and aria-describedby stay explicit here because the library does not add them. - Tabs: Base UI's tabs, with roving tabindex, arrow keys, Home and End, and a data-tab-id the sidebar's focus restore looks up. Every interactive look is also reachable through a data-preview-state attribute that CSS alone reads, so a gallery can show hover, active and focus without a pointer. A test pairs the two lists in both directions. The class-resolution test compiles Studio's real stylesheet with every class the primitives emit and fails on any that produces no selector. Tailwind treats an undefined class as silence; that silence is what let four dead classes sit in this file. * feat(studio): export Button, IconButton, Tabs and Tooltip from the package root * docs(studio): trim comment blocks to pass the no-essay-comments gate * refactor(studio): use native CSS.escape in the button class-resolution test * refactor(studio): share the ui test mount helper and flatten the Button leading slot * fix(studio): keep the tooltip open on a disabled control * fix(studio): give the tooltip trigger no layout box and anchor to the child * fix(studio): anchor the tooltip through a ref so it adds no render * fix(studio): button children lay out with the consumer gap * test(studio): keep the no-icon label and shortcut layout assertion from the standalone fix * docs(studio): shorten the Tooltip header comment to fit the comment gate * test(studio): the Button vacuity guard names tokens that stay undefined |
||
|
|
fd020ca27c |
feat(studio): host apps can import the render queue from the package (#4184)
* feat(studio): host apps can import the render queue from the package * feat(studio): export FfmpegStatus and exercise it in the host import test |
||
|
|
93ff9a994d |
feat(studio): fail the suite on a class that resolves to nothing (#3623)
* feat(studio): fail the suite on a class that resolves to nothing Tailwind silently drops a class it cannot compile, so `rounded-button` renders as no radius at all and nothing goes red. Two tests close that: - the token gate compiles Studio's entry stylesheet with every class the source claims and reports `file: class` for anything that produces no selector. Tailwind is the judge, so there is no allowlist. - the hex ratchet counts colour literals per file against a committed baseline. It fails on a rise, and on a fall it prints the command that banks the lower number. The baseline is only written under a named flag, never as a side effect of a run. The gate's first run found seven names the markup has always asked for and no config has ever defined: rounded-button, shadow-btn-primary, bg-surface-hover, bg-accent-red, ease-standard, text-2xs and two panel backgrounds. Each is added to theme.css, the semantic ones by role and the rest as deprecated aliases for the sweep to remove. It also found a dead state class on the timeline clip, styled nowhere, which is deleted. * fix(studio): gate a class by where it is used, not what it is called The extractor treated a string as a class candidate only when its binding matched Styles/Classes/ClassName(s), so the same twelve classes were gated in `sizeStyles` and invisible in `buttonSizes`. A name is not a contract. Candidate-ness now follows one file's data flow: an identifier used inside a className attribute or a class-building call marks whatever is bound to it as classes, under any name. A subscript is excluded, so the key of `variantStyles[variant]` is not mistaken for a class list. The name-based anchor stays for the map whose only consumer is another module. The sweep this opened found one class no config has ever defined: the automation menu's hover row asked for a background that compiled to nothing, and now uses the token the rest of that row already uses. * fix(studio): the colour ratchet counts code, not colour-shaped numbers in comments * fix(studio): draw the drop preview and preview guides from theme colours instead of literals * chore(studio): bank the lower colour-literal baseline now that comments are not counted * docs(studio): shorten the style test and extractor comments to fit the comment gate * fix(studio): the colour ratchet keeps strings when stripping comments * fix(studio): escape every regex metacharacter when following a bound class list * fix(studio): find a class list bound to a dollar-prefixed name * fix(studio): style source paths use forward slashes on every platform |
||
|
|
1dce615c56 | chore: release v0.8.51 (#4178) | ||
|
|
ff078accda |
feat(studio): say why the SDK session's file read came back empty (#4161)
* feat(studio): say why the SDK session's file read came back empty
`stage: read` was the largest remaining class of SDK-session failures and a
single opaque string covering four different outcomes. It is a hard block, not
a blip: 56 users hit it over a 7-day window and, between them, landed zero
successful SDK edits — while staying active in Studio (3107 playback events,
138 design inputs, 20 commit transactions). Every edit in those sessions fell
back to the server path with nothing recorded, because no session also means no
resolver-shadow event.
readProjectFileOptional now returns a discriminated result instead of
`string | undefined`, and the telemetry carries the branch that fired:
- unsafe_path — the traversal/NUL guard rejected the path client-side
- http_error — the request failed, with its status
- missing_content — 2xx whose body was not the shape we expect
- empty_file — `optional=1` answers an absent file with 200 + "", which
parses into a session with no elements; previously this
looked like a healthy session that merely went unused
Two candidate causes were investigated and ruled out first, with tests kept as
regression cover in studio-server (projectIdEncoding.test.ts): project ids that
percent-encode (the CLI derives the id from the folder name, so a space or
non-ASCII character produces one) and sub-composition paths whose separator
encodes as %2F. Both resolve correctly today because Hono hands over an
already-decoded `c.req.path`. The decoded/encoded seam in resolveProjectPath is
a real latent hazard, hence the tests, but it is not this bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(studio): fold the absent-file read reason into the result union
Review feedback on #4161. `empty_file` was synthesized by the caller after
the helper returned `{ok: true, content: ""}`, so reason attribution was split
across two places and `ProjectFileReadResult` was not the full reason surface.
It now joins the union, and the caller has one branch.
Renamed to `absent_or_empty`: the optional=1 absent-file shim and a genuinely
0-byte file are the same 200 on the wire and cannot be told apart here, so the
literal name says what the tag actually means. For a composition it is always
the former.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
dc94692441 |
feat(studio): dock engine for a movable Studio layout, not yet wired in (#4165)
* feat(studio): dockview-backed dock engine, not yet wired into the shell Registry, layout schema/builder, zustand store, the Dock component and a persisted studioUiPreferences.dockLayout field. No panel moves onto it in this change; Studio shell is untouched and looks identical. * fix(studio): dock.css reads panel tokens as css variables so it compiles standalone Tailwind v4 processes dock.css without the studio theme, so theme() calls failed to resolve. Use the --color-panel-* variables instead. Also drop the unused DockController.open member. * test(studio): dock restore and schema cross-checks fail when their code is removed * fix(studio): a reopened side panel rejoins its column's tab group instead of opening a new column * test(studio): pin the dock reopen paths for the centre zone and a fully closed column |
||
|
|
7f452ad276 |
feat(core): speed ramps as a rate lane in data-automation (#4129)
* feat(core): speed ramps as a rate lane in data-automation A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio. * fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests * test(engine): read the playback-rate clamp from core instead of a literal |
||
|
|
86667203ce |
feat(studio): trimming a clip shows the snap guide and the frame at the dragged edge (#4163)
* feat(studio): trimming a clip shows the snap guide and the frame at the dragged edge * fix(studio): guide and preview stay correct under group trim and beat snaps Independent review found: the group-trim guide and preview seek read the raw single-clip snap even when a member clamp moved the rendered edge; a beat-snapped trim never drew a guide because the beat highlight still read the old move-only draggedClip prop; the 1/30 frame lead duplicated STUDIO_PREVIEW_FPS. * fix(studio): a trim never changes play state and never snaps to the playhead Both are regressions from the guide/preview seek this branch adds: the preview and restore seeks now carry keepPlaying, so seek() only resumes playback if it was already running; the playhead is dropped from the trim snap set since the dragged edge drives its own preview seek, so snapping to it was circular. Neighbour clip edges and beats remain valid targets. * fix(studio): releasing a trim while playing no longer rewinds the playhead * refactor(studio): share trim-with-seeking-playhead setup in resize tests |
||
|
|
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. |
||
|
|
339dc76026 |
feat(studio): move every design value into one theme file (#3621)
* feat(studio): move every design value into one theme file Studio's design values lived in three places: a JS Tailwind preset, an inline @theme block, and raw hex and pixel literals in markup and CSS. theme.css is now the single owner. Tailwind v4 turns it into both the utility classes and the CSS custom properties, so markup, plain CSS and canvas code all read the same number. - semantic colour palette (background levels, surfaces, borders, text levels, accent, selection, playhead, danger, media, container), radii, control heights, the micro type scale, font stacks, shadows, easings and motion durations - the stock Tailwind palette is cleared with --color-*: initial, so a colour that is not a Studio token cannot be reached from markup; the entries Studio's markup still names are re-declared as deprecated aliases and removed once the sweep converts them - motion durations ship as @utility rules that zero themselves under prefers-reduced-motion, since Tailwind has no duration namespace - Phosphor icons get one default size and weight, read off the theme at the app root; icons that pass their own still win - published as @hyperframes/studio/theme.css; the JS preset export stays as a deprecated shim for v3 consumers, with a test that fails if the two drift apart * docs(studio): trim theme token comments to pass the comment-length gate * docs(studio): shorten theme test docblock to fit the comment gate * refactor(studio): collapse the duration utilities into one functional rule * refactor(studio): trim theme.css comments to three lines * test(studio): fail when a legacy palette entry is not a Tailwind default * fix(studio): keep the palette reset out of the exported theme.css * style(studio): format theme.css with the repo formatter * fix(studio): declare theme.css browser-only so the packed-consumer smoke does not import it in node |
||
|
|
f50df37671 |
feat(studio): the Lint badge pulses while an error finding exists (#4152)
* feat(studio): the Lint badge pulses while an error finding exists * refactor(studio): drop needless memo around the lint error flag * fix(studio): the Lint badge pulse reads the same findings as its count * fix(studio): name the badge severity for screen readers and cover a mixed finding list * fix(studio): the error badge uses the panel danger token |
||
|
|
0a5c235173 |
feat(cli): hyperframes timeline prints a project's tracks and clips (#4138)
* feat(cli): hyperframes timeline prints a project's tracks and clips * docs(cli): list the timeline command * feat(cli): timeline command reports unreadable lanes; add skill docs * fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes * fix(cli): timeline resolves symlinks before the project containment check * fix(cli): timeline accepts project folders whose name starts with two dots * refactor(cli): timeline and compositions share the engine's start resolver * docs(skills): point agents at the timeline command where they decide how to read a project * fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips Nested rows carry absStart/absEnd on the main timeline and their owning file, alongside existing local start/end. Text prints absolute time first, local time in parentheses. Adds jq/node one-liners for common queries, tested against a fixture. * fix(cli): timeline places nested media at the runtime start and tests its query one-liners A nested media clip with a negative literal data-start was reported later than it plays, because the raw start was clamped before the host offset was added. The skill's jq and node one-liners now run against the fixture in the test suite. * fix(cli): timeline clamps a negative media start to 0 when its host starts at 0 The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the timeline command now does the same. * refactor(core): one media-start rule shared by the runtime and the timeline command The runtime and the CLI each carried a copy of the rule for when a nested media clip's start takes the host offset, and the copies had drifted. Both now call resolveMediaStartSeconds. * test(cli): timeline fixture for a media start written as an expression parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the two callers cannot disagree on what counts as a literal; this pins the expression case. |
||
|
|
0915affe97 |
fix(studio): button children lay out with the consumer's gap (#4159)
Children now render directly as flex items of the button (which already carries gap-1.5/gap-1/gap-2) instead of a wrapping span, so a label plus a kbd shortcut as two children get real spacing instead of 0px. |
||
|
|
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 |
||
|
|
2db126d4c8 |
docs(skills): one owner doc for everyday edits and a hyperframes-studio skill (#4125)
* docs(skills): one owner doc for everyday edits and a hyperframes-studio skill Agents got contradictory advice on volume fades and were blocked from splits and copies by the Ask-agent guardrails. - creator-editing-recipes.md is the single owner: adds align-to-event, copy-a-group and swap-media recipes and the one volume rule (use the lane) - variables-and-media, data-attributes (skill and CLI copy), the gsap adapter and tracks-and-clips now defer to it - Ask-agent guardrails allow the neighbour edits a split, retime, copy or swap requires and point at the owner doc - new hyperframes-studio skill: sub-compositions for every scene, one caption track, one element kind per track, safe zones - drift test lints every html example in the owner doc with the real linter and fails on any doc teaching a volume tween * refactor(skills): drop unused tween allowlist from recipes drift test * docs(skills): name the track kind attribute in the studio skill * docs(skills): stop claiming lint rules that do not exist yet and fix track numbering Review fixes: - studio skill: drop the severity claim and the preview-overlay claim, group tracks by data-track-kind instead of numeric ranges that clashed with the recipes - recipes: audio data-start is root time only in the root composition; scene-local inside a sub-composition file - guardrails: timeline edits are exempt from the element-only and timing lines; the pointer names the skill reference, not a repo path - montage doc defers to the volume lane; skill count is 21 everywhere - drift test also scans the montage doc * fix(skills): the ducking generator emits a volume lane and captions defer to the studio safe zones - audio-duck.mjs prints a data-automation lane (clip-local times from the target data-start) instead of a timeline volume tween; --json adds the lane - operations.md shows the generated lane; the drift test scans it - embedded-captions safe-zone lines point at the studio skill, which owns the values * chore(skills): regenerate the skills manifest after rebase * docs(skills): remotion translation docs point volume ramps at the automation lane * docs(skills): studio safe zones use the action-safe 90 and title-safe 80 boxes * docs(skills): caption docs defer to the studio safe boxes * chore(skills): regenerate the skills manifest after rebase * fix(skills): studio skill names the safe margins; guardrail test matches the timeline-edit wording |
||
|
|
34644a1993 |
Merge pull request #4148 from heygen-com/release/v0.8.50
chore: release v0.8.50 |
||
|
|
b44f1f85dc |
feat(lint): flag nested structure, missing timing and caption tracks on the root composition (#4146)
* feat(lint): flag nested structure, missing timing and caption tracks on the root composition One rule set for the root composition: nested layout inside a top-level element, a timed element or media without data-duration, captions not marked data-track-kind=captions, and captions spread over more than one track. Findings are errors when the host is Studio and warnings elsewhere. Sub-composition files are left free to hold layout. * test(lint): keep the structure rules silent on the shipped catalog and skills Runs the rules over every registry and skills html file and asserts no findings, and treats a bare media element with no timing as a static layer. * fix(lint): run the structure rules as Studio in both adapters and skip non-root files The Vite Studio adapter now lints as the studio host, and HTML the project lint did not treat as a root (components, snippets) is linted as a sub-composition. Style, script and template content no longer count as nested layout, sub-composition hosts need no duration, and a caption row without a lane is not a second track. * fix(lint): count legacy data-end as a length and pin the catalog test to real roots |
||
|
|
42f4afa367 | chore: release v0.8.50 | ||
|
|
ea07f0e93b |
Merge pull request #4139 from heygen-com/sdk-cutover-decline-reason
fix(studio): a composition with a <canvas> no longer kills the SDK session |
||
|
|
7797c5ead4 |
feat(studio): the timeline shows one row per top-level element and no child rows (#4132)
* feat(studio): the timeline shows one row per top-level element and no child rows * test(studio): cover the top-level id collection the timeline rows come from * chore(studio): shorten the row-elements comment * fix(studio): keep implicit layers as rows and say why a nested element cannot be hidden * refactor(studio): drop implicit layers; rows are exactly what topLevelElements returns * test(studio): untimed root wrappers are not timeline rows * fix(studio): an empty timeline message clears the rows; drop the unused implicit contract * fix(studio): an empty clip manifest reaches the timeline and clears the rows * fix(studio): an empty clip manifest does not shrink the transport to its 1s floor * chore(ci): allow deleting the child-row expansion hook and its test |
||
|
|
63d0ea4d28 |
fix(studio): show where a dragged file will land instead of a drop-to-add box (#4142)
Dragging a file over Studio covered the screen with a "Drop to add at the playhead" box, but a drop lands at the pointer time on the target row. The box is gone; the timeline now draws a landing preview at the exact start and track the drop will use (a new row below the last lane when that is the target), taken from the same placement function the drop commits. |
||
|
|
db78b4fe68 |
fix(studio): keep a literal relative require out of the canvas-stub docs
verify-packed-manifests scans packed JavaScript for relative import
specifiers with a set of regexes that do not strip comments, so the
`require('./canvas-shim.cjs')` inside the stub's doc comment read as a real
import of a file that is not in the studio tarball, and the Build job failed.
Describes linkedom's try/catch guard in prose instead. The same literal was in
the test file's header comment; CI only reported the first match, so both are
fixed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d5e1de9201 |
fix(studio): a composition with a <canvas> no longer kills the SDK session
linkedom's HTMLCanvasElement constructor calls createCanvas(300, 150) from the
Node-only `canvas` package, behind this guard:
try { module.exports = require('canvas') }
catch { module.exports = require('./canvas-shim.cjs') }
The guard only works with a runtime require. Vite resolves the specifier
statically, so the bundled form is `try { exports = <module> } catch {...}` — a
plain assignment that cannot throw. The catch never runs, linkedom's own shim is
bundled but unreachable, and createCanvas lands undefined. Verified in the built
artifact: the canvas module namespace carried only a `default` export.
Parsing any <canvas> tag therefore threw inside openComposition, so Studio lost
its SDK session for the entire composition. Every edit then fell back to the
server path, and the resolver shadow did not run either, which is why this was
invisible before v0.8.47 added studio:sdk_session_unavailable. In the 72h after
the flip it accounted for 1065 of 1544 session-open failures, across a large
share of 197 users. Canvas is common in compositions: Three.js, shaders,
particle effects, charts.
Aliases `canvas` to a local browser stub that mirrors linkedom's shim
(measurement-only; nothing in the SDK rasterizes through it). Reimplemented
locally rather than pointing at linkedom's commonjs/canvas-shim.cjs, which its
exports map does not expose.
Confirmed against a rebuild: the namespace now carries createCanvas, so the
call returns a stub instead of throwing.
Note the unit tests here pass with or without the fix — vitest resolves the
require at runtime, so only the bundled build was ever broken. The regression
guard therefore pins the alias itself, since nothing else in the suite can
observe the bundler.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
898d27869e |
feat(studio): preview ruler and safe-margin toggles (#4127)
* feat(studio): preview ruler and safe-margin toggles * test(studio): cover guides placement in the preview pane and the caption band box * feat(studio): safe margins are two nested 90 and 80 percent boxes with edge ticks * fix(studio): safe box ticks centred on the line, no halo, tighter skill wording test |
||
|
|
c80ae95084 | fix(studio): hovering or selecting a timeline clip no longer swaps its filmstrip (#4130) | ||
|
|
a192701adb |
feat(studio): add per-project UI preference scoping (#4093)
* feat(studio): scope UI preferences per project Panel widths, timeline zoom, and other UI preferences shared one localStorage entry across every project. Key by the active project id (from the existing hash route), falling back once to the pre-scoping entry so an existing project's first load doesn't look reset. * docs(studio): trim a comment past the repo's 4-line cap * fix(studio): make per-project preference scoping opt-in Auto-deriving the project id from the URL by default would have silently flipped every existing caller into per-project mode, most of which read once at mount and never again across a live project switch, and some of which (playback rate, mute, snap) are documented as deliberately project-agnostic in playerStore's reset. Require an explicit projectId instead so today's 9 call sites are unaffected. |
||
|
|
6995a4c2f7 | feat(studio): the agent sees every timeline clip, audio included (#4123) | ||
|
|
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. |
||
|
|
cb9ca4f0af | chore: release v0.8.49 (#4122) | ||
|
|
f92944a2e7 |
Merge pull request #4100 from heygen-com/sdk-cutover-decline-reason
feat(studio): split ineligible_operation into its four real causes |
||
|
|
707f9a44c3 |
feat(studio): the first clip on an empty main track starts at 0 (#4099)
* feat(studio): a clip dropped onto an empty main track lands at 0 AD96 PR 1: the existing-clip-drag path (timelineCollision.ts) forces start=0 for a clip landing on the main track for the first time, since a track with one clip has no leading gap by convention. The file-drop path is deferred to a follow-up once #4067 clears its file. * refactor(studio): share the AD96 main-track drag test setup Fallow flagged the two new tests as a 22-line clone; factor the shared DraggedClipState builder into one helper. * fix(studio): don't retime the rest of a multi-selection on the main-track snap Adversarial review caught it: forcing the grabbed clip's previewStart to 0 fed straight into resolveMultiSelection's group delta, silently dragging every other selected clip's time on a vertical-only gesture. Skip the magnetic snap for a multi-selection drag; generalize resolveMainTrackDropStart to also serve the upcoming file-drop path (originTrack/isAudio instead of a full dragged element). * feat(studio): dropping a new asset or file onto an empty main track lands at 0 AD96 PR 1, file-drop half (unblocked now that #4067 merged): the same magnetic snap the existing-clip-drag path got now also covers a catalog-asset drop and an OS file drop, applied once at the single DOM drop dispatcher (timelineDragDrop.ts) so a multi-file batch still sequences correctly after the snapped first placement. Composition and block drops are left untouched — out of scope, per the original scout. * refactor(studio): share the drag-over-then-drop test act() block Fallow flagged the repeated act(() => { dragOver; drop }) pattern as two clone groups; factor it into one dropAt helper. * fix(studio): a mixed audio+video file drop always snaps, regardless of order Adversarial review caught it: classifying a multi-file drop by only the first file's kind meant [song.mp3, clip.mp4] and [clip.mp4, song.mp3] snapped differently, purely from Finder selection order. Classify the whole batch as audio only when EVERY file is audio, and add the missing all-audio and mixed-order test coverage. * refactor(studio): tighten main-track snap comment and dedupe file-drop array * fix(studio): snap a top-gutter track-insert that lands on literal track 0 Adversarial review caught it: dragging a clip into the top insert gutter creates a fractional lane that's never literal track 0 during preview, but normalizeToZones can renumber it to literal 0 on commit when the topmost visual lane was non-zero (main track genuinely empty). That persisted start was never routed through resolveMainTrackDropStart. Apply the same snap at commit time, using the real post-normalize track, guarded against multi-selection the same way the drag-preview path already is. * fix(studio): compute the track-insert snap once so the z-sync sees it too The previous fix snapped only the persisted edit after the fact, so the stacking sync still read the unsnapped start. Resolve the snap inside buildTrackInsertEdits from the final renumbered track and feed the one value to both the edit and the z-sync candidate. * refactor(studio): drop internal ticket codes from main-track snap comments and test names * refactor(studio): simplify main-track snap * fix(studio): snap the first main-track clip on its final lane and append new drops One landing rule now runs in both the drag preview and the commit, after the top-gutter renumber and the expanded-child-to-host conversion, so the ghost matches the commit. New file and asset drops onto a filled main track land after its last clip. The unused excludeKey check is gone. * refactor(studio): keep media-panel drops out of this change * refactor(studio): simplify main-track landing * test(studio): pin the main-track snap on a plain lane change at commit level |
||
|
|
61db453133 |
feat(studio): split ineligible_operation into its four real causes
The first 24h of v0.8.47 telemetry showed 30 declines against 25 successes on the dom family, and 26 of those 30 were `ineligible_operation` -- a single opaque string standing in for four unrelated causes. That made the number unactionable: a structural edit the SDK has no vocabulary for (expected, and permanent until the op mapping grows) looked exactly like a reserved-attribute or unsafe-attribute decline (narrow, and possibly worth fixing). `sdkCutoverIneligibleReason` now names which check failed: unsupported_op_type, child_scoped_op, reserved_attribute, unsafe_html_attribute, no_ops, or target_unaddressable. `shouldUseSdkCutover` is derived from it rather than duplicating the conjunction, so the predicate and its explanation cannot drift -- a test pins that agreement across every case. Also separates the two conditions that are not properties of the op batch at all: with the dom family disabled, sdkCutoverPersist reported `ineligible_operation`, which read as "the SDK cannot express this edit" when the truth was "this family is switched off". It now reports feature_disabled and session_unavailable distinctly, matching the other four chokepoints. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cc36905c29 | fix(studio): resolve a multi-clip selection's network probes in parallel (#4088) | ||
|
|
42f947f4c3 |
fix(studio): select and reveal a timeline clip after a drop (#4067)
* fix(studio): select and reveal a timeline clip after it's dropped Dropping an asset, block, or composition onto the timeline placed the clip but never selected it, so the user had to go find it. Wire the same select+reveal pair AssetCard/AudioRow already use into all three drop paths via a shared selectAndRevealTimelineElement helper. Root-caused a shared bug along the way: useTimelineSelectionPreviewSync compared the resolved selection count against only the ids already present in timelineElements, not against every id actually wanted. A freshly-selected id not yet in timelineElements (a just-dropped, pasted, or duplicated clip) made that comparison pass on zero resolved out of zero expected, falling through into a loud selection clear instead of the intended quiet retry. Fixed the comparison and covered drop, paste, and duplicate-of-a-group. * refactor(studio): drop the dead fallback in the new store-key helper Simplify-pass finding: both new selectAndRevealTimelineElement call sites always pass a DOM id they just minted, so the `?? fallback` after deriveTimelineStoreKey (which only returns undefined for a domId-less, selector-less caller) could never run, and the pattern was duplicated at both call sites. Added a non-optional variant for callers that already hold a guaranteed DOM id, and dropped the now-redundant "old code" narration from a test comment. * fix(studio): cap the selection-sync retry so a dead member cannot stall the group The bail-and-retry added for the drop/paste/duplicate race had no upper bound: a group member that can never resolve (deleted out of band, a stale id) made the whole group's selection retry forever instead of settling. Cap it at a few attempts, then apply whatever did resolve, same as the pre-fix graceful degradation, only bounded now instead of unconditional on every sync. * fix(studio): select and reveal a block dropped onto the preview canvas handlePreviewBlockDrop (a real drag-and-drop onto the preview pane, wired from usePreviewBlockDrop) was the fourth caller of addBlockToProject's hostKey but never selected the new element, unlike the other three drop paths this change wires up. * fix(studio): close a stale retry-budget gap and dedupe the store-key helpers Post-rebase simplify pass and independent review against the new head: deriveTimelineStoreKeyForDomId now calls deriveTimelineStoreKey instead of reimplementing its body, the composition-insert path uses the same helper instead of hand-building the sourceFile#domId format, and the selection-sync retry counter resets on deselect so reselecting an already-exhausted, permanently-unresolvable id gets a fresh grace window instead of degrading on the first attempt. Adds coverage for the asset-drop selection wiring and the deselect/reselect retry-budget reset. * style(studio): shorten a retry-budget docstring past the comment-line cap * fix(studio): quiet the just-dropped-clip toast and reveal the full clip The selection-not-found toast fired on the routine first miss while a freshly dropped or pasted clip was still resolving; it now waits until the retry budget is exhausted. The timeline focus coordinator likewise cleared a reveal request on its first miss before the dropped element had landed in logicalRows, cutting the clip off at the viewport edge; it now tolerates a bounded number of retries like its selection-sync sibling, so AssetCard and AudioRow reveals benefit too. * refactor(studio): share one retry-budget tracker between the two hooks The selection-sync and timeline-focus retry counters were the same shape, incremented and reset independently. Extracted recordRetryAttempt so both hooks call one tracker instead of maintaining their own copy. * fix(studio): stop the post-edit scroll restore from undoing a fresh reveal A dropped clip's reveal could still land cut off at the viewport edge even with the retry fix: Timeline's "restore scroll after an edit re-derives the elements" effect and the focus coordinator's reveal both react to the same element-list change, but the restore effect captured lastScrollLeftRef before the reveal had a chance to update it, so its deferred frame stomped the reveal's scroll position back to the stale pre-drop value. The reveal now keeps lastScrollLeftRef in sync with its own write, and the restore effect reads the ref inside its frame instead of capturing it early, so whichever effect runs last within the commit wins with the freshest value. Confirmed against the original repro (a Playwright drop scripted through the real dev server) that scrollLeft converges to the fully-revealed value and stays there. * docs(studio): note the load-bearing hook order behind the scroll-race fix Flagged by an independent review of the prior commit: nothing in the suite would catch a future refactor that reorders these two hook calls and reintroduces the stale-scroll race. * fix(studio): bound an unresolved focus request by wall time, not just retries Answers two review questions on the scroll-race fix. (1) The retry budget only advances when this effect re-runs, which needs some other dependency to change; a request whose target never appears and which nothing else ever re-renders sat unresolved forever, staying eligible to resolve against an unrelated element that later reused its id. A setTimeout backstop now clears it after 4s regardless of re-renders, independent of the retry-count path. New test proves the leak and the fix (fails without the timeout, passes with it). (2) The "hook order is load-bearing" comment in Timeline.tsx no longer holds once the geometry effect's ref read is deferred into its own frame: a new test co-renders both hooks in both registration orders and both converge, so the order claim is false for the current code. Deleted the comment; the same test fails in geometry-first order when run against the pre-deferral code, confirming order mattered before and does not now. * refactor(studio): drop a nonce check the store already performs Simplify pass on the wall-time backstop commit. The timeout callback guarded clearTimelineFocus behind a nonce match, but the store action already no-ops on a mismatched nonce (a zustand same-reference return short-circuits it) — the guard duplicated a check the setter owns, and was inconsistent with the sibling budget-exhausted branch two lines up that already calls it unguarded. Confirmed no other findings: the two-mechanism clearing (retry count plus wall time) is genuine, not redundant, and the timer has no leak path. |
||
|
|
e36a53d6b6 |
fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration Adds the compute and paint-and-idle inputs to the composition readiness gate, plus window.__hf.buildReady: a promise registry a piece registers when it has setup work no adapter can see (mesh building, shader compiles). The runtime holds render-ready on it and the player shows its loading state instead of a blank frame while it resolves. * refactor(core): dedupe the paint-and-idle readiness test setup Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput tests as a 12-line clone; extracted the shared doc/tracking setup. * fix(core): stop the readiness settled-tracker hanging on 2+ pending promises Promise.all allocates a new promise every call, so comparing ITS identity across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never observes settled once 2+ promises are combined: every poll rebuilds a new combined promise, re-arms, and republishes forever without the caller ever seeing true. Reproduced with two already-resolved window.__hf.buildReady entries, which is the exact multi-piece usage the new authoring doc describes. Fixes both trackers at once with a shared createSettledTracker that compares the source promise array by element instead of the Promise.all wrapper. * fix(core): cancel readiness inputs once settleCompositionReadiness settles computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept running after the shared timeout won the race, since nothing told the losing inputs to stop. One AbortSignal now threads through all three inputs and is aborted the instant the race settles. * docs(core): fix a stale sync-callback claim in settleCompositionReadiness paintAndIdleReadinessInput always has a frame to wait on for any real document, so the sync fast path this docstring described is no longer reachable with the default inputs except for a viewless document. Found by the simplify pass on this branch. * fix(core): clear a stale buildReady entry on runtime teardown A never-resolved window.__hf.buildReady promise from a torn-down composition survived into the next init in the same window (window.__hf is never reset), permanently blocking render-ready for whatever loaded next -- the exact black-screen class this branch fixes, reproduced through its own new mechanism. Found by the independent review. Also fixes a stale hyperframes-player.ts docstring (media/images/fonts had not been updated to compute + paint-and-idle) and documents the buildReady key-collision hazard in three.md. * fix(core): skip the compute readiness wait when no runtime ever ran computeReadinessInput polled window.__renderReady for the full shared timeout on any same-origin document with no HyperFrames runtime injected (a probe-only composition using native video/duration, or a blank test iframe) — nothing was ever going to set that flag. Checking window.__hf first (the same signal composition-probe.ts already treats as "has a runtime") lets it return null instead of waiting. * fix(player): debounce the loading overlay and diagnose compute timeouts paintAndIdleReadinessInput always has a frame to wait on for an attached iframe, so settleCompositionReadiness's synchronous-settle path is unreachable in practice now — the loading overlay showed on every single Play, even ones with nothing genuinely pending. Debounce the overlay itself instead: a wait that resolves fast never shows it. Also names compute (window.__renderReady) in the stuck-assets timeout diagnostic, which previously only re-scanned media, images and fonts. * fix(engine): name a stuck buildReady key in the zero-duration diagnostic A build promise a composition registers on window.__hf.buildReady that never resolves left the export path's zero-duration timeout naming nothing useful — hasHf/hasSeek/duration all looked fine, since the runtime was genuinely present and just waiting on that one promise. Probes buildReady's keys for pending ones and names them in the hint. * fix(engine): stop the buildReady diagnostic from crashing on a rejection A microtask race against a bare resolved promise rejected the whole diagnostic expression on any rejected buildReady entry (the runtime's own createSettledTracker rejects deliberately) and mis-sorted a settled non-native thenable as pending, since its extra adoption tick loses the race. Both measured directly, not inferred. Settle each key onto its own record past one macrotask instead: no throw, and pending/resolved/rejected are distinguished correctly. A rejected key gets its own hint, separate from a merely pending one. * fix(player): report a backgrounded document instead of overclaiming a paint bound MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames ever fire) still rides the full shared 8s timeout, same as before this input existed. Corrected the comments that claimed otherwise. That gap is exactly why the stuck-assets diagnostic left paint-and-idle unreported: there was no accurate way to say it might be the cause. Added documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded tab is the reachable, directly observable case. * refactor(engine): simplify the buildReady diagnostic rewrite Skip the macrotask wait entirely when a composition registers no buildReady keys — the common case, since it's opt-in. Nothing to settle means nothing to wait on. Collapse buildZeroDurationDiagnostic's inline parameter type into the existing HfDiagnostic interface instead of maintaining two copies of the same shape. * docs(engine): note the setTimeout(0)-thenable gap the review found An independent adversarial review, executing hostile inputs rather than tracing, found the macrotask wait can still misreport a thenable that resolves via its own internal setTimeout(0), since the diagnostic's own timer is registered before the thenable's. Bounded and self-correcting (only feeds the message text, and the diagnostic re-runs every ~1s), so documenting the boundary rather than chasing an unbounded fix. |
||
|
|
7b4f3a22b0 | chore: release v0.8.48 (#4085) | ||
|
|
8fe480c0a0 |
feat(studio): operators get a magnetic main track that closes gaps on delete (#4069)
* feat(studio): closing a gap on the main track closes it automatically Adds a Ripple toggle to the timeline toolbar (default on). With it on, deleting a clip on the main track shifts every later main-track clip left to close the gap, folded into the delete's own undo step. Linked overlays and the auto-snap/insert behaviors are separate follow-ups. * refactor(studio): one shared resolver for gap shifts, no scaffolding ahead of its caller Consolidates three separate shift-to-element lookups (the gap-close menu's two and the delete-ripple's one) into a single resolveShiftedElements, built without a non-null assertion. Also drops resolveOpenGapShifts and its tests from this branch: it ships with its actual caller in the next PR. * fix(studio): canvas Delete on a timeline clip uses the timeline's own delete op Clicking a clip then pressing Delete went through the DOM/canvas delete path, which never ripples the main track — only the timeline's own right-click Delete did. Both paths now converge: a DOM/canvas selection that resolves to an exact timeline row hands off to the timeline delete op instead of the REST remove-elements path, so ripple and undo-coalescing apply from every gesture, not just one of two. * test(studio): cover the delete-routing handoff and a stale-session ripple persist Adds an end-to-end test that a canvas selection resolving to a timeline row is handed to the timeline delete op rather than the REST path, plus a test proving an SDK-eligible ripple persist after a delete rebases on the post-delete disk bytes instead of resurrecting the deleted element from a stale in-memory session. Also drops the unused single-element handleDomEditElementDelete plumbing in useDomEditCommits.ts / useElementLifecycleOps.ts, dead since before this branch. * test(studio): cover the multi-member delete-routing handoff Closes an adversarial-review should-fix: the marquee-group path (expandGroup, 2+ members all resolving to timeline rows) was verified at the resolver level but not through the useDomEditSession integration wrapper end to end. * fix(studio): ripple delete closes only the gap it opened resolveMainTrackDeleteRippleShifts compacted the whole main track (resolveAllTrackGaps) instead of the deleted clip's own span, so a delete also swallowed any pre-existing gap further down the lane, or pulled every clip to zero when nothing followed the deleted clip. Now each survivor shifts left by exactly the duration of the deleted clips before it. * chore(studio): trim playerStore.ts under the file-size cap Rebasing onto main put this file's line count over the cap once combined with this branch's own additions; tightens the playbackRequest doc comment, no behavior change. * test(studio): cover multi-select ripple delete of non-adjacent clips Closes the should-fix from the second adversarial review: a marquee delete of two or more non-adjacent main-track clips in one batch had no regression coverage, though the shift-sum logic already handled it. * fix(studio): undo after a rippled delete names the delete, not the move editHistory.ts coalesces a folded ripple move into the delete's own undo entry by keeping the LAST recordEdit call's label — which was always "Move timeline clips", so the toast read "Undid Move timeline clips" after a keyboard Delete instead of naming what the user did. handleTimelineGroupMove now accepts a label override, and the ripple delete path passes its own "Delete timeline clip" label through it. * fix(studio): surface and de-dup the ripple's post-delete failure path A ripple that failed to persist after a committed delete was only logged to the console, leaving the user with an unexplained gap; now shows an error toast. The delete's history label was also duplicated as a literal at both call sites (the delete write and the folded ripple move) with no shared source; both now read from one constant so they cannot drift apart. * test(studio): dedupe the two delete-ops harness mounts Fallow's duplication check flagged the two new ripple tests' harness setup as a clone group; extracted a shared mountDeleteHarness helper. * fix(studio): one toast, not two, on a ripple-persist failure The generic group-move failure toast and the delete op's own specific one both fired on the same ripple-persist error, telling the user about one action twice. handleTimelineGroupMove now accepts a suppressFailureToast option (console-logged instead, not silenced) for a caller with its own more specific message, and the delete op's unconditional "Deleted X" toast is skipped when the ripple failed. * test(studio): cover suppressFailureToast through the real move hook The prior test mocked handleTimelineGroupMove entirely, so it proved the call site passes the option but not that the hook's own catch honors it. A missing project id makes enqueueGroupOperation reject before any persist runs, reaching the real catch block without faking the SDK/server path. * test(studio): dedupe the shared move-change fixture Fallow flagged the two suppressFailureToast tests' identical "change" object as a within-file clone group; hoisted to the describe scope. * test(studio): unmount the React root in the group-move harness |
||
|
|
700b94834d |
refactor(studio): extract hotkey dispatch out of useAppHotkeys (#4079)
useAppHotkeys.ts sits right at the studio 600-line cap on its own, so any unrelated growth in the same file (as landed independently in #4064) tips it over on the merge ref, where the file-size check actually runs. Moves dispatchModifierKey, dispatchPlainKey, their shared HotkeyCallbacks type, and handleUndoRedoKey into a new appHotkeysDispatch.ts, the same split useTimelineDeleteOps.ts already uses for this file's sibling. Pure code motion: no behavior change, same exports, same call sites. |
||
|
|
3306dbcdc0 |
fix(studio): keep the volume slider editable while automation owns the level (#4066)
* fix(studio): keep the volume slider editable while automation owns the level The Design panel's Volume slider disabled itself outright once a volume automation lane existed, with no alternative write path ever wired in — so turning automation on froze the control permanently. A commit now inserts or moves a keyframe at the playhead instead, reusing the exact merge logic the timeline automation lane's own double-click gesture already uses. The slider also now shows the envelope's live value at the playhead rather than the static, possibly stale attribute. * fix(studio): sample the live playhead for volume automation, not the stale store The store's currentTime only updates once at the end of playback, so a keyframe committed mid-transport landed at the last seek instead of the real playhead and could silently overwrite the lane's seed point. useLivePlayheadTime subscribes to the same live signal the timeline follows, matching how the audio FX panel already reads it. Also stops treating a video element's automation as invisible on the timeline: groupAutomationLanes, its paired row-height count, and the lane-drawing filter all gated on audio tag, so a volume lane written from the property panel never showed a track to expand or a keyframe to see. All three now key off "has lanes" (or audio-or-video where the element itself, not its attribute, has to gate a live edit), matching groupAutomationLanes and useTimelineTrackLayout's documented invariant that the row's reserved height and its drawn lanes count the same clips. * refactor(studio): simplify pass on the volume-automation fix Extracted mergeInsertPoint (automationLaneGeometry.ts) so the panel's withPointAt and the timeline lane's own double-click gesture share one merge/seed/sort rule instead of two copies of the same three lines. Fixed a real duplicate subscription: useVolumeAutomation was calling useLivePlayheadTime() itself even though its only caller already reads the same value via the prop PropertyPanel.tsx computes and passes down. Threads currentTime through as a parameter instead. Unified the three places that decide whether an element can draw an automation lane (groupAutomationLanes, automationLaneCountOf, TimelineAutomationLaneSlot's clip filter) behind one exported predicate, isAudioOrVideoTimelineElement, so the row's reserved height and its drawn lanes can't independently drift on what counts. * test(studio): pin the clip-local time translation for volume automation The adversarial reviewer mutated out `currentTime - elStart` and every existing test still passed — the duration clamp happened to mask its absence in every case that touched it. New case starts the clip at t=10 so the clamp can't hide a missing subtraction. |
||
|
|
5ef5419a9e |
feat(studio): the segment ease control is discoverable at rest (#4074)
* feat(studio): the segment ease control is discoverable at rest The per-segment ease diamond only ever appeared on hover, so nothing on the timeline hinted a segment had an editable ease at all. Now it sits at low emphasis by default and reaches full opacity on hover or focus, same as every other reveal-on-interaction control in this file. Left-click still selects and seeks (the frequent gesture); the right-click menu item is unchanged. The existing title attribute already names the control on hover. * fix(studio): keep the resting ease button clear of narrow diamonds At fit zoom on short segments the always-on button sat on top of both neighboring diamonds and read as a grey smear. Add hasRoomForRestingEase, a pure width check (button box plus a gap each side), and only show the button at rest when the segment clears it; narrower segments stay hover-only, same as before this feature. |
||
|
|
e174b1715b |
feat(studio): host apps can open the ask-agent flow from a picked element (#4075)
* feat(studio): host apps can open the ask-agent flow from a picked element Exports AskAgentModal and its props type from the public barrel so a host app embedding only the player can render the same modal. Factors the DOM-edit prompt builders' shared formatting into functions keyed on a minimal common shape, then adds buildPickerAgentPrompt and buildPickerAgentContextPreview built from the runtime picker's own payload (no Studio DOM access required) instead of DomEditSelection. An empty selection yields a plain comment prompt. * docs(studio): clarify agent-prompt-builder comments Simplify pass: buildElementInfoLines's comment omitted the conditional text line it emits, and selectorIndex's always-zero value from the picker path was undocumented as expected rather than a gap. * refactor(studio): share the selector/text line pair between context previews Simplify pass: buildAgentContextPreview and buildPickerAgentContextPreview each built the same [selector-tag-line, text-line] pair inline. Extracts buildSelectorAndTextLines() so both call the one place that owns it. * fix(studio): cut two over-length comment blocks to the 4-line cap Both docstrings had grown past the repo's 4-contiguous-comment-line limit before this branch's first push. Re-wraps each to 2 content lines, keeping the same meaning. * feat(studio): surface the picker's label and media src in agent prompts A host app embedding only the player has no DOM to read an element's label or image source from, so the picker-built prompt needs to carry both explicitly. The Studio DOM-based builder is unaffected: neither field is set on its mapping, so its existing output is unchanged (covered by the golden test). |
||
|
|
a8d44b2596 |
fix(studio): keep clips on their row through group, ungroup, and composition recovery (#4068)
* fix(studio): keep a split clip on its original track row Splitting an unauthored clip cloned the DOM node without stamping data-track-index, so the runtime's positional-index fallback renumbered it onto a different track on insertion. Both halves now pin to the element's current resolved track. Also adds A, matching CapCut, to return from the razor tool to select while it is armed. * fix(studio): stop the in-point seek from also firing when A exits the razor tool Both A bindings register on window in capture phase; the razor's listener mounts after the playback one, so relying on preventDefault ordering let the seek fire before the tool switch. Playback now checks activeTool directly instead. * fix(studio): stamp the resolved track through group, ungroup, and composition recovery Same defect class as the split fix: an unauthored clip's track falls back to its position in document order, and any DOM insertion elsewhere can shift that position. Group and ungroup move elements into and out of a wrapper; composition recovery clones a missing element back in. All three now stamp each element's already-resolved track explicitly, the same way split does, so none of them can drop a clip onto a different row. Also closes a gap in the earlier "A" hotkey fix: the razor-armed guard was unconditionally swallowing Shift+A and Alt+A, which should still seek to the in-point since only plain A exits the razor tool. * refactor(studio): reuse the existing patch-target and selection-match types Simplify pass on the track-stamping fix: resolveGroupChildTracks built its patch target by hand instead of calling buildDomEditPatchTarget, already used one function up for the same shape. resolveAuthoredTrack's selection param was a hand-typed copy of ElementMatchSelection instead of importing it. Collapses the three duplicated optional-integer track checks added to the wrap/unwrap/split routes into one isOptionalInteger helper. No behavior change. * fix(studio): match an ungroup child with no DOM id by its stable id findMatchingTimelineElementId had no branch for hfId, the stable content-hash id every element gets regardless of whether it has an authored id. An ungroup child identified only by that id fell through every branch and kept its old track instead of getting the resolved one stamped, leaving it exposed to the same row-drift this PR closes for every other element. * refactor(studio): resolve review lint suppressions with real fixes Three lint suppressions from this branch's earlier commits, resolved instead of left as noise: - unwrapElementsFromHtml's complexity: extracted the child-track map build into its own named function. - handlePlaybackKeyDown's complexity: extracted the razor/A-key check into its own named function. - The duplicated param block between UseDomEditSessionParams and UseDomSelectionParams: pulled the shared fields into one DomEditTimelineParams type both interfaces now extend. No behavior change. * fix(studio): fall back to the resolved track when nothing was authored resolveAuthoredTrack only ever returned authoredTrack, so an implicit or ungrouped child with no explicit authoredTrack resolved to undefined instead of its actual resolved track, silently skipping the row-pin for exactly the unauthored population this change exists to fix. Falls back to the resolved track, matching the function's own existing comment. * fix(studio): pin the razor split's resolved track for unauthored clips buildCutTarget had the identical gap resolveAuthoredTrack did: it only stamped an explicitly authored track, omitting the field entirely for an implicit/unauthored clip, so a split of it still drifted onto a new row. Falls back to the element's resolved track, same as the group and ungroup paths. * refactor(studio): share one track-fallback rule between split and group resolveAuthoredTrack and buildCutTarget each re-implemented "authored track, else the runtime's resolved track" with a different rounding rule (one rounded only the authored branch, the other neither). Extract resolveElementTrack so both flows share the same rule and can't drift out of sync again. |
||
|
|
b0e4316a20 |
feat(studio): open a project's root composition automatically on load (#4057)
* feat(studio): open a project's root composition automatically on load
Opening any project landed on an empty canvas ("Drop media here") with
Play disabled, even when a root index.html composition existed, because
nothing auto-selected it. Studio now opens the root composition (or the
only one, if there's just one) as soon as the file tree loads and no
URL already named a selection, and marks it "Root" in the comps list.
The root path is resolved once, in LeftSidebar from the shared file
tree, and passed down as a prop rather than re-derived independently
in the comps list from a different, driftable source array.
The settle guard is keyed to projectId, not a bare boolean, so
switching projects in-session (the studio's hash-based project switch,
same component instance) still gets its own auto-open instead of
silently inheriting the previous project's settled state.
* fix(studio): reset selection state on an in-session project switch
Switching projects in-session left activeCompPath from the old project
in place, which read as "already open" and made the new project's
auto-open settle without ever firing. useResetSelectionOnProjectSwitch
clears both activeCompPath and activeCompPathHydrated when projectId
changes, composed inside useAutoOpenRootComposition so the same commit
sees the reset before auto-open's own effect runs.
Also extracts the composition-selection wiring (state, URL hydration,
auto-open, this reset) out of App.tsx into one hook,
useActiveComposition, so App.tsx loses more lines than this fix adds
and stays under the 600-line cap without trimming unrelated lines.
* refactor(studio): un-nest the project-switch reset from auto-open
An independent review of 0bd281127 found useAutoOpenRootComposition had
grown three pass-through params purely to forward into a nested
useResetSelectionOnProjectSwitch call; none were read by its own
auto-open decision. React's render-phase state adjustment gives the
same commit-timing guarantee for a sibling call, so this calls the
reset hook directly from useActiveComposition instead, restoring
useAutoOpenRootComposition to its original 5-param signature.
Also applies two smaller review findings: a shared isHydratedFromUrlState
helper replaces three copies of the same URL-state check, and
useActiveComposition's setEditingFile/showToast params use the
existing EditingFile/AppToast types instead of restating their shape.
* fix(studio): make the file tree's project-switch staleness impossible
An independent review of ca3776e14 traced a live regression: on an
in-session project switch A->B where B's URL carries no comp param,
the reset added in this stack lets useAutoOpenRootComposition's effect
fire in the same commit-flush, but masterCompPath (derived from
fileManager.fileTree) still held project A's data for at least one
render + effect cycle, because useFileTree only cleared fileTree/
fileTreeLoaded inside a useEffect keyed on projectId, itself timed one
cycle behind. Auto-open would call onSelectComposition with A's master
path for project B and permanently mark itself settled, so B's real
root composition never opens for the rest of the session when the two
projects don't share an entry filename.
Root cause fixed in useFileTree.ts: the fetched tree is now held
together with the projectId it was fetched for, and fileTree/
fileTreeLoaded/compositions/projectDir are derived by comparing that
stored id against the live projectId, returning empty/not-loaded
whenever they don't match. This makes reading another project's data
structurally impossible, instead of adding a second reset timed to
race the first. The now-unreachable raw setFileTree setter is removed
(zero call sites repo-wide).
Every other consumer of these four fields was exposed to the same
stale window and is fixed by the same change: App.tsx's URL-hydration
guard and useDomEditSession's fileTree/projectDir, StudioLeftSidebar's
composition/asset lists, StudioRightPanel's assets/fontAssets/
projectDir, and VariablesPanel's fileTree-derived fallback comp path.
Regression test added (useResetSelectionOnProjectSwitch.test.tsx):
real useFileTree + useActiveComposition wired together with a mocked
fetch for two projects with different root files. Verified it fails
on the pre-fix useFileTree.ts (asserts B's root, gets A's stale one)
and passes on the fix.
* refactor(studio): stop the file-tree catch handler re-zeroing state
The effect already resets fileTree/compositionPaths/projectDir to
empty synchronously before the fetch starts, so the catch branch only
needs to flip loaded:true; re-writing the same empty arrays clobbered
any concurrent refreshFileTree() result for the same projectId that
landed first. Found by a simplify-pass review of 9555e920f.
* fix(studio): resolve the auto-opened composition from the filtered list
resolveMasterCompositionPath scanned the raw file list, which can include
non-composition .html (a vendored preset, a file with no
data-composition-id). Auto-open now reads the server's filtered
compositions list instead, and settles once per project either way, so a
stray .html earlier in the list can no longer become the opened target.
* fix(studio): close the same master-composition mismatch at its two sibling call sites
resolveMasterCompositionPath had two more callers still reading the raw
file tree: the sidebar's Root badge and the Variables/Slideshow panel's
master-view target. Both already had the server's filtered composition
list in scope; switch them to it too, and reword the docstring so a future
caller reaches for the right list.
* fix(studio): refresh the composition list along with the file list
refreshFileTree only patched the raw file list, leaving compositions
frozen at the project's initial load. Two callers now read compositions
for their root-composition target, so a file operation that adds or
removes a composition (create, duplicate, block install, background
removal) would leave the sidebar's Root badge and the Variables/Slideshow
panel's master target stale until the next full project switch.
* fix(studio): fix refreshFileTree ordering race and stale-field wipe
Two overlapping refreshFileTree calls for the same project could apply
out of order, and a response missing the compositions field could wipe
a known-good list. Adds a request-id guard and a stale-only fallback,
plus regression coverage for both and for LeftSidebar's root badge
sourcing from the filtered compositions list.
* refactor(studio): cancel superseded refreshFileTree requests
Simplify pass on the previous commit: reuses the id+abort pattern
already established in useFileManager.ts's openSourceForSelection
instead of only discarding the stale response after it completes.
* test(studio): assert the superseded refresh's fetch actually aborts
Closes the coverage gap the independent review flagged: the abort
call had no test asserting on it, so a dropped abort() would have
stayed green. Also cuts two comment blocks over the repo's 4-line cap.
* refactor(studio): extract shared test harness setup in useFileTree.test.tsx
Simplify pass on
|