4964 Commits
Author SHA1 Message Date
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
Miguel Ángel b171ee5716 feat(studio): the timeline publishes where it draws 0 s, so a host can map a click to a time (#4724)
* feat(studio): the timeline publishes where it draws 0 s, so a host can map a click to a time

* style(studio): keep the provider types' comment share at its base
2026-09-29 05:47:11 +00:00
Miguel Ángel 3dc3a9ca1a fix(studio): clips inside nested compositions sit at their master time (#4681)
* fix(studio): clips inside nested compositions sit at their master time

* fix(studio): nested clip starts read authored scene lengths as the runtime does

* test(studio): drop a reference-start case the DOM fallback never reads

* test(studio): a clip inside a referenced scene sits after its authored length

* fix(studio): drag and trim a nested clip to where it is drawn

* fix(studio): keep a root-time nested video's drag in master time

* fix(studio): duplicate and keyframe edits on a nested clip use its own clock

* fix(studio): nested clip drops stop at the host start and trims scale on its clock

* fix(studio): a nested clip's head trim stops at its host's start

* fix(studio): a dropped nested clip stops at its host's start before snapping
2026-09-29 04:28:20 +00:00
Miguel Ángel 5606b31020 fix(studio): moving one word moves only that word, not every word sharing its animation (#4705)
* fix(studio): move only the dragged word, not every word sharing its tween

A drag on one element whose position tween also targets its siblings (a stagger on a
shared class) now persists that element's own offset instead of rewriting the shared
tween. The motion-path overlay leaves such a tween read-only, and every write of one
drag records as one undo step.

* fix(studio): keep the comment share flat in the word-move fix

* fix(studio): save a shared-tween word's move as left/top on that word

A CSS translate offset on an element a GSAP tween still drives gets folded into GSAP's
transform, so render, preview and a second drag disagreed. left/top is never parsed by
GSAP, adds to any offset the word already has, and is refused when it would not shift
the box exactly. The resize anchor move takes the same channel, and tweens that miss the
element or are shared with siblings no longer steer a drag that has its own tween.

* refactor(studio): drop the untested own-tween selector override in the drag

* fix(studio): keep a Layers-picked word's move and make a resize one undo step

A word picked in the Layers panel is lifted to position:relative for display only; the
move now reads the authored position, saves position:relative, and survives deselect.
An anchored resize saves its move after the size, under the same undo key, and drops it
when the size save fails. A percent left or top gets its own refusal message.

* fix(studio): give the host canvas hook the per-word left/top move

The host geometry hook now stages a shared-tween word's move with the same left/top
offset as the editor, saved through the host's patch path, so a Desktop drag of one
word moves only that word.

* fix(studio): keep host word moves saving after one failed save

The host canvas hook reset its save queue per save, as the host style hook does, so one
conflict no longer pauses every later word move. A save error the patch path already
showed is no longer covered by a generic failure toast.
2026-09-29 03:58:28 +00:00
Karan Jagtiani c6b5edc967 docs(adopters): add Skyflo (#4655) 2026-09-29 03:54:07 +00:00
heygen-rui-bot f361ee4bc4 codegen: resync cloud client from EF d1d64f05 (#4716) 2026-09-28 23:44:41 -04:00
Miguel Ángel e4fbc84874 chore: release v0.8.90 (#4721) v0.8.90 2026-09-28 19:29:59 -07:00
Miguel Ángel 1bede4742a perf(studio): clicking an element in the preview selects it without restyling the whole film (#4707)
* perf(studio): clicking an element in the preview selects it without restyling the whole film

Every hit test added a `* { pointer-events: auto !important }` style element to the
preview document and removed it again. Each insert and removal restyled every element,
and the head mutation made the off-canvas indicators rebuild their layer walk from
scratch. The override only let pointer-events:none elements into the hit list, and the
author pointer-events filter removed them again, so the hit test now uses the browser's
own result: the candidates are what Chrome itself hit-tests.

* docs(studio): the double-press hit test comment no longer claims it lifts pointer suppression
2026-09-29 02:04:09 +00:00
Miguel Ángel 9e1dbca7a2 fix(studio): undo and redo update the preview in place instead of reloading it (#4718)
* fix(studio): undo and redo swap the restored scene in place instead of reloading

* fix(studio-server): a preview asked for right after an undo shows the undone state

* fix(studio): an undo that restores a page outside every scene still reloads in full

* fix(studio-server): a preview built across a write is not cached under the old signature

* test(studio): a soft undo keeps the scene hashes so the next edit still swaps
2026-09-29 02:03:56 +00:00
Miguel Ángel ea4893602a chore: release v0.8.89 (#4720) v0.8.89 2026-09-28 19:10:32 -07:00
Miguel Ángel 001d001c58 feat(studio): a host can save canvas moves, resizes and rotations with the handles (#4709)
* feat(studio): a host can save canvas moves, resizes and rotations with the handles

* fix(studio): the canvas overlay accepts commits that resolve to an outcome

* fix(studio): host resizes undo on a failed save and refuse without a timeline session

* style(studio): wrap long lines and trim a comment

* fix(studio): host geometry saves refuse until the preview has loaded
2026-09-29 01:57:44 +00:00
Miguel Ángel 2195db5e1d fix(media-use): never replace a voice, music or sound file the person put in the project (#4665)
* fix(media-use): record agent-made voice, music and sound effects in the project's media manifest

* fix(media-use): record in place only when --source says the agent made the file

* test(media-use): a rewritten agent file keeps one manifest record

* fix(media-use): leave a person's own sound effect unlabelled and mark locally made music generated

* refactor(media-use): record agent audio in-process through the shared manifest library

* style(media-use): format the in-process recording and refresh the skills manifest

* chore(skills): refresh the skills manifest for the media-use changes

* fix(media-use): report a broken ffprobe setting plainly and document the project sfx source

* style(skills): format the pipeline manifest assertion

* chore(skills): refresh the skills manifest

* fix(media-use): never replace a voice, music or sound file the person put in the project

* style(media-use): format the keep-person-files change

* chore(skills): refresh the skills manifest

* fix(media-use): a regenerated file gets a fresh record, and lookups skip the replaced one

* test(media-use): a replaced record no longer answers a prompt lookup in the CLI

* chore(skills): refresh the skills manifest

* chore(skills): refresh the skills manifest from a clean tree

* fix(media-use): never give a recorded file the id of a download in flight

* chore(skills): refresh the media-use manifest hash

* fix(media-use): say when audio lands on a new name, and never let two lines share one

* style(media-use): format the keep-files change

* chore(skills): refresh the media-use manifest hash

* fix(media-use): keep two sound effects off one file when a name is taken

* refactor(media-use): drop a set the one-at-a-time downloads never needed

* chore(skills): refresh the media-use manifest hash

* fix(media-use): keep two sound effects off one recorded file on a rerun

* style(media-use): format the rerun test

* chore(skills): refresh the media-use manifest hash

* fix(media-use): list only a file's newest record in candidates and the media index

* revert(media-use): leave promote as it was, nothing calls it

* test(media-use): give the index test's two assets their own files

* chore(skills): refresh the media-use manifest hash

* chore(skills): regenerate the manifest after merging the base branch

* fix(core): a figma import lists each media file once, like media-use
2026-09-29 01:45:34 +00:00
Miguel Ángel 343cf381f7 chore: release v0.8.88 (#4719) v0.8.88 2026-09-28 18:33:19 -07:00
Miguel Ángel 6b59004de2 feat(studio): one timeline tool picker with select leftward and rightward (#4714)
* feat(studio): one timeline tool picker with select leftward and rightward

The Selection and Razor buttons become one button showing the active tool, with a menu:
Select (V), Split (B), Select leftward ([) and Select rightward (]). Leftward and rightward
select every clip, on every track, that starts before or at/after the playhead.

* test(studio): toolbar order and label follow the tool picker

* fix(studio): select leftward and rightward use the live playhead and the usual selection resets

* style(studio): keep playerStore's comment share at its base
2026-09-29 01:21:14 +00:00
Miguel Ángel 00a907d6b3 feat(studio): a host can hide the timeline's keyframe lanes and diamonds (#4715)
* feat(studio): a host can hide the timeline's keyframe lanes and diamonds

* refactor(studio): read the timeline's keyframe data through one gate

* fix(studio): hidden keyframes keep the track names in the timeline header

* test(studio): check diamonds on a collapsed clip and lanes on a keyframed one

* style(studio): format the keyframes switch test
2026-09-29 01:21:05 +00:00
Miguel Ángel 82ae73a031 chore: release v0.8.87 (#4717) v0.8.87 2026-09-28 18:02:10 -07:00
Miguel Ángel 663b02297d feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk (#4701)
* feat(cli): add hyperframes clean to find and remove what HyperFrames left on disk

`hyperframes clean` lists leftover render work dirs, debug renders, the history of projects
that no longer exist, idle video proxies and extracted video frames, with sizes, and removes
them. `--dry-run` removes nothing, `--json` is for agents, `--snapshots` also removes
snapshot folders (otherwise listed as reclaimable). It never touches outputs, sources or
anything a running render or preview still owns.

* docs(cli): say that clean does not search a project's subfolders

* fix(cli): clean test compares folder names on Windows and the skills manifest is regenerated

* fix(studio-server): the proxy cache sweep removes only files the transcoder named

It removed any idle .mp4 or .webm in .transcode-cache, so a user file placed there, or reached through
a symlinked cache folder, could go. Proxies are always <sha256>.<ext> and their temp files
.tmp-<uuid>-<proxy name>; nothing else is swept.

* style(producer): keep a renderDirOwner doc comment within 120 columns
2026-09-29 00:43:42 +00:00
Miguel Ángel cc792d4a8a feat(studio): export the canvas edit overlay so a host can mount its handles (#4708) 2026-09-29 00:43:32 +00:00
Miguel Ángel e07ea1fc70 perf(cli): load each command's code only when that command runs (#4656)
* perf(cli): load each command's code only when that command runs

* fix(cli): honest bench percentiles, and no unused registry entry in the split build

* fix(cli): the startup bench is a package script and stops on a failed run

* style(cli): format the bench's failed-run check

* fix(cli): a notice module that fails to load cannot fail the command
2026-09-29 00:07:28 +00:00
Miguel Ángel e25bc3a806 fix(media-use): record agent-made voice, music and sound effects in the media manifest (#4648)
* fix(media-use): record agent-made voice, music and sound effects in the project's media manifest

* fix(media-use): record in place only when --source says the agent made the file

* test(media-use): a rewritten agent file keeps one manifest record

* fix(media-use): leave a person's own sound effect unlabelled and mark locally made music generated

* refactor(media-use): record agent audio in-process through the shared manifest library

* style(media-use): format the in-process recording and refresh the skills manifest

* chore(skills): refresh the skills manifest for the media-use changes

* fix(media-use): report a broken ffprobe setting plainly and document the project sfx source

* style(skills): format the pipeline manifest assertion

* chore(skills): refresh the skills manifest

* fix(media-use): a regenerated file gets a fresh record, and lookups skip the replaced one

* test(media-use): a replaced record no longer answers a prompt lookup in the CLI

* chore(skills): refresh the skills manifest

* chore(skills): refresh the skills manifest from a clean tree

* fix(media-use): never give a recorded file the id of a download in flight

* chore(skills): refresh the media-use manifest hash

* fix(media-use): list only a file's newest record in candidates and the media index

* revert(media-use): leave promote as it was, nothing calls it

* test(media-use): give the index test's two assets their own files

* chore(skills): refresh the media-use manifest hash

* fix(core): a figma import lists each media file once, like media-use
2026-09-29 00:07:16 +00:00
Miguel Ángel 498643bea4 fix(studio-server): the server loads where sharp's native binary is missing (#4654)
* fix(studio-server): the server loads where sharp's native binary is missing

* fix(studio-server): a missing sharp warns once and is checked before the image is read
2026-09-28 23:48:24 +00:00
Miguel Ángel 381fb0fd9a fix(studio-server): undo keeps an outside change even when Studio's history claim arrives late (#4631)
* test(studio-server): a write landing between Studio's read and its patch survives undo

* test(studio-server): the outside write also survives a claim that arrives after receipts expire

* fix(studio-server): undo keeps an outside change even when Studio's history claim arrives late

* fix(studio-server): history forgets replaced bytes on delete and frees unused bytes past budget

Also recreates the history folder before storing replaced bytes, hears writes inside the queued start, and pins the agent-turn claim that arrives after the receipt expired.
2026-09-28 23:48:13 +00:00
Miguel Ángel 7e0711505a fix(studio): hide handles Studio would refuse to commit (#4666)
* fix(studio): hide handles Studio would refuse to commit

Studio showed move, resize and rotate handles on animated elements whose
motion it cannot write back (a helper or loop, a runtime-computed value, a
live tween with no matching source). The drag ran, the commit was refused
at release, the element snapped back and a toast explained why.

The commit's refusal rules now also run when a selection resolves:
- move uses the existing drag preflight, now exported
- rotation and resize rules are extracted into preflight functions that the
  commit paths call first, so both use the same decision code
- the session narrows canApplyManualOffset / Size / Rotation (with the
  refusal's message as reasonIfDisabled) in one place, and the chrome,
  nudge, group drag and design panel read that unchanged
- until an element's first check lands, its manual flags stay closed

A press on a box that cannot move now shows the reason at once instead of
after 4px of travel.

* refactor(studio): read the rotation preflight selector once and derive the scale flag

* fix(studio): make the handle check read what the commit reads, and never stall

- Match tweens to an element the same way everywhere: the animation fetch
  used by the handle check and by group moves now also matches through the
  live element, so an element a class tween animates keeps its handles.
- The handle check reads each file's parse once per edit version. A file with
  no tweens is an answer, not a cold parse to retry, so plain elements get
  their handles after one read and later selections in that file at once.
- A failed read is not remembered: the next selection asks again.
- While the check is still running a press stays silent, and only the primary
  button explains a blocked press. A group press with a blocked member now
  says why instead of doing nothing.

* fix(studio): check handles per project, keep answers on failed reads, refuse shared group tweens

- The handle check's parse cache now uses the same project and file key as
  the parse request, so switching projects never judges one project's file
  by another's parse.
- A failed re-read after a save or undo keeps the last good answer and asks
  again on the next selection, instead of closing the handles.
- A group move stays refused when a member's motion comes from one tween
  shared with other targets, as before: each member would write into the
  same tween. The group check and the group commit use the same rule; a
  single selection still edits the shared tween.
- The element-to-tween matcher moves to its own module so the move rule can
  use it without an import cycle.

* docs(studio): the group move rule comment says which tweens it excludes
2026-09-28 23:48:01 +00:00
Miguel Ángel 840f657028 feat(player): play and loop a range of a film with range-start and range-end (#4688)
* feat(player): play and loop a range of the film with range-start and range-end

The runtime takes a set-play-range control and stops on the range end frame itself.
The player wraps to range-start with loop, fires ended without it, and reports a
range cut to the film with rangeclamped. Video and __timelines players check it per tick.

* fix(player): hold a range on its last frame and let the runtime own range jumps

A runtime with the play-range capability jumps to range-start itself when a new range leaves
the playhead outside, so a stale state no longer ends the film; older runtimes keep the
player fallback, guarded until a state lands inside. Every range stop shows the last frame
before range-end; a paused playhead re-parks only when it falls outside the new range.

* fix(player): stop a range a check early and enforce its end on a video in a background tab

* test(player): share the range video setup

* fix(player): a slow frame no longer cuts a range short; a video without a range is unchanged

* fix(player): a range still stops early where video time moves once per frame

* refactor(player): cut the range look-ahead comment to one line

* fix(player): a per-frame video time rounded down still stops a range early

* test(player): pin no rounding tolerance at a film's own end

* test(studio): expect the play-range capability on control messages
2026-09-28 23:38:32 +00:00
Miguel Ángel dc61ce105b feat(studio): a host can hide the timeline's audio effects controls (#4704)
* feat(studio): a host can hide the timeline's audio effects controls

* test(studio): share the whole-timeline mount setup between timeline tests

* fix(studio): hidden audio effects also take the effect-rack shortcut off lane names

* test(studio): share the gain-automated group fixture

* test(studio): use the shared group fixture in every automated-group test

* fix(studio): a disabled group lane name does not light up on hover

* refactor(studio): keep the timeline provider and lanes under the file size limit

* refactor(studio): format the lanes row indexes within the size limit
2026-09-28 23:38:21 +00:00
Miguel Ángel 0af671c6df feat(studio): a host can save style edits on a selection through the Studio style commit (#4698)
* feat(studio): a host can save style edits on a selection through Studio's own style commit

* fix(studio): drop an import the moved writer no longer needs

* fix(studio): host style saves recover after a failure and never revert a later edit
2026-09-28 23:05:18 +00:00
Miguel Ángel a519927436 fix(studio): an escape cancels a preview marquee band and stops there (#4703)
* fix(studio): an escape cancels a preview marquee band and stops there

* fix(studio): name the band escape handler instead of commenting it

* test(studio): check the band is gone before the pointer is released
2026-09-28 22:24:32 +00:00
Miguel Ángel 0c2c4248e6 docs(skills): studio agents answer questions before editing and plan new films like the launch films (#4699)
* docs(skills): studio agents answer questions before editing and plan new films like the launch films

* docs(skills): format the new studio sections and regenerate the manifest

* docs(skills): realign the storyboard table and regenerate the manifest

* docs(skills): defer studio film planning to the owning guides and read polite requests as changes

* docs(skills): format the studio sections and regenerate the manifest

* docs(skills): route felt notes and new films in a project through the studio skill

* docs(skills): keep the interview's opening line and regenerate the manifest

* docs(skills): fit the new entry row to the table

* docs(skills): realign the entry table and regenerate the manifest

* docs(skills): keep only follow-on films in an existing project

* docs(skills): realign the studio tables and regenerate the manifest
2026-09-28 22:07:32 +00:00
Miguel Ángel 590c192498 feat(catalog): add 3D objects, cursors, snow, starfield and paper transitions (#4349)
* feat(catalog): shelve 3D objects and cursors in their own catalog sections

Carries the generator grouping rules from #4310 and #4308.

* feat(registry): add a starfield ambient overlay

From #4313.

* feat(registry): add a falling snow ambient overlay

From #4331.

* feat(registry): add a torn-paper-rip transition

From #4321.

* feat(registry): add a paper-collage-reveal transition

From #4322.

* feat(registry): add a curtain-reveal transition

From #4323.

* feat(registry): add 3D objects: product turntable, glass hero, exploded stack, extruded logo

From #4310.

* feat(registry): add eight cursor animations and the dart cursor

From #4308.

* fix(catalog): find items by everyday editor words and accept a bare search word

From #4311.

* chore(registry): remove the simulated-cursor component

The Cursors section's new pointer animations replace it (from #4308).

* fix(registry): take cursor-zoom-follow's screenshot from runtime variables only

The declared default is empty, so page markup never becomes an image URL.

Clears the code-scanning alert raised on #4308.

* docs(cli): show a catalog search example that finds items

"smoke" matches nothing in the catalog; "crossfade" finds fade-through.

* chore(ci): allow the simulated-cursor removal in the deletion guard

* fix(ci): skip tracked files missing from disk in the player CDN pin check

Catalog regeneration removes a deleted item's pages before the publish PR untracks them,

so the check crashed on every catalog deletion PR.

* chore(cli): drop comments that narrate catalog search history

* fix(registry): drag-drop keeps the family arrow and shows the drop slot only while hovering

* fix(registry): agent cursor labels its steps in an accent tag, no glow or sparkles, in 4.8 s

* fix(registry): annotate cursor draws a tapered marker stroke and clears the circled value

* fix(registry): show real file thumbnails and end the context menu demo sooner

* fix(registry): give marquee select real photos, a Finder-blue marquee and a tighter loop

* fix(registry): centre the text-select toolbar on the selection and tighten its timing

* fix(registry): selection cursors default to the macOS system blue

* style(registry): format the agent and annotate cursor sources

* fix(registry): dart-cursor stops covering the choice it makes and shows the export finishing

* fix(registry): frame the product turntable fully and light it with two studio softboxes

* fix(registry): touch-indicator taps like a real phone and the post it taps gets liked

* fix(registry): press-ripple confirms with a check and small click rings at the cursor

* fix(registry): exploded stack separates at once and marks each layer in blue as it is labelled

* fix(registry): extruded logo settles facing the camera, set in Geist Bold with a neutral rim

* fix(registry): magnetic cursor docks its toolbar on the selection and ends after the press

* style(registry): format the exploded stack source

* fix(registry): glyph trail becomes a tutorial cursor path with numbered steps on a settings screen

* fix(registry): make glass-hero a clear lens that shows the headline through it

* fix(registry): snow reads as real snowfall with bokeh flakes over a winter title card

* fix(registry): oversized cursor is a 3.5x arrow that clicks a real editor toggle on

* fix(registry): starfield reads as a drifting night sky over a real title card

* style(registry): format the glass hero source

* fix(registry): make the paper tear show a white fibrous edge and lift the torn sheet away

* fix(registry): curtain reveal draws velvet folds that gather onto a title card

* fix(registry): make every zoom-follow click change the screen and read at zoom

* fix(registry): paper collage pieces stay opaque, tear with deckled grain and lift away on an arc

* refactor(registry): drop dead code and correct stale comments in reworked items

* fix(registry): agent cursor scrubs cleanly, docs match press-ripple, fictional demo names

* docs(registry): catalog notes say oversized-cursor exits and how it differs from press-ripple

* docs(registry): plain lowercase comments in oversized-cursor and touch-indicator

* docs(registry): oversized-cursor names CLICK_AT as the click cue
2026-09-28 22:03:59 +00:00
Miguel Ángel f7207247de fix(studio): an escape that cancels a timeline band stops there (#4700)
* fix(studio): an escape that cancels a timeline band stops there

* test(studio): an escape that cancels a timeline band never reaches the host
2026-09-28 21:58:51 +00:00
Miguel Ángel f7ef18dca8 fix(producer): renders leave no captured frames behind when interrupted (#4694)
Ctrl+C during a render left its whole work dir of captured frames (and a
staging dir) in the renders folder. Puppeteer's default SIGINT handler kills
Chrome and exits the process before the render's own cancel path can remove
them. The engine keeps that default unless the host owns Ctrl+C; the CLI's
render cancellation scope claims it while a render runs, so the render aborts
and cleans up after itself.

A render killed outright (SIGKILL, crash) cannot clean up, so each work dir and
staging dir now records its owner process (pid, hostname, boot, and on Linux
the pid namespace), and the next render into the same folder removes
the ones whose owner has exited.

Cancelling a render while its browsers start no longer leaves anything
running that kept a batch render alive after Ctrl+C: the BeginFrame warm-up
loop, the probe browser and file server, or a capture browser whose launch
finished after the cancel (now closed before the worker settles, since
Puppeteer's exit hook skips every other live browser).
2026-09-28 21:58:06 +00:00
Miguel Ángel e0b940aa30 fix(core): a sound effect starts on its time instead of a frame late (#4696)
* fix(core): a sound effect starts on its time instead of a frame late

* style(core): format the early-start change

* test(core): a clip past its end is never started early

* fix(core): a seek stops a clip started early, and the lead never passes 40 ms of real time

* test(core): drive the early-start tests the way the runtime receives a seek and a speed

* fix(core): the early-start lead never passes 40 ms of film time at fast speeds

* fix(core): keep a sped-up sound effect inside strict sync when it starts early

* test(core): pin the early-start cap at fast speed
2026-09-28 21:53:17 +00:00
Miguel Ángel a0c72ad64a fix(studio): drilling in opens the composition's full project path (#4689)
* fix(studio): drilling in opens the composition's full project path

* fix(studio): keep the shared source-path helper as is and trim ./ only for the drill path

* fix(studio): a project folder named preview keeps its path
2026-09-28 21:04:10 +00:00
Miguel Ángel 303c8e922f perf(studio): swap an edited scene into the live preview instead of reloading (#4499)
* perf(studio): swap an edited scene into the live preview instead of reloading

On a preview reload, Studio fetches the rebuilt preview document and asks the preview to swap
in only the scenes that changed. When the preview cannot or will not swap (it predates the
swap, a full reload is already running, or the document changed outside its scenes), Studio
does the full shadow reload as before. A document from an older reload is never swapped in.

* fix(studio): re-sync selection and layers after a scene swap

A swap replaces scene DOM without a load event, so selection, manual
edits, the Layers panel and the edit callout kept pointing at the
replaced nodes. They now listen for either a load or a swap.

* fix(studio): drop a stale scene swap once the preview is replaced

A swap still pending when the preview reloaded or switched composition
could fall back to reloading the old one. Color grading and its compare
view are also re-sent after a swap.

* docs(studio): say exactly when the preview generation changes

* refactor(studio): cut scene swap comments to what names and types do not say

* fix(studio): fall back to a full reload when a scene swap download stalls

* fix(studio): give up on a scene swap that stalls at any step, not only the download

* fix(runtime): a scene swap refuses when an animation outside the scene moves its elements

* test(runtime): pin the scene swap volume re-probe, host CSS variables and diagnostics-free swap

* fix(runtime): a scene swap keeps a video its scene script animates instead of reloading it

* fix(runtime): a scene swap stops every animation the old scene script started

* fix(runtime): record scene script animations only on pages a scene swap can act on

* docs(core): reword the scene animation recording comment

* fix(runtime): watch media as the page parses only on pages a scene swap can act on

* perf(runtime): a scene swap checks for outside tweens in one pass over the film's tweens

* test(runtime): pin the single revert and the cleared animation record of a scene swap

* test(studio): pin that a playing film keeps playing through a scene swap

* fix(runtime): a scene swap rebuilds media when the edit changes the scene script

* fix(runtime): a scene swap undoes the sets a scene script made

* fix(runtime): a scene swap rechecks outside tweens and runs new scripts after every host

* test(runtime): pin that a tween started during the caption wait leaves the page untouched

* fix(runtime): a scene swap refuses when its script set a value on an element the swap keeps

* docs(core): say the scene recording drops a finished tween that the timeline would keep when paused

* fix(runtime): a scene swap refuses when two of its animations write to an element it keeps

* fix(runtime): a scene swap resets kept media and refuses animations outside the scene

* fix(runtime): a scene swap keeps a moved video in place and swaps tweens on plain objects

* chore(core): regenerate the inlined position-edits script for the kept-video move reapply

* fix(runtime): a scene swap checks the timeline registry as it is after the caption wait

* fix(runtime): a scene swap refuses when a scene changed during the wait or a revert re-registers

* fix(runtime): a scene swap stops every animation a revert chain registers and rechecks its hosts

* fix(runtime): a scene swap refuses a moved host or a cancelled swap and stops at a shortened end

* fix(studio): a scene swap that times out is cancelled before the reload starts

* fix(runtime): a scene swap refuses when a scene leaves its place or the film during the wait

* fix(studio): a newer edit cancels an older scene swap still waiting

* fix(runtime): a scene swap checks only its host's place, so a later body append does not refuse it

* fix(studio): a scene swap cancelled before it starts does not run

* fix(runtime): a scene swap refuses when the film's scene layout changed during the wait

* fix(runtime): a scene swap refuses when the order around a scene changed during the wait

* test(runtime): pin that a scene swap checks the film root order when every scene is wrapped

* fix(runtime): a scene swap gives kept media the mute, rate and loop a fresh load gives it

* fix(runtime): a scene swap rebuilds media a script wrote attributes on, keeping the rest

* fix(runtime): a scene swap keeps media carrying the runtime classes or reordered attributes

* fix(runtime): a scene swap resets a kept video's volume as a load does, not through the volume probe
2026-09-28 20:44:30 +00:00