4997 Commits
Author SHA1 Message Date
Miguel Ángel 132b9909fa chore: release v0.8.99 (#4800) v0.8.99 2026-09-30 22:41:49 +00:00
Miguel Ángel ca3a3f6e0b fix(studio): keep sub-pixel position and angle while dragging and on drop (#4766)
* fix(studio): keep sub-pixel position and size while dragging and on drop

* test(studio): expect three-decimal rotation in the manual edits rotation test

* fix(studio): keep resized boxes on whole pixels so a centred element stays put
2026-09-30 21:51:21 +00:00
Miguel Ángel ca54631464 test(studio): measure render drift against a producer frame in the edit accuracy bench (#4764)
* test(studio): measure render drift against a producer frame in the edit accuracy bench

Finds the target's box in a producer-captured frame by its flat colour, with sub-pixel edges from area moments, and compares it with the reloaded preview. The baseline gains the render column from the same full run on main.

* test(studio): mark the pixel box scan as intentionally complex for fallow

* test(studio): regenerate the edit accuracy baseline with render drift on main

Same full run on main 14e87005d as #4760's baseline, with the render column: render passes 618/660, 70/660 pass, 314/660 pass everything but smoothness.

* test(studio): fail only render drift when the producer cannot render a case

A producer capture failure used to turn the case into a harness error that wiped every accuracy metric. It now sets render to null with renderError, fails render alone, and is counted in the table.

* test(studio): shorten the render drift box comment to two lines
2026-09-30 21:01:40 +00:00
Miguel Ángel 299670e210 fix: stop two merge-queue flakes in the history prune and timeline tests (#4786)
* test(studio-server): history prune tests allow a file time a moment past Date.now()

* ci: temporary probe for the history prune test flake

* fix(studio): timeline scroll-end timer no longer fires after unmount

* ci: remove the temporary history prune probe

* test(studio): check the virtualizer teardown with a spy and a 200 ms wait
2026-09-30 20:51:39 +00:00
Leonel Rivas 7b0ec69704 fix(core): pair a staged scene copy's video with its own render frame (#4791)
Page-side shader transitions clone the outgoing and incoming scenes,
ids included, into staging layers. Color grading grades the copied
<video> too, and findInjectedRenderFrame looked its frame up with
getElementById, which returns the live scene's <img>. The live and the
copied grading entries then both kept their canvas right after that one
<img>, moving each other on every mutation, and the loop blocked the
page: a graded <video> inside a transition stopped the render at the
transition's first frame.

Look through the element's following siblings first, where the injector
puts the frame, and fall back to the id lookup only when it isn't there.
2026-09-30 20:03:54 +00:00
Miguel Ángel f1c1f1d69c fix(parsers): moving a clip on the timeline moves its inner animations too (#4761)
* fix(parsers): moving a clip on the timeline moves its inner animations too

* fix(parsers): a timeline move keeps chained and partly known tweens where they belong

* fix(parsers): moving a clip leaves chained outside tweens and template aliases in place

* fix(studio): a clip timing edit syncs its animations once, not twice

* fix(studio): a timing edit falls back to the server when the SDK wrote no animation change

* fix(studio): the SDK sync check reads scripts inside a template too

* fix(studio): the SDK sync check finds template scripts through the DOM walker

* fix(core): a clip retime writes only its own timed tweens, never outside ones

* style(core): format the clip tween import
2026-09-30 20:03:48 +00:00
Miguel Ángel 567525a8c2 fix: readers never see an empty or half-written project file (#4777)
* fix: readers never see an empty or half-written project file

* test: pin the create routes and block install to whole-file writes

* fix(sdk): a write through a link to a missing file creates that file

* fix: saves through a linked folder reach the real file, atomic writes handle long names

* test(sdk): check for the stray file, not the folder listing

* fix(core): creates work on Windows FAT drives, and links through folders resolve as the system does

* fix: agree the helper move in the deletion check, and match temp paths without a path regex

* test: check POSIX file modes only off Windows
2026-09-30 19:17:59 +00:00
Miguel Ángel 76c634dfa5 fix(studio): resizing a cropped element keeps the same part of it in view (#4743)
* fix(studio): resizing a cropped element keeps the same part of it in view

Rebased onto main as one commit (was a0a86cc1..080dfecf). Merged with the crop
release rule from main: a drag that ends where it started saves nothing.

* fix(studio): the crop follows each axis GSAP does not tween, while dragging and once saved

* fix(studio): the live-tween check reads every timeline, not only the first after a soft reload
2026-09-30 18:44:04 +00:00
Miguel Ángel 04b01342cf chore: release v0.8.98 (#4788) v0.8.98 2026-09-30 18:43:57 +00:00
Miguel Ángel 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
2026-09-30 18:07:24 +00:00
Miguel Ángel 8d537dea9f fix(studio): save moves in GSAP-free files without a 2 s wait for a warm parse (#4770)
* fix(studio): save moves in GSAP-free files without waiting 600 ms for a warm parse

* fix(studio): read a GSAP-free file once instead of re-fetching it as a cold parse
2026-09-30 17:28:59 +00:00
Miguel Ángel 0f390167df perf(studio): stop sorting the font list while the font picker is closed (#4769)
* perf(studio): stop sorting the font list while the font picker is closed

* test(studio): keep the font list test off the network and clean up on failure

* test(studio): stub the font APIs for every font field test and clear the head after each
2026-09-30 17:28:46 +00:00
Miguel Ángel 0c6f5fd80e test(studio): an edit accuracy benchmark for manual editing (#4760)
* test(studio): start an edit accuracy benchmark for manual editing (work in progress)

* test(studio): an edit accuracy benchmark for manual editing

Drives real pointer and keyboard gestures in the built CLI Studio on generated one-element projects and scores tracking, drop jump, reload drift, undo fidelity and smoothness in composition px.

* test(studio): move render drift out of the first benchmark PR

Render drift against a producer frame ships in the stacked follow-up so this PR stays near the size budget.

* test(studio): keep the edit accuracy bench clean under fallow

Builds the grid as a product of its axes, drops unused exports and marks the harness functions that fallow scores as complex.

* test(studio): report edit accuracy with the Studio and bench commits it measured

Moves scoring and the report files into report.mjs, stamps both commits at start, counts undo byte failures apart from the worst box, and survives Studio swapping the preview iframe mid-read.

* test(studio): bank the edit accuracy baseline measured on main

660 cases on main ae914334f: 0 pass all six checks, 304 pass everything but smoothness. Five cases that hit a harness error in the full run were rerun and spliced in.

* test(studio): state the edit accuracy build prerequisite in the runner header

* test(studio): keep the edit accuracy baseline one line per case under the formatter

* test(studio): store edit accuracy baseline values rounded up

Rounding to the nearest 0.01 stored seven 0.502 px tracking results as 0.5, which reads as a pass at the 0.5 px limit.

* test(studio): score edit smoothness on dropped frames and main-thread work per frame

* test(studio): measure edit accuracy on a still preview and score press jump

Snapshots wait for the preview frames and the box to hold still for 1 s, so Studio's asynchronous reload after a save is measured, not raced. A preview that never settles fails the metrics it feeds instead of erroring.

Adds press jump (box at press against the box before it, 0.5 px). Smoothness counts only dropped frames beyond a blank-page control driven the same way in the same Chrome; unknown trace data fails smoothness alone.

The runner takes the suite lock per chunk of 8 cases (--lock), reads the port Studio announces, keeps undo history and tmp projects inside the case dir and removes them on a signal, and stamps the CLI tree's commit and bundle hash.

* test(studio): stamp the edit accuracy build with the Studio bundle as well as the CLI

The CLI bundle hashed the same for two different Studio builds; Studio's index.html names its hashed assets, so the stamp now covers it.

* test(studio): regenerate the edit accuracy baseline on main after the settle fix

Full grid on main 14e87005d with the settle fix, press jump and the smoothness control: 70/660 pass, 314/660 pass everything but smoothness, 0 harness errors, 0 unsettled. Scored without render, which #4764 adds.

* test(studio): wait for each edit accuracy undo write before sending redo

Undo and redo each wait up to 15 s for their own write, like the settle; redo is sent only once undo landed, and a write that never lands fails undo with undoTimeout instead of scoring an untested redo. The reload waits for the files to hold still. The headline is now the accuracy count, with smoothness on its own line against the control. The settle compares each read with the window's first read, and a CLI outside a git tree no longer stops the run.
2026-09-30 17:07:51 +00:00
Miguel Ángel d572fe3baf ci: install a pinned static ffmpeg on linux instead of apt (#4782)
* ci: install a pinned static ffmpeg on linux instead of apt

* ci: run the jobs that use a shared action when that action changes

* ci: drop the ffmpeg cache so the install is bounded by the download alone

* ci: cap ffmpeg download retries so a Retry-After header cannot stretch the install
2026-09-30 17:07:32 +00:00
Miguel Ángel d1a5a05609 fix(studio): a click after an empty-canvas press selects on the first try (#4759)
* fix(studio): a click after an empty-canvas press selects on the first try

* fix(studio): a shift press outside the box no longer sets a click guard that never fires
2026-09-30 14:47:35 +00:00
Miguel Ángel 9ae1e3c842 chore: release v0.8.97 (#4775) v0.8.97 2026-09-30 14:08:38 +00:00
Miguel Ángel 7734697872 fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name (#4756)
* fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name

* fix(studio): a copy's tweens start where their originals do and stay in the timeline's scope

* fix(studio): a copy takes only tweens it can place exactly, and never moves the film's own

* fix(studio): a copy leaves out a tween that sits inside a callback or a guard

* fix(studio): a copy leaves out a set that runs only inside an if or an else

* chore(studio): keep the copy change under the comment ratchet
2026-09-30 12:58:02 +00:00
Miguel Ángel 14e87005d9 fix(studio): name a repeated section by its authored id in the Design panel (#4768)
* fix(studio): name a repeated section by its authored id in the Design panel

* fix(studio): the timeline's page-read fallback also names a repeated section by its authored id
2026-09-30 12:48:00 +00:00
Miguel Ángel ae8d1780ff fix(studio): undoing an agent's build takes its sections off the timeline too (#4757)
* fix(studio): undoing an agent's build takes its sections off the timeline too

* fix(studio): a reloaded preview's timeline is read from the new document, sections included

* fix(studio): a section still in the preview stays on the timeline, and trims are unchanged
2026-09-30 11:27:13 +00:00
Miguel Ángel 9a27b9f934 feat(studio): select rightward takes crossing clips, and a host can keep only Select and Split (#4755)
* feat(studio): a host can keep only Select and Split in the timeline tool menu

* refactor(studio): drop the prop comment the name already carries

* feat(studio): select rightward includes clips crossing the playhead
2026-09-30 09:27:28 +00:00
Miguel Ángel 18a1b226c5 feat(studio): a popover can point at its trigger with an optional arrow (#4758)
* feat(studio): a popover can point at its trigger with an optional arrow

* fix(studio): keep a side arrow off the popover corner

* refactor(studio): name the side arrow corner clearance instead of commenting it

* refactor(studio): the arrow corner clearance applies on every side
2026-09-30 09:07:15 +00:00
Miguel Ángel be5beaee61 fix(studio): font faces a host saves keep their own weight and style (#4753)
* fix(studio): a family's saved font faces keep their own weight and style

* fix(studio): keep the font face change within the comment ratchet

* fix(studio): a saved font face cannot break the page, and only weights a browser keeps are written
2026-09-30 08:57:58 +00:00
Miguel Ángel 84811c4329 fix(studio): shift+click on a group member removes it for good (#4754)
* fix(studio): shift+click on a group member removes it for good

Removing a member with shift+click shrank the canvas group, but the timeline kept the old set, and the timeline syncs its set back onto the canvas, so the member came back about 70 ms later. A one-element marquee over a live group had the same fault. The canvas now publishes the new set whenever the caller states the whole selection (a shift toggle or a marquee), while a late single primary inside the live set still keeps it.

* test(studio): a shrinking selection keeps the remaining member as the anchor
2026-09-30 07:08:14 +00:00
Miguel Ángel ae914334f2 feat(studio): let a host make the timeline read-only while seek, scroll and zoom stay live (#4749)
* feat(studio): let a host make the timeline read-only while seek, scroll and zoom stay live

Timeline takes readOnly and onReadOnlyPress. In read-only the subtree sees no edit callbacks, every clip is locked, and each refused press (clip drag, clip/lane/keyframe menu, drop, eye, razor, beat dot, FX) reports once through onReadOnlyPress.

* refactor(studio): route read-only refusals through one helper and share the keyframed test fixture

Keeps the provider hook under the complexity limit, drops the comments the ratchet counted, and reuses the ruler test's keyframed card instead of copying it. The provider keeps one tree shape in both modes so toggling read-only keeps scroll and zoom.

* fix(studio): switching the timeline to read-only drops edits already in flight

On the switch into read-only the timeline cancels an active clip drag or resize, beat drag and keyframe retime without committing, and closes the clip, gap and keyframe menus. A keyframe-diamond drag and an automation-lane point drag or double-click now report through onReadOnlyPress too, and the refusal stays one stable function however the host writes its callback.
2026-09-30 05:32:47 +00:00
Miguel Ángel 5461e0ab03 chore: release v0.8.96 (#4752) v0.8.96 2026-09-30 05:17:09 +00:00
Miguel Ángel 3f8b46cc1e fix(studio): shift+drag moves a selection along one axis instead of deselecting it (#4747)
* fix(studio): shift+drag moves a selection along one axis instead of deselecting it

* fix(studio): a tap on a selection puts the preview back and a travelled drag never toggles

* test(studio): a cancelled drag returns the preview to its start
2026-09-30 05:07:14 +00:00
Miguel Ángel 67327a8b25 fix(studio): a crop drag that ends where it started saves nothing (#4745)
* fix(studio): a crop drag that ends where it started saves nothing

* fix(studio): a crop pan back to its start saves nothing, and a release saves where it let go

* test(studio): a first crop released before its last render survives deselect
2026-09-30 04:17:43 +00:00
Miguel Ángel 4825f79294 fix(studio): an element placed by a stylesheet translate stays put after a move or resize (#4734)
* fix(studio): an element placed by a stylesheet translate stays put after a drag or resize

* fix(studio): an unset computed translate reads as nothing underneath

* fix(studio): an element on the old offset channel keeps its folded translate masked too

* test(studio): pin that a drag GSAP does not own leaves its translate unmasked

* test(studio): the unmasked translate still carries the offset channel
2026-09-30 03:51:21 +00:00
Miguel Ángel f5a348e92d chore: release v0.8.95 (#4751) v0.8.95 2026-09-30 03:47:02 +00:00
Miguel Ángel c3f7066bf3 feat(studio): draw the empty lane below the last track as a drop target and tighten the top gap (#4741) 2026-09-30 03:14:08 +00:00
Miguel Ángel 2fc5a37fce fix(core): a video's sound plays at its boosted volume in the preview (#4746)
* fix(core): a video's sound plays at its boosted volume in the preview

A <video> with data-volume above 1 now plays through the same Web Audio gain as <audio>, so the preview matches the export. Videos at or below 1 keep their native output.

* style(core): keep the runtime's comment share at its base

* fix(core): a boosted video hidden after its volume drops goes silent
2026-09-30 03:07:21 +00:00
Miguel Ángel efd3c51ac9 fix(studio): undo puts a resized, rotated or moved element back on screen (#4740)
* fix(studio): undo puts a resized, rotated or moved element back on screen

* fix(studio): undo no longer drifts an element with a Studio offset and a GSAP tween

A soft reload killed the old timeline but kept its children, and the runtime seeks that captured timeline until the rebind swaps it. The old tween painted its transform after the authored-transform restore, and the new tween folded it into its start. Clear the timeline before killing it. The restore also covers SVG elements now, and the save path has a test.
2026-09-30 02:18:34 +00:00
Miguel Ángel 7a1869d921 fix(studio): a timeline scrubs when its host omits sessionEpoch (#4742)
* fix(studio): a timeline scrubs when its host omits sessionEpoch

* fix(studio): drop the added comment for the comment ratchet
2026-09-30 02:02:46 +00:00
Miguel Ángel daa44fcd75 chore: release v0.8.94 (#4744) v0.8.94 2026-09-29 17:41:30 -07:00
Miguel Ángel 5470d5dea1 fix(studio): selecting words while editing text keeps the edit open (#4738) 2026-09-29 17:21:04 -07:00
Miguel Ángel 3e4ccd9567 fix(studio): a corner drag on a small element resizes it instead of starting a crop (#4730)
* fix(studio): a corner drag on a small element resizes it instead of starting a crop

* refactor(studio): one crop gesture ending, edge layout in one helper
2026-09-29 17:20:49 -07:00
Miguel Ángel ac754b25d0 feat(studio): a host can turn off body drags and keep the resize, rotate and crop handles (#4732)
* feat(studio): a host can turn off body drags and keep the resize, rotate and crop handles

* fix(studio): drop comments the names already say

* fix(studio): a refused group body press still swallows its trailing click

* fix(studio): a refused body press reaches the host untouched

* test(studio): a refused group press sets no cursor

* fix(studio): body drag turns off only for a host that takes the canvas input

* docs(studio): say allowBodyDrag takes effect with the host canvas input

* docs(studio): keep the Enter note on the canvas input doc

* docs(studio): shorten the canvas input doc line
2026-09-29 17:20:35 -07:00
Miguel Ángel 26db307016 feat(studio): let a host hide timeline history, place the shortcuts button and set track padding (#4739)
* feat(studio): let a host hide timeline history, place the shortcuts button and set track padding

* fix(studio): clamp host track padding and pin every padding reader with mounted tests
2026-09-29 17:19:47 -07:00
Miguel Ángel d2851aa304 fix(studio): resizing a faded or moving element writes a plain size, not keyframes (#4737)
* fix(studio): resizing a faded or moving element writes a plain size, not keyframes

* test(studio): the fade resize test catches every keyframe mutation
2026-09-29 22:07:07 +00:00
bd873ec35c fix(core): namespace SVG ids during composition inline to prevent cross-scene collisions (#3494)
* fix(core): namespace SVG ids during composition inline to prevent cross-scene collisions

Two nested compositions that each declare their own SVG ids (`<clipPath
id="clip">`, `<symbol id="shape">`, `<filter id="fx">`) are legal per
file and pass `hyperframes check`, but collide once both are inlined
into one render/preview document. `url(#id)` funcrefs (`clip-path`,
`filter`, `mask`, `fill`, `stroke`, `marker-start/mid/end`) and
fragment `href`/`xlink:href` refs (`<use href="#id">`) are resolved by
the browser's native SVG/CSS engine, which always binds to the first
matching id in document order — so the later scene either clips to
nothing or paints the earlier scene's content.

`getElementById` was already scoped per composition in #646, and media
pipeline ids got a parallel `data-hf-render-id` attribute in #3340.
Neither covers this: native `url(#id)`/`href="#id"` resolution can't be
intercepted by a JS proxy, so the `id` attribute itself has to become
document-unique.

Add `svgIdNamespacing.ts`: during `inlineSubCompositions`, every id
declared on an `<svg>`-subtree element is prefixed with the
composition's document-unique runtime id, and every same-document
reference to it — DOM attributes (`href`, `xlink:href`, any `url(#id)`
value including inside `style`) and the composition's own extracted
`<style>` text (both `#id` selectors and `url(#id)` declaration
values) — is rewritten to match. Renamed elements keep their original
id on `data-hf-authored-id`, the same attribute #646 already added for
the composition root, so an inline script's own
`document.getElementById(originalId)` keeps resolving via the existing
`__hfGetElementById` scoping shim.

Extract `selectorIdTokens.ts` from `compositionScoping.ts`'s existing
single-id selector scan so the new many-id rewrite reuses the same
guarded-region logic instead of a second copy.

Closes #3490

Co-Authored-By: Miga <noreply@anthropic.com>

* fix(core): only namespace SVG ids that have native url/href references

SVG ids referenced exclusively by JavaScript (e.g. GSAP's
`tl.to("#cut-1")`) must not be renamed — global libraries access
`document` directly and bypass the composition-scoped querySelector
Proxy, so renamed ids break animation targeting.

Now namespaceSvgIds pre-scans for url(#id) funcrefs and href="#id"
fragment refs (both attribute values and <style> text content) and
only renames ids that appear in at least one native reference. Ids
with no native reference (only used by JS) keep their original
names.

Fixes style-17-prod regression where GSAP selectors targeted
#cut-1/#tear-path-1 etc. which were unreachable after rename.

Co-Authored-By: miga-heygen <miguel.sierra_miga@heygen.com>

* fix(core): decode CSS identifier escapes when rewriting id selectors

`replaceSelectorIdTokens` compared the raw text after `#` against the
candidate ids, so an escaped selector such as `#fx\.1` never matched the
element whose id attribute is `fx.1`, and callers spliced in unescaped
replacements that were invalid CSS for ids containing `.`, `:` or a
leading digit.

The scanner now reads the token as a CSS identifier per CSS Syntax
Level 3 (`\.`-style and `\HEX `-style escapes, U+FFFD for out-of-range
code points) before comparing, and exports a spec-faithful
`escapeCssIdentifier` (the `CSS.escape` algorithm) for emitting
replacements. Consuming the whole identifier also makes the token
boundary check exact for non-ASCII and escaped name characters.

Also removes the ambiguous `[^\]]` branch from the guarded-segment
regex so a bracket body cannot match both the quoted and bare
alternatives (flagged as potential exponential backtracking).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(core): rename only colliding SVG ids and keep authored selectors resolving

Renaming every natively referenced SVG id broke author code even in a
single composition: with `<path id="shape"/><use href="#shape"/>`,
`svg.querySelector("#shape")` returned null because the id had been
prefixed although nothing collided. The getElementById shim did not
cover Element selector APIs or GSAP selector arrays, and escaped
stylesheet selectors (`#fx\.1` for `id="fx.1"`) stopped matching after
the rename.

Namespacing now runs once per assembled document, after every instance
is inlined, and is collision-driven: an id is renamed only when the
merged document would otherwise carry it more than once. One colliding
element keeps the authored id - the first in document order that cannot
be renamed (top-level document content, or a JS-only id), else the
first in document order - so native `url(#id)`/`href="#id"` resolution
keeps binding to it, and every other renamable duplicate gets a
document-unique id with its references rewritten. Ids that never
collide, including every single-composition project, are byte-for-byte
unchanged. The demand-driven gate is kept: JS-only ids are never
renamed, and the residual cross-instance collision for that class is
documented in `collectNativelyReferencedIds`. Nested hosts are excluded
from their parent's scope, minted ids are checked against every id in
the document, and an existing data-hf-authored-id is never overwritten.

The composition script runtime keeps `#authoredId` selectors resolving
for renamed elements by rewriting the token to
`:is(#authoredId, [data-hf-authored-id="authoredId"])` - a superset, so
the same selector still matches an untouched instance:
- scoped `document.querySelector/querySelectorAll` and the GSAP proxy
  (`gsap.to`, `timeline.to`, `gsap.utils.toArray/selector`);
- GSAP array targets, whose string entries now resolve through the same
  scoped lookup instead of the global document;
- `Element.prototype.querySelector/querySelectorAll`, patched once per
  document and only when at least one id was actually renamed, so a
  document without collisions runs untouched natives. The renamed-id
  set is discovered from `[data-hf-authored-id][id]` in the compiled
  DOM, so no extra plumbing between compiler and runtime is needed.
`Document.prototype` is deliberately left alone: an unscoped
document-wide lookup by a third-party library cannot know which
instance it means and resolves to the first in document order, exactly
as before. `matches`/`closest` and selectors built at runtime with hex
escapes are likewise outside the shim.

Stylesheet rewriting matches ids through CSS identifier escapes and
emits escaped replacements, so `#fx\.1 { ... }` becomes
`#scene-b--fx\.1 { ... }` and `url(#fx.1)` becomes `url(#scene-b--fx.1)`.

Tests: unit coverage for the collision/keeper rules, exclusion, id
minting, escaped selectors and hex escapes; end-to-end regressions that
execute the compiled scoped scripts in a real DOM for (i) a single
composition with native and script references to the same element,
(ii) two colliding compositions where native refs and every script
lookup form resolve to their own element, and (iii) an escaped id that
is styled and natively referenced keeping its rule after rename.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(core): stop importing htmlBundler from the inline sub-composition suite

`inlineSubCompositions.test.ts` imported `assignBundledRuntimeCompositionIds`
from `./htmlBundler` only to give duplicate hosts distinct runtime ids.
`htmlBundler.ts` statically imports esbuild, and esbuild refuses to load
inside a jsdom test realm on Windows ("new TextEncoder().encode('')
instanceof Uint8Array is incorrectly false"), which made the whole suite
fail to load in the Windows test job while every other suite passed.

The test now carries a small local mirror of the bundler's assignment
(`<id>__hf<n>` for hosts whose authored id repeats), so the suite has no
esbuild dependency. Test behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Miga <noreply@anthropic.com>
Co-authored-by: James <james.russo@heygen.com>
2026-09-29 21:18:50 +00:00
Val 3631ee3da0 fix(producer): keep clip playback rate in distributed video metadata (#4736)
parsePlanVideosJson rebuilt each video from a fixed field list that had no
playbackRate, so chunk workers indexed extracted frames at rate 1 while the
planner mixed audio at the authored rate. A data-playback-rate="1.5" clip
showed its picture at 1x in planV2/renderChunkV2/assembleV2 output, while the
single-process render was correct.

Carry playbackRate through the parser, accepting a finite number or a rate
lane, and pin it with a JSON round-trip test that checks the chunk-side frame
lookup for 1.5x, 0.5x and a rate lane.
2026-09-29 20:30:04 +00:00
Miguel Ángel 2eeaefb3e5 chore: release v0.8.93 (#4733) v0.8.93 2026-09-29 13:14:35 -07:00
Miguel Ángel 5a77dbcea1 perf(core): the preview loads only the media near the playhead (#4682)
* perf(core): the preview loads only the media on screen or due next

Studios preview bound every timed video and audio at preload=auto and called

* perf(core): a far clip in the preview stays unloaded until it comes due

A trimmed clip outside the window now waits at preload=none, reloaded so a fetch
the parser started stops too; at metadata, 60 clips' header reads still queued
on the origin under a slow link. An untrimmed clip keeps metadata, since its
window is read from the element's duration.

* fix(core): a far preview clip makes no request before it comes due

Chromium keeps fetching a clip once it has started, whatever preload says after,
so the reload at preload=none from the bind only restarted the fetch. In Studio's
preview the runtime now sets preload=none on each trimmed clip after the first
frame as it is parsed, and reloads only a clip the window armed when it leaves.

* perf(core): a playing clip loads the next clip on its track

At 3 MB/s a cold 1080p clip needed about 3.3 s to show a frame, longer than the
2 s look-ahead, so every clip on a one-track film was still empty when it came
due. While the film plays, a clip that starts now arms the next clip on the same
track (authored track index within one composition host, read as the timeline
payload reads it), and the window pass keeps that clip armed. Paused or
scrubbing, only the look-ahead applies.

* chore(core): say in one line why the preview parses later clips at preload none

* fix(core): the Studio timeline stops waiting on clips the preview left unloaded

Studio's timeline readiness scans every media element for canplay, so each far
clip the preview deferred held it until the 8 s timeout. In a document with the
Studio preview mark, a clip the runtime has not set to buffer now counts only
if it is on the first frame, as a lazy image already does.

* fix(core): a clip leaving the preview window closes its fetch on a slow link

Chromium keeps a fetch in flight when preload drops to none and load() runs, so
on a 3 MB/s link each played clip kept its connection and the armed next clip
queued behind them with no bytes. The release now drops src around the reload
and puts the same attribute back, which ends the fetch without a new one.

* refactor(core): name the authored-length check for what it reads

* fix(player): a clip the preview stops preloading drops its parent-frame copy

The player adopts each iframe clip whose preload turns auto into a parent-frame
proxy that fetches the whole file, but never let go when the runtime set the
clip back to none. On a 3 MB/s link those proxies and the thumbnail reads held
all six connections, so the armed next clip got no bytes. A demoted clip now
releases its proxy through the same path a removed clip uses.

* chore(player): keep the proxy adoption note in one place

* perf(studio): pause timeline thumbnails while playing preview media waits

A playing preview clip that fires waiting now holds every thumbnail job and
aborts the ones in flight until the transport stops; they re-run afterwards.
An aborted job frees its slot at once, and a filmstrip decode closes its
range requests on abort.

* Revert "perf(studio): pause timeline thumbnails while playing preview media waits"

This reverts commit 36926ebf22.

* fix(core): preview keeps loop-start clips loaded and drops arms a jump leaves behind

Studio's loop tick now tells the runtime where a wrap lands (window.__hf.setLoopStart), and
while playing the preview keeps the clips due there loaded, so a wrap no longer starts cold.
A next-on-track arm lasts only while the clip that set it plays on screen, so a jump away
releases it. A clip with no authored length stays out of the window and loads as in a
render, so no reload drops its duration. A clip is held until 4 s ahead after it is armed
at 2 s, so a scrub or a pause at the edge does not refetch it. The parse-time watcher is
installed only in the preview, once per page. One helper stops a clip's download, <source>
children included, for released and detached media alike.

* fix(core): a loop wrap also keeps the loop-in clip's next clip loaded

The loop start's clips are held out to the 4 s release distance, as a clip being left is, so
the wrap no longer reloads the clip after the loop-in one from nothing.

* fix(core): the preview leaves clips on <source> children out of its release

Putting a clip's <source> children back after the reload started a new download and moved them
ahead of its other children, which a scene swap reads as an edit. Release now stops only a src
clip's fetch; detached media is dropped as on main. Tests cover the loop hold's playing check,
its time check, and the untrimmed guard on the next-clip arm.

* style(core): drop a release comment the commit message already carries

* fix(core): arm a nested clip timed in root seconds by its real start

* fix(core): keep a clip loaded for a step back across its end in the preview
2026-09-29 18:44:13 +00:00
Miguel Ángel 6d00d94b46 chore: release v0.8.92 (#4729) v0.8.92 2026-09-29 15:47:29 +00:00
Miguel Ángel 65d66cb2c3 fix(studio): a clip dropped on a row stays on that row at the nearest free time (#4663)
* fix(studio): keep a dropped clip on the row it was dropped on

* fix(studio): keep group moves, new-track file drops and audio drops where they were aimed

* fix(studio): drop assets on the row's own track and keep locked clips as obstacles

* fix(studio): keep multi-file audio drops together and stop locked clips sliding in the drag preview

* fix(studio): read a dropped asset's track from every clip of the target file

* refactor(studio): move the drag preview and shell wiring out of the timeline provider state

* docs(studio): drag floor comments name the host start, not 0

* docs(studio): shorten the drag commit and landing comments

* docs(studio): the drag commit comment says a new track renumbers rows

* fix(studio): a clip released on its own spot stays there

A clip whose neighbours end off the centisecond grid no longer jumps to the end of its row
when dropped back where it was; the snap guide clears when the row moves the clip.

* fix(studio): a clip keeps its own spot when float sums overshoot a neighbour

0.333 + 1.733 is 2.0660000000000003, so the own-spot check allows a hair of slack at both ends.
2026-09-29 09:14:11 +00:00
Miguel Ángel 481dae1ed9 fix: render names a folder composition source instead of crashing (#4613)
* fix(producer): render names a data-composition-src that points at a folder instead of crashing

* fix(lint): name a data-composition-src that points at a folder instead of crashing

* fix(cli): compositions, keyframes and Studio insertion survive a folder composition source

* fix(lint): tell a folder composition source to point at the HTML file inside it

* fix: read a composition source through one file handle so its type check and read cannot race

* fix(parsers): report a named pipe as a composition source instead of blocking on it

* revert(parsers): open composition sources read-only again, which CodeQL reads as no file creation

* fix(parsers): open composition sources with a private mode, skip pipes, and treat bad paths as missing
2026-09-29 07:58:17 +00:00
Miguel Ángel c5a69727ff fix(core): bundling and rendering keep print-only, titled and non-CSS styles conditional (#4664)
* fix(core): keep print-only and non-CSS head styles apart when bundling

* fix(core): inline each linked stylesheet where its link was, keeping its media

* fix(core): merge a titled head style only with styles of the same title

A style with a non-empty title belongs to a style sheet set; the browser
enables only the preferred set, so merging it into an untitled
(persistent) style or one with another title changes what applies.
An empty title is persistent, like no title; titles compare
case-sensitively. An inlined linked sheet carries its link's title.
The merge key returns undefined for a non-CSS type (never merged or
inlined) and treats media="all" as no media, as the browser does.

* fix(core): leave a disabled stylesheet link as a link instead of inlining it

* fix(producer): render keeps conditional head styles apart, as the preview does

* test(core): pin case and padding in head style merge keys, drop a subsumed case

* fix(core): keep a composition style's media, and leave its non-CSS styles out of CSS

* fix(core): emit a conditional composition style as its own element, keeping media and title

* test(core): make the non-CSS composition style fixture able to fail

* fix(core): a composition's link keeps its own media and title when another links the same file

Hoisted composition links are deduped on href, rel, condition, type and disabled
rather than href alone, in the bundle, the render and the live mount, and keep
their type and disabled state.

* fix(core): the bundle keeps a font's only applied copy when an alternate style repeats it

The font dedupe asks cssStyleMergeKey whether a style always applies, so a titled
style no longer counts as always applied.

* fix(core): the bundle inlines a linked sheet whose type names a charset

Chrome applies <link type="text/css; charset=utf-8">, so a link's type is read by
its MIME essence; a style's type still has to be exactly text/css. Adds a test for a
template with no matching root and a non-CSS style.

* style(core): drop a comment the constant's name already says

* fix(core): hoisted links dedupe on CORS mode, integrity and referrer policy, not noscript copies

One helper, hasSameLink, decides whether a hoisted link duplicates a live one, for the
bundle, the render and the mount. inlineSubCompositions no longer dedupes its result on
its own. Tests cover rel, disabled, non-CSS type, CORS, integrity, referrer policy and a
root noscript link.

* fix(core): a root link and a composition's same link differing only in fetch attributes ship once

The link key goes back to href, rel, condition and disabled; crossorigin, integrity and
referrerpolicy are not compared, so a root snippet repeated in a composition is emitted
once and the root's override styles keep their order.
2026-09-29 07:27:33 +00:00
Miguel Ángel ca714f10b5 fix(studio): with keyframes hidden, a keyframed clip no longer opens the track-name column (#4725)
* fix(studio): with keyframes hidden, a keyframed clip no longer opens the track-name column

* style(studio): format the audio-group column test

* test(studio): the ruler origin is 80 px when hidden keyframes open no column
2026-09-29 06:54:58 +00:00
Miguel Ángel dcff54749c chore: release v0.8.91 (#4726) v0.8.91 2026-09-29 06:33:17 +00:00
Miguel Ángel 3c771120c9 fix(studio): the timeline follows a paused playhead and zoom keeps it in place (#4712)
* fix(studio): the timeline follows a paused playhead and zoom keeps the playhead in place

* test(studio): pin a paused on-screen seek past the playback follow line

* style(studio): format the follow change and trim its comments

* fix(studio): only a person's zoom anchors on the playhead, not a pin or a resize

* test(studio): pin that a toolbar zoom is counted as a person's zoom

* fix(studio): keep a paused timeline scroll when the preview reloads after a keyboard pause

* fix(studio): publish the paused time so a reverse shuttle stop keeps the timeline scroll

* fix(studio): follow a paused playhead only on a seek, not when a reload republishes the time

* fix(studio): keep a reloaded preview's hand-over from counting as a person's seek

* fix(studio): keep a paused timeline still during a beat drag and after a preset audition

* fix(studio): keep a paused timeline where a person put it while they trim a clip
2026-09-29 06:31:46 +00:00