Commit Graph
133 Commits
Author SHA1 Message Date
Miguel Ángel c6478e404c fix(studio): undo takes back the first edit of a film whose media is still being copied (#5043)
* fix(studio): undo takes back the first edit of a film whose media is still being copied

* fix(studio): the first undo of a film repaints the preview in place

* fix(studio): background media copies keep the media ledger and survive a history prune

* fix(studio): a busy project history never makes writes wait for its lock again

* refactor(studio): name the restore stamping instead of commenting it

* perf(studio): undo stamps only a restore side that has no stamps

* fix(studio): closing a project history finishes its media copy first

* fix(studio): media copies stay resumable until recorded and skip a replaced folder

* fix(studio): the history lock wait comes back after a successful open
2026-10-05 08:05:14 +00:00
Miguel Ángel b3d2b82fe9 feat(core): the renderer's font pipeline is importable from @hyperframes/core/fonts/embed (#5041)
* feat(core): the renderer's font pipeline is importable from @hyperframes/core/fonts/embed

* style(core): format the font pipeline and state the bundled-subset invariant

* test(producer): repin the test lane map after the font tests moved to core

* fix(core): images build fonts once, CLI bundles one pipeline, font tests pass on Windows

* test(core): font retry tests get a budget no slow machine can exhaust

* test(core): the Retry-After budget test fails when the budget is ignored
2026-10-05 04:54:53 +00:00
WaterrrForever 6a27a34f21 feat(cli,studio): hand off on macOS now, and open the app on Windows and Linux (#4999)
* feat(studio,cli): add an Edit with Framey button and `hyperframes open` for HyperFrames Studio

Point people from the CLI and the Studio preview to HyperFrames Studio, the desktop app.

- Studio header: an "Edit with Framey" button with Framey, the app's cursor mascot (the eye
  follows the pointer, a wiggle on hover, honours reduced motion). Until the app opens
  handed-over projects (HANDOFF_READY), a press shows a "Meet Framey" card with the download.
  The button shows only where the preview server answers GET /api/open-in-desktop on macOS,
  so the desktop app's own embedded Studio never shows it.
- `hyperframes open [dir]`: hands the project folder to the app with `open -b` (released app,
  then Canary) and points to the download when neither is installed. When run by Claude Code
  or Codex it leaves .hyperframes/agent-handoff.json (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID)
  so the app's first chat can pick up that conversation.
- render and preview print one line about the app: the download until HANDOFF_READY, then
  `hyperframes open` when the app is installed. Nothing for a draft render, a batch row, or a
  run inside the app (HYPERFRAMES_DESKTOP_PROJECT).

* fix(studio,cli): gate `hyperframes open` too, guard the open-in-desktop POST, keep one gate

Review of #4951 at 58934a34.

- One gate: HANDOFF_READY lives only in the CLI. `GET /api/open-in-desktop` now says
  `handoff`, and Studio's button reads it, so the gate cannot be half-flipped.
- While gated nothing opens and nothing is written: `openInDesktop` returns
  `handoff-unavailable` (so `hyperframes open` prints the download line and the POST route
  answers with it), and no `.hyperframes/agent-handoff.json` can sit waiting for a later release.
- The POST answers only Studio's own same-origin request (loopback Host, matching Origin,
  Sec-Fetch-Site same-origin); a cross-site or rebound-host POST gets 403. Routes moved to
  server/desktopRoutes.ts with their own tests.
- `hyperframes open`: exits 1 when nothing opened, answers in JSON under --json for a bad
  directory, and tells a missing app from one macOS could not open (`open-failed`). A project
  that cannot be written still opens, with nothing handed over.
- Tests drive the live path through `ready`, are path-portable for Windows, and cover the draft
  and batch-row hint skips (`wantsDesktopHint`, `batchRowRenderOptions`).
- The load smoke mocks the route.

* feat(cli,studio): hand off on macos now, and open the app on windows and linux

The released Mac app (b254) takes a handed-over folder and its conversation, so
HANDOFF_READY turns on for macOS. Windows and Linux stay on the download until the
desktop change that reads a folder from the command line ships.

- `hyperframes open` finds the app off macOS: the per-user install on Windows, and
  on Linux the AppImage the app records in launcher.json; it starts it with the folder
- download links follow the machine: the DMG on macOS, the AppImage on Linux, and
  nothing on Windows, which has no public download yet
- the Studio button shows wherever there is a download or an installed app; the card
  no longer says "for macOS"
- every render prints the app line except batch rows: music-to-video delivers a draft
- the success line names the app that took the folder (Canary included)

* fix(studio): the edit-with-framey button asks its route through studioApiFetch

* fix(cli,studio): name the app the hyperframes desktop app, apart from the studio preview

* fix(cli,studio): fewer comment lines, so no file's comment share rises
2026-10-04 12:54:17 +00:00
WaterrrForever bd0dc8c3ae feat(studio,cli): add an Edit with Framey button and hyperframes open for HyperFrames Studio (#4951)
* feat(studio,cli): add an Edit with Framey button and `hyperframes open` for HyperFrames Studio

Point people from the CLI and the Studio preview to HyperFrames Studio, the desktop app.

- Studio header: an "Edit with Framey" button with Framey, the app's cursor mascot (the eye
  follows the pointer, a wiggle on hover, honours reduced motion). Until the app opens
  handed-over projects (HANDOFF_READY), a press shows a "Meet Framey" card with the download.
  The button shows only where the preview server answers GET /api/open-in-desktop on macOS,
  so the desktop app's own embedded Studio never shows it.
- `hyperframes open [dir]`: hands the project folder to the app with `open -b` (released app,
  then Canary) and points to the download when neither is installed. When run by Claude Code
  or Codex it leaves .hyperframes/agent-handoff.json (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID)
  so the app's first chat can pick up that conversation.
- render and preview print one line about the app: the download until HANDOFF_READY, then
  `hyperframes open` when the app is installed. Nothing for a draft render, a batch row, or a
  run inside the app (HYPERFRAMES_DESKTOP_PROJECT).

* fix(studio,cli): gate `hyperframes open` too, guard the open-in-desktop POST, keep one gate

Review of #4951 at 58934a34.

- One gate: HANDOFF_READY lives only in the CLI. `GET /api/open-in-desktop` now says
  `handoff`, and Studio's button reads it, so the gate cannot be half-flipped.
- While gated nothing opens and nothing is written: `openInDesktop` returns
  `handoff-unavailable` (so `hyperframes open` prints the download line and the POST route
  answers with it), and no `.hyperframes/agent-handoff.json` can sit waiting for a later release.
- The POST answers only Studio's own same-origin request (loopback Host, matching Origin,
  Sec-Fetch-Site same-origin); a cross-site or rebound-host POST gets 403. Routes moved to
  server/desktopRoutes.ts with their own tests.
- `hyperframes open`: exits 1 when nothing opened, answers in JSON under --json for a bad
  directory, and tells a missing app from one macOS could not open (`open-failed`). A project
  that cannot be written still opens, with nothing handed over.
- Tests drive the live path through `ready`, are path-portable for Windows, and cover the draft
  and batch-row hint skips (`wantsDesktopHint`, `batchRowRenderOptions`).
- The load smoke mocks the route.

* fix(studio): the edit-with-framey button asks its route through studioApiFetch
2026-10-04 10:52:23 +00:00
Miguel Ángel ada4e0d289 fix(studio): show each timeline row its own layer instead of a flat bar (#4917)
* fix(studio): show each timeline row its own layer instead of a grey bar

A row got a frame only if it spanned under 92% of the film and its id did not
look like a backdrop; every other row drew a plain grey bar. Every element row
now gets its frame, and the capture fades the row element's siblings first, so
the frame shows that layer and not the whole film. Studio's dev server and the
CLI share one capture helper; Studio's own copy is gone.

* fix(studio): read timeline rows from any realm so each keeps its selector

* refactor(studio-server): always isolate the element in a thumbnail clip

* fix(cli): undo a thumbnail's isolation before its page takes the next capture

* chore(core): generate the dom-realm export and trim thumbnail clip comments

* fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys

* fix(studio-server): keep faded siblings in a page-owned list so restore is exact

* feat(studio): draw a text layer row as its own live text instead of a captured picture

* refactor(studio): move timeline text reading into its own module

* fix(studio): keep text rows readable and current, and leave painted layers as pictures

* fix(studio): refresh a canvas-edited text row by its own identity and source file

* fix(studio): refresh a canvas-edited text row known only by its data-hf-id
2026-10-03 00:22:49 +00:00
miga-heygenandMiguel Ángel fcc637068c fix(cli): honor an explicit --port for background preview reuse and launch (#3915)
* fix: honor explicit --port when reusing a background preview server

startBackgroundPreview never compared a reuse candidate's actual bound
port against the caller's explicitly requested --port before returning
it, so a second `preview --port <N>` call could silently return an
existing server on a different port instead of honoring the request.

Reuses the existing PreviewServerPortMismatchError (already used by
findPreviewServerForProject for the same discipline) instead of adding
a new error type: an explicit --port that doesn't match the reuse
candidate now throws a clear conflict rather than substituting the
wrong port silently. A bare launch with no --port keeps reusing any
project-matching server, unchanged.

Deliberately out of scope: the interactive/embedded launch path
(runEmbeddedMode -> findPortAndServe) has the same silent-substitution
shape when a same-project server is found mid-scan at a port other
than the one requested. That path's output isn't the JSON lifecycle
schema this bug was reported against, and fixing it would mean
touching the interactive dev-server bind/scan loop rather than a
simple pre-return check, so it's left as a follow-up candidate.

Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>

* fix: honor explicit --port on every background preview exit path

`startBackgroundPreview` already refused to reuse a same-project server on
the wrong port when --port was explicit, but the fresh-launch path still
trusted whatever port the detached child bound. The child scans upward from
--port and takes the first free port, so `--background --port N` with N busy
reported `{ type: "started", port: N+1 }` and recorded ownership of it.

- After the child comes up, compare its port with the explicit --port. On a
  mismatch, reap the wrapper, wait until the substitute stops answering, and
  throw `PreviewPortUnavailableError` (JSON code `preview-port-unavailable`).
  If the substitute never stops, fail loudly instead of reporting it.
- Share one `unmetPreferredPort` predicate between the reuse and launch
  paths, and one `awaitServerGone` wait between the reap path and
  `stopBackgroundPreview`.
- When several same-project servers are running, prefer the one on the
  explicit --port as the reuse candidate instead of the lowest port.
- Map launch failures to JSON codes in one place
  (`backgroundStartFailureCode`).

Tests: preferred port bound → reported as-is; preferred port taken → reaped
and rejected with both ports, no session record; substitute that keeps
serving → distinct error; bare launch keeps the next free port; explicit
port picks the matching sibling. Deleting the launch-path check, the
candidate ordering, or the post-kill wait each turns its test red.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(cli): reuse the same-project server on the explicit --port

`startBackgroundPreview` picked its reuse candidate before honouring
`--port`, so with an owned server on 3002 and an unmanaged same-project
server on 3003, `--port 3003` rejected with "No Studio preview server …
on port 3003. Matching server port: 3002" even though 3003 was running.

- A policy-matching same-project server already on the explicit port is
  now the reuse candidate, owned or not; the owned server stays
  authoritative otherwise, and an explicit GPU-policy change on the
  requested port still replaces it.
- `PreviewServerPortMismatchError` receives every policy-matching
  same-project server, so the ports it reports are the ones running.
- `policyMatchingServers` is the single owner of the same-project +
  GPU-policy predicate; `matchingServer` derives from it.
- `BackgroundPreviewResult` names the launch result union once;
  `PREVIEW_PORT_MISMATCH_CODE` replaces three literal error codes.

Tests: unmanaged sibling on the explicit port is reused without touching
the owned server; mismatch error lists every same-project port and no
foreign-project port; owned server on the explicit port is replaced on a
GPU-policy change. Each new check fails when its guard is removed.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(cli): keep the --port honour comments short

* fix(cli): drop the scan binding stopBackgroundPreview no longer reads

* refactor(cli): keep comment share at base in the preview lifecycle files

* fix(cli): reuse a same-project server on an explicit --port beyond the scan window

* test(cli): pin the JSON codes for a background start that misses --port

* refactor(cli): probe an out-of-window --port in its own helper

---------

Co-authored-by: Miguel Angel <miguel.sierra@heygen.com>
2026-10-02 18:26:05 +00:00
Vance IngallsandClaude Opus 5.5 9244c47328 feat(studio): normalize loudness and duck under voice, with a limiter report (4/8) (#4816)
* feat(studio-server): share loudness measurement with an absolute target and video support

normalize-audio moves its measure/plan code into @hyperframes/studio-server/loudness,
gains an absolute mode (--lufs, default -16, clamped at +12 dB and -1.5 dBTP) and
accepts <video> clips with sound. Adds the @hyperframes/core/audible-video subpath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio-server): add a measure-only loudness normalize endpoint

POST /projects/:id/loudness/normalize measures a clip's played window (audio or
video) and answers the -16 LUFS data-volume plan; Studio writes the attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: register the loudness and audible-video subpaths in their descriptors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: format the loudness and audible-video subpath descriptors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(studio): add Normalize loudness and Duck under voice to the clip menu

Normalize measures the clip on studio-server and writes data-volume as one undoable
edit; Duck toggles a carve against every overlapping voice (audio or video with
sound) at the default strength. Both live in clipMenuAudioItems with one hook-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(studio): tighten carveOutput docs to the package comment share

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(producer): report how far the true-peak limiter lowered the export

The AAC true-peak pass now returns its attenuation; the render logs "Audio lowered
by X.X dB to stay under -1 dBTP" and sets RenderJob.audioLoweredDb, which Studio
servers carry in the complete SSE event and the /renders record. Also exports
useAudioMetersVisible from @hyperframes/studio for hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio-server): scan HTML start tags without a backtracking regex

The start-tag regex backtracked quadratically on '<' followed by many spaces
(CodeQL js/polynomial-redos). A linear character scan reads the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): refuse to normalize a volume-automated clip and honour save outcomes

A volume lane owns the clip's gain in preview and render, so writing
data-volume changed nothing while the toast claimed success; Normalize now
refuses with a clear message. Normalize and Duck also check each quiet-save
outcome, stop at the first failed or refused write and report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(core): drop the duplicate audible-video subpath descriptor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:51:34 +00:00
Miguel Ángel c72b735a8c fix: a save's temp file no longer reaches Studio as an outside change (#4857)
* fix: a save's temp file no longer reaches Studio as an outside change

* refactor: the project file walk and its tests carry no new duplication or complexity

* test(studio): the edit accuracy gate judges the two nudge sequences again
2026-10-01 11:48:00 -07: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 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 26bfd38136 fix(studio-server): a save never recreates a project folder renamed away (#4602)
* fix(studio-server): a save never recreates a project folder renamed away

* style(cli): wrap imports after merging main

* fix(studio-server): proxy and upload answer that the project folder is gone instead of 502 or an empty 201

* test(studio-server): rename the project folder while an upload is being read

* fix(studio-server): a proxy or backup answers that a vanished project folder is gone

* test(studio-server): clean up the renamed folder after the proxy test

* refactor(studio-server): drop an unused re-export and share the folder-gone test setup

* fix(core): no folder outside a project is created once the project folder is gone

* test(studio-server): a refused render leaves no outside renders folder behind

* docs(core): wrap the mkdirWithinProject comment under the line limit

* test(studio-server): pass the project folder to the preview document store
2026-09-28 09:48:22 +00:00
Miguel Ángel 59e3850f6a fix(studio-server): a write through a link lands on the file the system resolves (#4589)
* fix(studio-server): a write through a link lands on the file the system resolves

* fix(cli): resolve catalog install paths as the system does, matching the install root

* fix(cli): list installed files relative to the real project folder
2026-09-27 12:30:17 +00:00
Miguel Ángel e00baefb5c fix(studio): reload the preview only when a file it loaded changes (#4570)
* fix(studio): reload the preview only when a file it loaded changes

* test(studio): read the owner id from the new change-owner shape

* refactor(studio): drop a comment the parser name already says

* fix(studio): keep reloading until the preview is built, and count what its document names

* style(studio): format the reference pattern and name the kept reload

* fix(studio-server): scan preview references in linear time and refresh all thumbnails on a merged reload

* fix(studio): keep a waiting film change when a file the preview never loaded arrives behind it

* test(studio): name the preview ownership test for what it checks

* chore(ci): allow the watchIgnore removals and trim the new comments

* fix(studio-server): reload the preview when a folder holding a file it loaded moves in or out

* perf(studio-server): check a changed folder in one lookup and cover every reference form

* fix(studio-server): record read folders for a project at the filesystem root

* fix(studio-server): reload the preview when the file a symlinked asset points at changes

* fix(studio-server): resolve symlinks on every preview read and key projects by their real folder

* fix(studio-server): record every link a preview read passes through, resolved as the system does
2026-09-27 10:05:53 +00:00
Miguel Ángel 4984a268f7 fix(cli): Studio's catalog install records the item and honours paths, as add does (#4573)
* fix(cli): Studio's catalog install goes through add, so it records the item and honours paths

* fix(cli): Studio's catalog lists the project's registry and install paths survive symlinks

* refactor(cli): name the Studio catalog's registry option plainly

* feat(cli): registry_item_added says whether add or Studio installed the item

* fix(cli): dropping the same catalog block in Studio twice installs it again

* chore(cli): drop a comment that restates add's source option

* fix(cli): Studio's re-record finds lock entries by their resolved path

* test(cli): the symlinked-folder install test checks the resize happened

* fix(cli): Studio mounts the installed item's own file by the path add recorded

* fix(cli): Studio's install never hands back a file the project kept for its own edit
2026-09-27 09:06:15 +00:00
James Russo 31c00d2f0e fix(studio): avoid preview reloads for temporary and excluded files (#4553)
* fix(studio): avoid preview reloads for temporary and excluded files

* docs: capture public preview reload reproduction

* fix(studio-server): normalize watch paths in linear time

* fix(studio): register dynamically opened preview projects
2026-09-27 02:23:25 +00:00
Miguel Ángel 7eb6430e4e fix(studio-server): history refuses once closed and reopens when a new project takes its folder (#4537) 2026-09-26 19:01:05 +00:00
James Russo edb3747c4c fix(telemetry): honor CLI opt-out in Studio and render CI (#4500)
* fix(telemetry): propagate CLI opt-out to Studio and render CI

* fix(ci): scope container opt-out to HyperFrames
2026-09-25 23:46:40 +00:00
Miguel Ángel 31441701fc fix(studio-server): one process at a time keeps a project's history open (#4448)
* fix(studio-server): one process at a time keeps a project's history open

* fix(studio-server): taking over a dead history owner never removes a live owner's lock

Two processes that both found the owner dead could each take the lock,
the second removing the first's. Eviction now happens under its own lock
with a re-read, a close removes the lock only while it names the closer,
and a lock file holding no pid counts as dead.

* fix(studio-server): a history lock that cannot be read fails the open instead of spinning
2026-09-25 11:52:46 +00:00
Miguel Ángel 11ac9bce69 fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files

* fix(studio-server): a GSAP save on a served id writes that id to the file too
2026-09-25 10:04:38 +00:00
Miguel Ángel 36f8bd0e91 feat(studio): undo and redo step through the project history, whoever made the change (#4456)
* feat(studio-server): an undo taken from Studio is not mistaken for an outside edit

* feat(studio): undo and redo step through the project history, whoever made the change

* feat(studio): the dev server keeps each project history, in the same folder as preview

* fix(studio-server): an undo that deletes a file reads as Studio's, and dev histories close

* fix(studio-server): a claim keeps an agent's earlier write the agent's and takes back Studio's own

* chore: allow the removal of Studio's browser edit history files

* test(studio): mock the project history in the runtime smoke

* fix(studio): step past a jammed undo and say why a step fails

* fix(studio-server): an agent turn cut by a claim commits its part before the claim

* fix(studio-server): a claim's cut ends a held drag and takes only the cut files out of a turn

* fix(studio-server): a held drag ends when someone else changed a file it already holds
2026-09-25 08:43:04 +00:00
Miguel Ángel f873734cc4 perf(studio): open a large film ready to play in about 4 s instead of 32 s (#4401)
* perf(studio): keep lint off the preview open path

The bundler linted every build only to print a StaticGuard warning; a preview bundle now
skips it through a staticGuard option, since Studio lints on its own route. That route now
lints in the CLI child-process worker the render path already uses, so seconds of parsing
no longer stall the preview and every other Studio request on the server event loop.

* perf(parsers): parse identical GSAP source once

Studio re-requests the parsed animations of unchanged source on every open, and several lint
rules parse the same script in one run. A small memo keyed by the script text serves repeats;
each caller gets its own copy.

* perf(studio): build each preview document once per content version

The preview route rebuilt the document for every browser without a cached copy. It now serves
one build per ETag from memory, and the CLI keeps the last build on disk salted with its own
file, so a restarted server serves the previous document without stale code.

* perf(studio): build the opening film's preview while the browser starts

The preview server built the document only when the player asked for it, after
Studio had booted, so a first open waited about a second of idle server time.
The CLI now requests the opening project's preview right after listening, and
requests that arrive while a build runs share it instead of starting another.

* perf(studio): request the preview document at page load

Studio asked for the preview only once the player mounted, about 640 ms into a
first open. It now requests it the moment the page loads, and again when the
hash names another project, so the build or cache read overlaps Studio's boot.

* feat(studio): show the cached frame 0 while the live preview boots

Studio covers the live preview with frame 0 as the thumbnail route last rendered it, sized
from the image so the stage does not shift, and removes it when the live frame is ready to
show. The request is cache-only, so a miss never starts a render on the open path; the
missing poster is rendered after the live frame shows, for the next open.

* perf(cli): start Studio before the startup lint finishes

hyperframes preview linted the whole project before it listened, 4 to 6 s on a large film, and
did so in --json mode where the result is discarded. The lint now runs in the CLI worker while
Studio starts and prints its summary when done; --json skips it.

* perf(studio): render thumbnails from one loaded page, after the preview boots

Every thumbnail opened a page and loaded the whole composition to shoot one frame, and the
Compositions card asked for one while the live preview was still booting. The server now keeps
one loaded page per preview document and seeks it per frame, reloading only when the project
changes; card thumbnails wait for the project first ready preview.

* perf(studio): parse GSAP animations after the preview boots

Studio asked the server to parse the composition animations while the preview was opening; on
a fresh server that cold parse ran on the event loop ahead of the preview build. The request
now waits for the live preview to boot, or to fail loading, since only keyframes need it.

* perf(cli): find orphaned browsers with one process-table read

preview ran pgrep three times and then ps once per matching browser, all
synchronously before listening: about half a second on a machine running
other headless Chrome. One ps listing now finds every orphan in ~20 ms.

* perf(studio): keep lint off the boot

Opening a project started two lint processes while Studio booted: the
terminal's startup lint and Studio's background lint, ~4 s of CPU next to the
boot. Studio's background lint now waits until the live preview has booted,
and lint processes run at the lowest CPU priority so they only take idle CPU.

* perf(studio): open the editing session after the preview boots

Opening a project read the composition source and parsed it for the editing
session (linkedom plus hf-id assignment) on the main thread while the live
preview was booting on the same thread: ~300 ms on a large film. The session
now opens once the preview has booted; nothing the first frame shows needs it.

* perf(studio): park the editor overlays until the preview boots

The overlay frame loop polled preview geometry and off-canvas indicators from
the first frame, forcing layout on a preview document that was still loading,
on the thread its boot runs on: ~200 ms before the first frame on a large film.
The loop now skips its subscribers until the preview has booted, then wakes.

* perf(studio): treat the preview as booted once it can play

Work deferred until the boot (editing session, lint, overlays, GSAP parse,
card thumbnails) started at timeline-ready, which fires before the asset gate,
so the editing-session parse ran inside the wait for play. The Player now
latches the boot when the preview can show and play, or fails to load.

* fix(studio): keep a left project's deferred work out of the one now open

Deferred open-time work (source parse, background lint, editing session) waited on one global
boot flag, so leaving a project before its preview booted let the next project's boot run the
old project's work and write its results. The wait is now per project and resolves false once
another project opens. A preview stuck without an error stops deferring that work after 5 s.

* perf(cli): let the play page revalidate instead of refetching

hyperframes play served the composition, its assets, the player and the runtime with no
validator, so every reload refetched everything, the mp4 in full three times. They now carry
an ETag and stay no-cache, so unchanged bytes cost a 304 and an edit is still seen.

* fix(cli): serve an edited text asset even when its size and mtime match

The play page tagged every asset by mtime and size, so a same-size rewrite within one
filesystem tick kept the old tag and the browser kept stale bytes. Text assets now get a
content-hash tag; media keeps the mtime and size tag Studio already uses.

* fix(studio): close idle thumbnail pages so an idle Studio runs no composition

Kept-loaded thumbnail pages stayed open for the life of the server, each running a live composition,
so an idle Studio cost server CPU and memory that main does not. A page now closes after 10 s unused,
and a page is closed only after the frame still running on it finishes.

* fix(studio): stop a first open from reloading the preview once per scene file

Studio stamped hf-ids into a composition the first time that composition was served, so opening a
film of external scenes wrote to index.html and every scene file mid-session. Each write reached
Studio as an outside edit and reloaded the preview, which failed for a large film. The server now
stamps every composition once before it starts watching the project.

* fix(studio): ignore play until the preview can play, so it is never undone

Space (and any play() caller) could start playback before the preview frame finished loading. Its
load step then paused and rewound to the restore point, so the play was silently lost. play() now
waits for timelineReady, the same gate the Play button already uses.

* fix(studio): hold play on a composition switch until the new preview loads

Readiness was reset only when the project changed. Drilling into another composition kept the
old ready flag while the new preview loaded, so Play or Space could start a play that its load
step then undid.

* refactor(studio): split background lint and thumbnail page reuse into single-purpose steps

Keeps the boot wait in the background lint only, moves page lookup and idle close out of
withPage, and drops a suppression the startup worker no longer needs. Behaviour unchanged.

* chore(cli): declare the render-setup worker as a fallow entry

It is spawned by path from cancellableProcess.ts, so it has no import-graph referrer; the entry list
is where the repo records that, replacing the per-file ignore removed in the previous commit.

* fix(cli): close a thumbnail page whose load fails

The page never leaves the promise that loads it, so when the load throws (a WebGPU composition with
no adapter, or the navigation timeout on a big film) nothing could close it.

* fix(cli): match text assets by exact type and keep Range on them

The pattern anchored only its first alternative, and text assets skipped buildRangeResponse, so a
ranged request for a stylesheet or SVG got a full 200.

* test: open the next project as Studio does, and read the stamped file once

The absent-read guard test switched project without beginning its timeline session, so the
per-project boot wait held the read. The stamp test checked then read the same file.

* test(cli): rewrite the scene file without opening it first

Opening the file by path counted as a check before the later read of the same path.

* perf(cli): take a thumbnail's version from the cached project signature

Each thumbnail walked the project and hashed every text file for its page version; the adapter
already caches that signature and clears it from the watcher. An aborted request now also stops
before its screenshot.

* fix(studio): keep Play off until the preview's load step has run

Play could enable before the preview document's load event, from the runtime's timeline message
or from the blank page's load step settling on the preview's first message. The load step then
paused and rewound the preview, so a Play pressed in between did nothing; a film with large
images loads late enough for this to hit a cold open. Readiness now waits for the document's load
step, whichever path asks for it.

* refactor(studio): move the preview poster's state into usePreviewPoster

NLEPreview.tsx passed the 600-line limit once main grew it; the poster's state and callbacks belong
beside PreviewPoster. No behaviour change.

* fix(cli): take an earlier thumbnail on a fresh page

A composition need not draw the same frame when seeked back, so a kept page that had shown 20 s
drew a different 10 s thumbnail than a fresh load. A kept page now only seeks forward, as a render
does; an earlier time loads a fresh page.

* perf(cli): close a kept thumbnail page a second after its last frame

Held for 10 s, the page went on running its composition after the open's thumbnail
burst, about 5 % of a core in the thumbnail browser while Studio sat idle. A burst's
requests arrive back to back, so it still loads the page once.

* fix(studio): count the preview as booted at its first frame

The boot waited for every video and audio in the preview to finish buffering, which on a
project with media took until the 5 s fallback. The thumbnails, editing session and lint
that wait for the boot then started seconds late and kept an idle Studio busy. They now
start once the first frame is up; media finishes buffering alongside.

* perf(studio): render one thumbnail per composition for its card and clip

A composition's sidebar card asked for its frame at 3.00 s and its timeline clip for the
clip's midpoint, so opening a project rendered every scene twice. The clip now asks for the
card's frame (the midpoint still, for compositions under 3 s), and the thumbnail cache
serves both from one render: 20 scene renders drop to 12 on a 12-scene project.

* chore: keep each changed file's comment share at or under main's

The comment ratchet counts the comment lines this branch added. Each is cut, or kept as one
line where it states an invariant (the GSAP parse cache returns copies; the boot starts waiting
work at the first frame). The orphan-cleanup docblock is condensed and now names the process-table
read it uses instead of the pgrep scan it replaced. No code line changed.

* fix(studio): keep a 3 s clip off its end frame and give card and clip one encoded URL

A composition exactly 3 s long asked for its thumbnail at 3.00 s, its end instant, which after a
fade-out is an empty frame; compositions of 3 s or less now use their midpoint. The card built its
thumbnail URL from the raw path while the clip encoded it, so names with & + [ or % asked for two
different URLs, and a % in the name made the card's request fail on the server; both now encode.
The readiness comment says the boot latches at the first frame, as it has since the boot moved.

* fix(studio): encode the card's live preview URL too, and document the bundler's static guard

The card's hover preview built its URL from the raw composition path, so a % in the file name made
the preview route fail to decode it. The card now builds its composition URL in one place, encoded,
for both its thumbnail and its hover preview. The static-guard bundle option keeps a one-line doc
(its default and what it warns about); a neighbouring docblock is rewrapped to keep the comment
share.

* test(cli): mock studio-server once so the signature spy and the history hook both apply

The main merge brought a second vi.mock of @hyperframes/studio-server into studioServer.test.ts;
the later one replaced the first, so createProjectSignature was no longer a spy and the cached
signature test read .mock of undefined. One mock now carries both overrides.
2026-09-25 04:56:55 +00:00
Miguel Ángel 177464b4c1 feat(cli): preview keeps project history, and Studio can claim its own edits (#4437)
* feat(studio-server): preview keeps a project history and names the next undo and redo

The history engine now reaches `hyperframes preview`, and its routes tell
Studio what Cmd+Z and Cmd+Shift+Z would revert next.

- The CLI preview server gives the shared API a project history, opened
  on first use under ~/.cache/hyperframes/history (overridable with
  historyRoot), and its file watcher feeds every change it sees to it.
  Shutdown closes it, so open windows are committed, within the same
  bounded wait as renders, so a history still opening cannot hold exit.
- GET /projects/:id/history also returns `back` and `forward`: the entry
  each step would revert, with its label, for the Undo and Redo buttons.
- POST /projects/:id/history/window takes an optional idleMs, so a drag's
  burst of writes can share one window that ends by itself (capped at
  10 minutes, below where a timer overflows).

Nothing in Studio calls these yet.

* feat(studio-server): a writer that records after writing can claim its writes

Studio records an edit after writing it, and many of its writes are
server mutations deep in its save paths, so opening a window before each
write would restructure them. claim() fits that order instead.

- ProjectHistory.claim(who, label, paths, { coalesceKey?, idleMs? })
  sweeps now and moves the not yet committed outside changes to `paths`
  into one entry of who's, under the edit's label. Other outside writes
  stay outside.
- Claims with one coalesceKey merge into one entry (a drag's burst) until
  a claim with another key, idleMs (default quietMs) without one, or any
  operation; Cmd+Z right after a drag undoes all of it.
- The ceiling is named where it lives: another writer's write to a
  claimed file between Studio's write and its claim folds into Studio's
  entry, and a write filed to another writer's open window stays there.
- Route: POST /projects/:id/history/claim { label, paths, coalesceKey?,
  idleMs? } -> { claimed: { id } | null }.

* fix(studio-server): a drag back to its start claims nothing, and next steps name their files
2026-09-24 20:55:58 -04:00
Miguel Ángel 86fa10ced4 perf(studio): show a film's first frame sooner by loading preview assets by URL (#4357)
* fix(studio): serve preview assets by URL instead of inlining them as base64

The Studio preview route serves project files under a base href, but both preview adapters
called the bundler with its self-contained defaults, so every image and font was inlined
once per reference. One exported option set now owns how a preview bundle is built.

* test(studio): cover poster, srcset and sub-composition preview asset URLs

Also types the Vite adapter bundler with BundleOptions, so the shared preview options are
checked against the real option set.

* fix(studio): tag a preview asset for revalidation only once it has settled

Preview assets are now served by URL, so the asset route serves every image and video. It tagged
them by mtime and size; two same-size writes inside one filesystem tick share an mtime, and copy
tools (cp -p, rsync) set mtime back, so a browser could keep stale bytes after an edit. The route now
tags a file by inode, change time and size, and only once its last change is three seconds old; a
younger file is served without a tag and refetched.
2026-09-23 21:08:56 -04:00
Miguel Ángel 5c3f02c855 perf(cli): tell the preview about a file edit 30 ms after it lands, not 300 (#4348)
* perf(cli): tell the preview about a file edit 30 ms after it lands, not 300

* perf(cli): keep bulk writes coalesced while lone saves go out fast

* style(cli): name the watcher delay

* perf(cli): throttle watcher flushes instead of switching to a long wait
2026-09-23 18:49:37 -04:00
Miguel Ángel 67c363b9cf fix(cli): keep live reload working after an atomic save on Linux (#4346)
* fix(cli): keep hearing a file after an atomic save on Linux

* fix(cli): skip a subdirectory the watcher cannot open

* test(cli): pin that one unwatchable subdirectory leaves the project watched

* test(cli): assert the tree keeps its other watches when one subdirectory fails

* test(cli): cover the manifest directory and prefix-named siblings

* test(cli): reset fake directories per test and cover an unwatchable root
2026-09-23 14:49:35 -04:00
Miguel Ángel 8cb7d06db6 perf(studio): re-render only the edited scene's thumbnails after an edit (#4315)
* perf(studio): refresh only the edited scene's thumbnails and let the preview load first

One scene edit regenerated every sidebar and timeline thumbnail, and those renders starved the new preview's requests on the Studio server. The thumbnail cache now keys on what a composition renders from, the server names the compositions a write affects, only those thumbnails refresh, and composition thumbnails wait while a shadow preview reload is in flight.

* test(studio): pin scene-scoped thumbnail reuse and the preview-reload hold

Memoizes the scoped thumbnail signature on the project signature so requests keep reusing the adapter's cached one, keeps the fresh-signature check that catches a write the watcher has not reported yet, and adds tests for sibling-scene reuse, root-head invalidation, the affected-composition set, per-path revisions and the scheduler hold.

* refactor(studio): share the file-change payload guard

* fix(studio): match thumbnail revisions for scenes mounted with a ./ path

* perf(studio): keep signature cache keys constant-size

* style(studio): format thumbnail-scope changes

* docs(studio): trim thumbnail input comments
2026-09-23 10:29:01 -04:00
Miguel Ángel 1b8f8a43d4 fix(cli): stopping the preview server no longer leaves a Chrome running (#4183)
* fix(cli): stop the preview server's browser when the server exits

Cancel in-flight renders and thumbnail launches before draining the
browser pool on shutdown, instead of only closing whatever browser was
already registered. A render whose Chrome died from the shutdown signal
itself was being misclassified as a transient failure and retried with a
fresh, untracked browser that outlived the process. Reject new render and
thumbnail requests once shutdown has begun, and await an in-flight
thumbnail launch before closing it.

* fix(cli): close preview browsers before a hung render, keep SIGINT armed

shutdown() awaited renders before closing browsers, so a render slower
than preview.ts 3s exit watchdog left Chrome running when it fired.
Close the thumbnail browser and drain the pool concurrently with, not
after, the render wait, and bound the wait under that watchdog.

A second Ctrl+C/SIGTERM during shutdown removed the one-shot signal
handlers, so it hit the OS default and killed the process before
cleanup ran. Use persistent handlers guarded by the existing
shuttingDown flag instead.

Also: getThumbnailBrowser could still hand a live lease to a request
that lands after shuttingDown flips true; trim a comment over budget;
replace a fixed-sleep test race with a drain-signal barrier.

* fix(engine): make browser pool shutdown terminal, not just draining

drain() resets its drainPromise to null once it settles, so acquire()
only waits for an in-flight drain -- a render still unwinding after
shutdown could relaunch Chrome the instant that drain resolved
(probeStage.ts:449-465 has exactly this gap between an abort check
and a later acquireBrowser call). No non-shutdown caller reuses the
pool after draining it (checked every drainBrowserPool()/drain()
call site), but added a separate terminal close() rather than
changing drain()'s own semantics, so a future reuse caller stays
safe by default.

BrowserLeasePool.close() sets a permanent closed flag before
draining, and acquire() checks it both before and after its one
await point, so a request already mid-await when close() lands still
sees it once that await resolves. studioServer's shutdown() now
calls the new closeBrowserPool() instead of drainBrowserPool().

Also bounds drain()'s own wait: a close() that hangs past 1s now
gets escalated to a force-close instead of blocking the caller
indefinitely, keeping total shutdown time under preview.ts's 3s exit
watchdog alongside the existing render-wait bound.

* fix(engine): trim closeBrowserPool JSDoc to house comment length
2026-09-22 22:49:44 -04:00
cc19b8c812 fix(server): contain static file requests within their served roots (#4217)
* fix(server): contain static file requests within their served roots

Static asset handlers joined the raw request path onto their root
directory without checking where it landed. Hono decodes percent
escapes while routing, so an encoded backslash (%5c) arrives as a real
separator on Windows and dot segments arrive already collapsed —
"/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files
above the studio bundle root (embedded Studio server) and above
compiledDir/projectDir (engine render file server). Browsers never
request those shapes, but anything that can reach the bound port can:
raw local clients, LAN peers when the preview binds 0.0.0.0, and a
shared-machine process hitting the render server.

Both handlers now resolve the request path first and refuse anything
that collapses outside its root, using the same lexical containment
the preview asset route already applies — bundle/project assets behind
symlinked directories keep being served, only dot-segment escapes are
rejected. Route-level pathSafety tests pin the behavior in both
packages, including the Windows backslash shape and the
symlinked-asset cases from the descriptor-pinning regressions.

Validated: new containment suites fail on the prior handlers (200 +
marker file content served from outside the root) and pass with the
guards; cli server (140), engine services (1024+, matching the main
baseline incl. pre-existing browserManager failures), typecheck,
oxlint, oxfmt and the full workspace build all clean.

* fix: close fallow audit findings, prove containment cross-platform

Fallow audit (health/complexity): createFileServer's request handler
exceeded the CRAP-score threshold once the new containment checks were
added. Extracted resolveContainedPaths, a small pure function that
resolves and validates both roots, cutting the handler's own branching
back down.

Fallow audit (duplication): the new pathSafety test files copied their
temp-root/studioDir setup and teardown from the existing staticAssets
test wholesale. Extracted the shared parts into
studioServerTestFixture.ts (makeStudioServerRoot, writeStudioIndexHtml,
cleanupStudioServerRoot); only the per-file vi.mock("node:path", ...)
call stays duplicated, since Vitest requires it declared statically in
each test file and it cannot be moved behind a shared function -- that
one block keeps a narrow, targeted ignore comment instead of a
file-wide one.

Security claim: isWithinProjectRoot's %5c-decoded-backslash escape is
real, but only on Windows -- POSIX never treats a literal backslash as
a separator, and Hono leaves %2f undecoded in c.req.path, so none of
the new tests' encoded-traversal payloads produce an actual escape on
Linux (verified empirically: they pass identically with the fix
reverted). isWithinProjectRoot now takes an optional path-module
override (defaulting to the host's native node:path, so every existing
caller is unchanged) so its win32 and posix branches can be unit
tested directly from one OS, the same pattern already used by
producer/fileServer.ts's isPathInside. New tests cover a backslash
traversal, a mixed backslash/forward-slash traversal, and a
drive-letter path escaping the root under path.win32, plus the
existing posix case -- confirmed each one fails when the check is
removed. The HTTP-level tests stay as-is: real regression coverage for
them comes from the windows-latest CI job, which runs both new
pathSafety suites unfiltered.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: orphan4183 <kingenmaww@gmail.com>
2026-09-22 22:18:56 -04:00
Vance IngallsandClaude Sonnet 5 24c3e197ef fix(studio): cutover gate classification, SSE cross-project bleed, benign races (#4269)
Implements the fixes from the 2026-09-21 cutover dashboard read
(plans/sdk/2026-09-21-cutover-dashboard-fixes-plan.md):

- sdk_cutover_failed now carries `family` and `error_kind`
  (network/conflict/http/sdk). The one production gate event was a raw
  `TypeError: Failed to fetch` from an unwrapped fetch (readProjectFile /
  writeProjectFile's preflight), which an instanceof-only classifier would
  still file as "sdk" and trip the rollback gate — added a message-based
  fallback for the browser's fetch-rejection strings.

- studio:sdk_session_unavailable's `network` reason now carries `elapsed_ms`
  and `hidden` (document.visibilityState at rejection), separating a
  near-instant policy block (CSP/PNA/extension) from a dropped connection
  the tab or server going away mid-flight.

- The ownership-race branch in useSdkSession's open effect now emits
  sdk_session_superseded instead of sdk_session_unavailable{stage:ownership}
  — it's a benign race (identity moved on mid-open), not a broken
  precondition, and was being counted as one on the dashboard.

- Root-caused and fixed the read-404 "storm": /api/events (CLI host) and
  the vite-dev hot-reload channel are one connection per SERVER, not per
  project. A stale tab left open after `hyperframes preview` restarts on a
  different folder (same port) received every save the CURRENT project
  made, reloading its preview (which then 404s) and re-reading its own
  composition on each one. Both transports now carry `projectId`; the
  client-side coordinator drops a delivery whose projectId doesn't match
  the tab's (absent field still passes, for one release of skew).

- studio-server's file routes report `why` on 403/404: `project_dir_missing`
  (new — the CLI host's static resolveProject kept answering for a project
  whose folder was renamed/deleted under a running server, which used to
  fail closed as an indistinguishable path-traversal 403) vs `nul` vs
  `outside_project`.

- absent_or_empty reads now carry `path_in_tree` (against the caller's
  loaded file tree). Instrumentation only, per the graveyard page
  (clearing-activecomppath-on-tree-absence) that refuted the "clear
  activeCompPath" fix this was the proposed next step for.

Tests: unit coverage added for every change (sdkCutover.test.ts,
useSdkSession.lifecycle.test.tsx, useExternalFileChangeCoordinator.test.tsx,
studioServer.test.ts, files.pathSafety.test.ts). oxlint/oxfmt clean,
tsc --noEmit clean on studio/studio-server/cli.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:40:47 -07:00
Miguel Ángel 2d8d44151a fix(cli): a Studio render refuses with the browser check's own message (#4176)
* fix(cli): a Studio render refuses with the browser check's own message

* test(cli): the Studio browser refusal test no longer loads the real producer
2026-09-19 12:25:08 -04:00
Miguel Ángel b44f1f85dc feat(lint): flag nested structure, missing timing and caption tracks on the root composition (#4146)
* feat(lint): flag nested structure, missing timing and caption tracks on the root composition

One rule set for the root composition: nested layout inside a top-level
element, a timed element or media without data-duration, captions not marked
data-track-kind=captions, and captions spread over more than one track.
Findings are errors when the host is Studio and warnings elsewhere.
Sub-composition files are left free to hold layout.

* test(lint): keep the structure rules silent on the shipped catalog and skills

Runs the rules over every registry and skills html file and asserts no
findings, and treats a bare media element with no timing as a static layer.

* fix(lint): run the structure rules as Studio in both adapters and skip non-root files

The Vite Studio adapter now lints as the studio host, and HTML the project
lint did not treat as a root (components, snippets) is linted as a
sub-composition. Style, script and template content no longer count as nested
layout, sub-composition hosts need no duration, and a caption row without a
lane is not a second track.

* fix(lint): count legacy data-end as a length and pin the catalog test to real roots
2026-09-18 22:49:15 -04:00
Miguel Ángel 9981d9afb7 fix(capture): never silently render a webgpu composition blank (#4091)
* fix(capture): never silently render a webgpu composition blank

A composition declaring data-requires-webgpu only got the Chrome WebGPU
flag when hardware GPU auto-detection succeeded. On a software-GPU host
it launched without the flag and rendered blank with no error.

compositionRequiresWebGpu (static declaration check) and
assertWebGpuAdapterAvailable (live per-page adapter probe) now live once
in packages/engine's browserManager, the shared choke point every launch
site already goes through. All six launch sites (frame capture, the four
CLI diagnostic commands, the studio thumbnail server, and the render
pipeline's capture-options builder) route through those two functions;
the old CLI-only static-heuristic guard is deleted, not kept alongside
the new one. A software host that cannot obtain any WebGPU adapter now
fails loudly naming the requirement instead of producing a blank frame.

* fix(capture): thread requiresWebGpu through the distributed chunk-render path

The distributed renderChunk() path built its CaptureOptions without
requiresWebGpu, so it fell through to frameCapture's own fetch-based
detection, whose failure path silently treats "requires webgpu" as
"doesn't" if that fetch ever fails. Every other launch site (frame
capture, all four CLI diagnostic commands, the studio thumbnail server,
and the in-process render pipeline) already reads the value from HTML
already in memory or on disk instead of relying on that fallback.

Reads the compiled composition's index.html the same way probeStage
already does and derives requiresWebGpu from it before building
CaptureOptions. Adds a regression test that pins the exact path
renderChunk() reads at render time, since the composition doesn't
actually exercise WebGPU so an end-to-end render can't distinguish the
two cases on this host.

* test(producer): observe the real CaptureOptions renderChunk() builds

The previous regression test called plan() and compositionRequiresWebGpu()
directly, bypassing renderChunk() entirely, so it could not detect a
regression to the wiring it was meant to protect (confirmed: it stayed
green with the fix reverted).

Exports the existing createCaptureSession dependency seam so a test can
substitute a spy that delegates to the real implementation while
capturing the CaptureOptions object renderChunk() actually constructs.
Verified non-vacuous by reverting the fix and confirming the test fails.

* fix(gcp-cloud-run): resolve puppeteer-core from a real file in the webgpu probe

The image build's WebGPU adapter probe imported puppeteer-core from an
inline `bun -e` eval string. The eval's synthetic /app/[eval] location
can't resolve the workspace-hoisted dependency, so every build of this
stage failed closed with "Cannot find package 'puppeteer-core'" before
the probe itself ever ran.

Moves the probe into a real script file next to probe-beginframe.ts,
which already imports puppeteer-core dynamically the same way and
resolves fine from its real workspace location. Verified locally against
a cached chrome-headless-shell binary.

* fix(engine): bound the webgpu adapter probe so a stuck driver can't hang the caller

requestAdapter() has no native timeout. A broken software GPU driver
that never settles it would hang assertWebGpuAdapterAvailable
indefinitely, including inside the GCP image build's probe RUN step,
producing an opaque build timeout instead of a clean probe failure.

Races the in-page request against a bounded timeout so a stuck adapter
reads as unavailable and fails the same way a genuinely absent one does.

* test(cli): mock the engine's webgpu exports in the browser-fallback test

* test(producer): run the typegpu adapter fixture only on hosts with a hardware WebGPU adapter

The fixture declares data-requires-webgpu, so a software-only host now refuses it
instead of silently rendering the shader blank. The old golden had no shader in it.
Tag it webgpu, skip that tag in the CI image and the test scripts, and re-record the
golden from a Metal render with the shader visible.

* fix(engine): bound the composition fetch that decides the webgpu flag

createCaptureSession fetched the served page with no timeout before launching the
browser, so a hung server stalled a render forever and hung a fake-timer test. Give it a
five second limit, and let that test say it has no WebGPU requirement the way the
producer does.

* fix(capture): refuse a software fallback WebGPU adapter instead of rendering on it

swiftshader reports a WebGPU adapter but cannot render canvas WebGPU, so a
data-requires-webgpu composition on a software-only host crashed on its first frame.
Refuse fallback adapters with the same clear WebGPU-unavailable error. The GCP image
probe now serves its page over http (file:// hides navigator.gpu) and reports a
software-only host instead of failing the image build.

* fix(engine): parse the composition root tag without a backtracking regex

* fix(engine): keep quoted attribute values whole when reading the composition root tag
2026-09-18 18:06:23 -04:00
miga-heygenandMiguel Ángel 71bb88719e feat(telemetry): render output-shape, adapter, count, and environment properties (#4017)
* feat(telemetry): add output-shape request facts to render events

output_resolution_preset, output_format, hdr_mode, video_frame_format,
and gif_fps_capped join render_complete/render_error, all threaded
from already-resolved RenderOptions/RenderPlan fields rather than
recomputed: createRenderPlan already computes gifFpsCapped (clamping a
requested --fps above 30 to 30 for --format gif) but only used it for
a console warning; this threads that same decision into RenderOptions
and telemetry. All five are known before the pipeline starts, so both
events carry them even when a render fails before a perfSummary
exists.

Also wires output_format/output_resolution_preset into Studio's
separate emitStudioRenderComplete/emitStudioRenderError path, since
Studio's request genuinely varies those two per-request. hdr_mode,
video_frame_format, and gif_fps_capped stay CLI-only: Studio exposes
no HDR-mode, video-frame-format, or GIF output control to users at
all, so there is no per-request value to report for those three.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add adapters_used to render events

adapters_used lists the runtime deterministic adapters
(packages/core/src/runtime/adapters/) a composition exercises.
Investigated the actual codebase rather than the ticket's guessed
7-adapter list and found 12 real registered adapters (gsap, css,
waapi, animejs, lottie, three, typegpu, d3, leaflet, mapbox, maplibre,
google-maps) — the map/data-source adapters share the exact same
window.__hf<Name> registration mechanism and are equally real
"adapters used" facts, so all 12 are included.

Detection unions a live page.evaluate probe (piggybacked on the same
probeSession resolveCompositionElementCount already uses) with a
static regex fallback over the compiled HTML, since this is purely
observational with no gating role (unlike compositionElementCount) —
union strictly increases recall rather than needing one source of
truth. Threaded through the same plumbing chain as the already-merged
composition_element_tags/aroll_video_count/heygen_video_count.

Studio's emitStudioRenderComplete/emitStudioRenderError get this field
automatically, with no Studio-side code change, via the same shared
capture-observability spread heygen_video_count already uses.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add audio/image/sub-composition/color-grading counts

audio_count, image_count, sub_composition_count, audio_group_count,
color_grading_count, and has_lut join composition_element_tags in the
same single scanElementTags pass — audio/image/audio-group counts read
from the scan's uncapped tag map so a 50+-distinct-tag composition
can't push a named count into the "other" bucket. sub_composition_count
counts data-composition-src mounts directly rather than reusing
collectSubCompositionSrcs, which dedupes by src and drops
placeholder/remote mounts (a different, smaller fact than the mount
count this field reports).

has_lut decodes the HTML-entity-escaped form of data-color-grading
before parsing, since linkedom's serializer re-emits it
&quot;-escaped on every compile-pipeline round-trip — without the
decode this silently reported false for every LUT-graded composition.
The LUT-presence check now mirrors normalizeLut's own definition of
"has a LUT" exactly, closing a gap where a blank or empty lut value
would have reported true.

Threaded through the same plumbing chain as the already-merged
per-tag/provenance/adapter fields; Studio's emitter picks these up
automatically via the shared capture-observability spread.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add browser_version_major and ffmpeg_version_major

Both are already-run subprocess calls whose output was discarded:
readToolVersion (checkFFmpeg's preflight) already runs `ffmpeg
-version`, and chromeLaunchOutcome already runs `--version` as a
Chrome launch sanity probe on every render. A shared
extractMajorVersion helper pulls the first X.Y-shaped number from
either banner, threaded onto EnvironmentCheckOutcome/
EnvironmentCheckResult, then RenderOptions right after
runEnvironmentChecks resolves in the local render path only.

Docker renders never populate these two RenderOptions fields (the
containerized producer runs its own preflight the host CLI never
sees), so both properties come back absent there rather than
reporting the host's own toolchain versions for a render that didn't
use them.

Studio does not call runEnvironmentChecks or this ffmpeg/Chrome
preflight at all before creating a render job, so its render_complete/
render_error events will not carry these two properties unless
Studio's own startup path is separately instrumented — a genuine
parity gap, not an oversight in this change.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add authoring_skill_source and authoring_skill_invalid

Both derive from resolution logic createRenderPlan already runs:
authoring_skill_source names whichever step actually won attribution
(an explicit --skill flag, or the skill persisted in hyperframes.json)
using the same flagSkill/projectConfigSkill values already computed
for authoringSkill itself, not a second lookup. authoring_skill_invalid
surfaces the raw --skill value when it fails slug-shape validation
(normalizeSkillSlug rejects malformed input, not unregistered skill
names) — this value already exists as RenderPlan.invalidAuthoringSkill
and already drives a CLI warning in present.ts; this change threads
the same field into telemetry rather than adding a parallel one.

Threaded through both RenderOptions builders in execute.ts (single and
batch render) and all three telemetry call sites in render.ts,
including the Docker path — skill attribution is a request-level fact
independent of which backend executes the render.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add root_body_mismatch and root_body_delta_px_bucket

Measures the composition root's authored data-width/data-height
against the scaffold's own html/body CSS size, straight from the
compiled document string scanElementTags already scans — the
source-level fact, not runtime DOM state. init.ts's applyCompositionSizing
(the #4001 fix) only patches the live DOM after the browser loads this
same HTML, so this measurement stays meaningful whether or not that fix
ran for a given render.

Reuses @hyperframes/parsers' canvasScaffoldPatterns regexes (the same
ones packages/lint's root_dimensions_mismatch rule reads) for the CSS
side. Root-tag detection here is a lighter first-data-composition-id-match
heuristic, not lint's more thorough findRootTag (which also handles a
leading decorative <svg> defs block) — that helper isn't part of
@hyperframes/lint's public API, and expanding it is out of scope here.

The delta is bucketed (0 / 1-10 / 11-50 / 51+ px) rather than reported
raw, and both fields come back absent — not a false/"0" default — when
the root or the CSS block can't be read at all.

Threaded through the same plumbing as every other static-scan field
this week, including the live-capture-observability fallback used on a
crash that never reaches perfSummary.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add hf_env_overrides (names only, capped at 20)

Reports the sorted names of HF_*/HYPERFRAMES_* env vars present when
createRenderPlan resolves — never their values, since several of these
carry filesystem paths. Snapshotting at plan-resolution time (the very
first line of the render command, before any pipeline code runs)
matters: this render's own preflight later injects
HYPERFRAMES_FFMPEG_PATH/HYPERFRAMES_FFPROBE_PATH into its own
process.env, which would otherwise make those two always read back as
"operator overrides" whether or not the operator ever set them.

Reports an empty array, never an absent key, when nothing is set,
matching every other capped/bucketed field added this week. Capped at
20 names, documented alongside the cap constant.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(cli): stop clobbering studio render_complete fields, fix an env leak

trackRenderComplete's explicit audio_count/image_count/root_body_mismatch/
adapters_used/etc. keys, sourced from perfSummary.drawElement and only ever
populated on the CLI's own render.ts path, always won the object spread over
the observability-capture fallback those same keys share with trackRenderError,
even when the direct value was undefined. studioRenderTelemetry.ts's
emitStudioRenderComplete never populates drawElement, only the capture
fallback, so 24 fields silently came back missing from every studio-triggered
render_complete event despite Studio having computed and sent the capture
value. Each field now falls back to its captureXxx counterpart via a new
directOrCapture() helper when the direct field is absent, with regression
tests proving both the fallback and that the direct value still wins when
both are present.

Also excludes HF_SHADER_WORKER_ENTRY from hf_env_overrides: cli.ts sets this
on every invocation to point the bundled shader worker pool at its own file,
sharing the HF_ prefix by coincidence rather than because an operator set it.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* style: remove em and en dashes from added code comments

No behavior change. Rewrites 22 comment lines this PR's own diff introduced,
using a comma or a period where the dash separated two clauses.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): add a temporary diagnostic for a CI-only rootBodyMismatch failure

Not a real fix. The rootBodyMismatch test group fails deterministically in
CI (confirmed on two runs of the same head) but will not reproduce locally
by any method tried, including an isolated file run, the exact combined
producer:test:unit command, a worker-count-constrained run, and a fresh
clone with a clean install and build matching CI's own steps.

This dumps the intermediate regex-match state for the simplest failing
fixture to stderr so the next CI run's log says what actually differs.
To be removed once that answer lands.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): widen the rootBodyMismatch diagnostic to compare import paths

Still not a real fix. The previous diagnostic (ca4e73983) proved the
imported HTML_BODY_CSS_WIDTH_FIRST_RE has 4 capture groups in the failing
CI run instead of the 2 the source and production code expect, but it used
a dynamic import(), which can resolve through a different codepath than
renderOrchestrator.ts's static import. This adds a static import of the
same binding, a require.resolve() of the resolved file path, and a direct
read of that file's own regex line, so the next run can rule in or out a
static/dynamic resolution split versus a genuinely different file on disk.

Locally both imports agree (2 groups, same object, correct file). To be
removed once the CI answer lands.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(producer): read the merged 4-group scaffold-size regex correctly

main merged #4012 while this branch was in flight, consolidating
HTML_BODY_CSS_WIDTH_FIRST_RE/HEIGHT_FIRST_RE from 2 capture groups (digits
only) to 4 (prefix text, digits, suffix text, digits) so cli's
applyResolutionPreset could reuse the same regex for its in-place replace.
detectRootBodySizeMismatch still read groups 1/2 as the digits, now the
prefix/suffix text, so Number() on them was NaN and every render's
rootBodyMismatch/rootBodyDeltaPxBucket silently came back undefined. Fixed
to read groups 2/4, matching lint's core.ts readHtmlBodyCssSize exactly.

Removes the temporary diagnostic test (ca4e73983, e2e384c8e) now that its
CI log gave the real answer: both a static and dynamic import of the
regex resolved to the same file, and that file's own content had 4 groups
because this branch's merge-base predates #4012, not because of any
resolution or caching difference. A fresh CI run confirmed no CI-only
failure remains after merging main and fixing the indices.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: nudge CI (no workflow run triggered on previous push)

* chore: nudge CI again now that #4017 is out of draft

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-17 02:10:27 +00:00
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.

Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.

Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.

* docs(studio): plan for loading the preview during the shell's first layout

* fix(runtime): a paused preview stays still after any seek

Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.

Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.

- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
  now returns every timeline it unpaused to paused in a finally. The second
  rearm after the child re-seek only ever changed the leaked state and is
  gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
  timed media element found running. A parked transport runs no ticks, so a
  capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
  requests playback through the player so adapter, rAF loop and flag agree.

Regression tests fail without each of the three changes.

* perf(studio-server): stop re-encoding videos the browser already plays on open

Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.

One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.

Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.

Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.

* perf(cli): serve the studio bundle compressed and cache hashed assets

The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.

The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.

* perf(cli): warm the preview route before the browser opens

The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.

The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.

* perf(studio): request the player chunk before the shell's first layout

The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.

* fix(studio): editing a text property no longer reloads the preview

Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.

The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.

A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.

shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.

Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.

* fix(runtime): paused-time media playback is borrowed, not banned

The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.

- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
  video while paused to render grading previews. It went dark on the first
  paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
  music track for ~140 ms while paused so a playhead drag is audible. Same path
  killed it.

A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.

Two corrections to the previous commit's reasoning:

The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.

Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.

Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing

* test(runtime): a lease ends when the transport plays or the borrower stops

Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.

* test(studio): the event-stream channel must open /api/events

* fix(studio-server): stop printing a proxy diagnostic line per clip on every render

Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.

The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.

The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.

The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".

Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.

* perf(cli): stop gzipping the studio bundle on loopback

Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.

The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.

The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.

* docs(runtime): say what the paused-media probe actually filters

* revert(cli): drop the preview prewarm that held the CLI event loop

The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.

Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.

Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.

Reverts packages/cli to origin/main exactly.

* fix(studio): let a remount retry the player chunk after a failed load

1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.

Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.

Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.

* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap

* fix(studio): stop one failed player import poisoning every later mount

The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.

The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.

* chore(ci): allow the deletion of the dead sdk-session reload test

* fix(runtime): the timeline resolver no longer unpauses children it cannot drive

Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.

The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.

Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.

* test(runtime): pin that the resolver unpauses a child only once the root holds it

The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.

* fix(runtime): the paused side stops every clip the transport drives

The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00
James Russo 97fde27df7 fix(cli): pin Studio bundle, signature and runtime file reads (#3728)
* fix(cli): read Studio bundle files through checked descriptors

* fix(cli): pin Studio signature and runtime artifact reads
2026-09-05 22:23:15 -04:00
miga-heygenandClaude Opus 4.6 00c575d23b fix(studio): prevent preview hang on burst external file rewrites (#3648)
* fix(studio): prevent preview hang on burst external file rewrites

Two interacting bugs caused Studio to freeze when multiple processes
(generator, check, snapshot) burst-wrote index.html within seconds:

1. SSE listener leak: the /api/events handler added a watcher listener
   per client connection but never removed it on disconnect. Reconnects
   accumulated dead listeners, each triggering readFileSync on every
   file change and writing to closed streams.

2. Generation starvation: processChange incremented generationRef and
   awaited drainPendingChanges. A second event arriving mid-drain bumped
   the generation, causing the first drain to bail at the generation
   check. With rapid writes, no drain ever completed and Studio stayed
   frozen on stale content.

Fix 1: use stream.onAbort() to remove the watcher listener when the
SSE connection closes.

Fix 2: gate processChange with a draining ref. While a drain is in
progress, stash the latest event. On completion, process the stashed
event — the last write in a burst always completes its reload.

Closes #3646

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): align coordinator tests with drain serialization

Update existing test to expect the new behavior: when two events
fire in quick succession, the first drain completes and triggers a
reload (previously it was silently discarded). The stashed event
then starts a second drain.

Also fix the burst-write test to use the drains array pattern and
explicit act() flushes for stashed event processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): stash events with allowDuplicate and harden listener cleanup

Address Rames's review findings:

- Stash with allowDuplicate: true so re-dispatched events are not
  swallowed by the duplicate guard (the identity was already written
  on the way in, so the stashed event matched itself on re-entry).
- Wrap SSE keepalive loop in try/finally so the listener is removed on
  both abort and throw, not just abort.
- Restore stale-completion guard test coverage lost in the rename.
- Use await act(async () => {...}) for burst dispatches so assertions
  depend on the stash guard rather than scheduling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): simplify drain serialization and restore SSE cleanup

Restructure processChange into intake + drain loop:

- processChange is now synchronous — validates, dedupes, checks own
  echoes, enqueues the accepted payload, and starts the drain loop
- startDrainLoop runs while the pending slot is non-null, draining
  one event per iteration via drainOnePending
- No recursive void processChange(...) from finally, so no
  allowDuplicate escape hatch needed — stashed events never re-enter
  intake guards

SSE listener: restore stream.onAbort alongside try/finally. Hono's
sleep() never throws, so finally alone doesn't fire on disconnect.
Both paths call removeListener (Set.delete is idempotent).

Tests: remove stale-drain test that contaminated subsequent tests by
emptying the shared roots array mid-test. Use sync act() for burst
dispatches — the stash decision is synchronous.

All 10 coordinator tests pass locally (NODE_ENV=test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-09-04 21:44:25 -04:00
James Russo d2741b3a28 fix(studio-server): preserve binary file writes and versions (#3653)
* fix(studio-server): preserve binary file writes and versions

* fix(studio-server): rely on exclusive file creation for POST

* test(studio-server): create race fixture atomically
2026-09-04 17:44:19 -04:00
Vance Ingalls 2685c8f223 docs(audio): document grouped audio and its guardrails (#3455)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized

* fix(studio): align audio rows, automation lanes and headers

* fix(studio): stabilize timeline audio derivations

* refactor(studio): simplify group metadata memoization

* style(studio): keep timeline layout within size gate

* fix(studio): keep timeline preset apply off auditions

* fix(studio): harden carve and FX rack behavior

* fix(studio): repeat audio FX reveal requests

* fix(studio): reconnect property-panel audio controls

* fix(studio): unify property panel audio detection

* fix(studio): satisfy panel and deletion gates

* feat(studio,core)!: remove solo and the group meter

* docs(audio): keep removal rationale current

* refactor(core): retire studio solo bridge

* docs(audio): document grouped audio and its guardrails

* docs(audio): point handoff at replacement stack
2026-08-23 19:19:27 -07:00
Miguel Ángel 5842dd8df4 fix(studio): invalidate the preview signature off the watcher that sees project writes (#3364)
* fix(studio): invalidate the preview signature off the watcher that sees project writes

The preview ETag is a hash of the project's files, memoised per project
directory. That cache was cleared from Vite's own watcher, which
`server.watch.ignored` deliberately excludes `data/projects/**` from, so
nothing ever cleared it: the ETag stayed frozen for the life of the dev
server, the preview answered every revalidation with 304, and the browser
went on serving the composition as it was when it first loaded.

The visible cost is thumbnails. Their disk cache key already content-hashes
the composition, so an edit correctly asks for a fresh capture, but the
capture is taken against the stale page, and a clip's filmstrip keeps
showing frames of a layout that no longer exists until the dev server is
restarted.

Studio already runs its own chokidar watcher over exactly these
directories, because Vite's would answer a composition edit with a full
page reload. That watcher now owns the invalidation, and the cache asks it
to follow any project directory it has not seen. All five event types
count: an added or deleted asset changes the signature as surely as an
edited one.

The cache moves behind `createProjectSignatureCache` so the invalidation
rule is a unit under test rather than a subscription buried in the adapter.

* fix(studio): filter signature invalidation, and stop the CLI server missing motion saves

Review follow-up on the unfiltered invalidation.

The watcher fired on everything under a project dir, but the signature walk
skips 14 directories and `.thumbnails` is one of them. That directory is
where the thumbnail route keeps its disk cache, and every capture also reads
the preview, so populating a timeline row discarded the memo on roughly every
request of the one workload it exists for.

The filter is a single exported predicate beside the exclusion set it reads,
and it is applied inside `invalidate` rather than at the watcher, so no caller
can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`:
that set is character-identical but drops all of `.hyperframes/`, and the
signature reads two manifest files back out of there.

Which is the same bug, still live, in the CLI server: its watcher filters
through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never
reached the listener that clears the cached signature. Studio writes that file
at runtime, so saving motion state left the preview ETag stale until restart.
The watcher now admits signature-relevant paths and the reload listener
re-applies its own filter, so what triggers a browser reload is unchanged.

Also from review: drop the `createViteAdapter` signature-cache default, which
produced exactly the memo-nothing-clears bug this PR fixes, and correct the
docstring — the content hash is already gated behind a stat fingerprint, so
what the memo saves is the walk.
2026-08-21 19:13:37 -04:00
Miguel Ángel 8b67bb6db5 fix(cli,studio): surface project lint in Studio (#3393)
* fix(cli,studio): surface project lint in Studio

* fix(studio): preserve per-file lint coverage
2026-08-21 15:11:24 -04:00
Miguel Ángel c1c70f44bd fix(cli): signal only processes the OS says own the port (#3307)
* fix(cli): signal only processes the OS says own the port

`/__hyperframes_config` is unauthenticated and the PID it reports is what
`--stop` and `--kill-all` send signals to, so any local process answering on
a scanned port could name an arbitrary PID and have the CLI kill it.
Reproduced with a twenty-line HTTP server on a scanned port self-reporting an
unrelated PID: before this, `--kill-all` killed that process; after it, the
process survives and only the real listener is stopped.

The listening PID now comes from the OS — `lsof`, and `netstat` on Windows,
where the lookup was previously unavailable and the self-reported value was
taken on trust. The response's own PID is used only where the OS lookup
fails, which is also the only case where it is unfalsifiable.

Orphan cleanup moves to the last step before a launch. It reaches outside the
process and kills other people's PIDs, so it must not run for an invocation
that turns out to be a validation error and never starts anything.

* fix(cli): fail closed when the OS cannot confirm who owns a port

Review follow-up.

The two halves of this change picked opposite directions for the same
condition. `isProcessDescendant` fails closed by design; `activeServerOnPort`
fell back to the self-reported PID whenever the OS lookup came back empty —
and that is not only "unsupported platform". `lsof` may be absent (the default
on many slim images), may time out, or may not see a socket owned by another
user. On such a machine every scanned port silently reverted to pre-change
behaviour, with nothing said.

Provenance is now part of the type rather than a convention: `ActiveServer`
carries `pidSource`, so a caller cannot mistake a self-report for the kernel's
answer. `--kill-all` requires `"os"` and skips the rest, naming the ports it
left alone and why. That is the deliberate trade — a blind sweep of a port
range has no evidence beyond an unauthenticated response, so an unconfirmed
PID must not be signalled. Managed previews are unaffected: they stop through
their session record, which proves ownership by process birth identity.

The fallback branch — the one with the security consequence — now has the
coverage it lacked, via an injected lookup matching the seam `testPortOnAllHosts`
and `isProcessDescendant` already use, including a live process that survives
because nothing confirmed it owns the socket.

Also state that `killProcessTree` honours `signal` on POSIX only: Windows
always passes `/F`, deliberately, since taskkill without it posts WM_CLOSE that
a console process may ignore. The caller-side comment claiming Windows cleanup
is a no-op described the code before this change and now says the opposite.
2026-08-18 17:41:46 -04:00
Miguel Ángel 5058236eda feat(studio): prompt to install FFmpeg before Export, not after (#3314)
Exporting without FFmpeg installed used to show "Server error (503). Check
the terminal for details." The server already knew the exact cause and sent
a per-platform install command in the response body; Studio discarded that
body and printed the status code. The user found out only after the
composition was finished.

Studio now asks the dev server on load whether this machine can encode, and
the Renders panel shows the cause plus a copyable install command when it
cannot, with a Recheck that avoids restarting Studio.

- New GET /api/environment/ffmpeg calls runEnvironmentChecks() with every
  optional check off, which is exactly the FFmpeg and ffprobe pair `doctor`
  runs, so Studio and the CLI cannot disagree. Only a passing result is
  cached.
- The refusal lives in startRender, not in a button. Studio renders from
  three places (the panel's Export, the header's, and each composition card
  in the sidebar), so a per-button check would leave the others free to
  queue a render that cannot finish. The header and sidebar controls reveal
  the prompt rather than going dead.
- A null probe result means "no answer", not "missing", so an older or
  unreachable dev server cannot lock a working setup.
- Failed render responses now surface the server's { error, hint }.
- getFFmpegInstallCommand() is the single owner of platform-to-command, with
  the prose hint derived from it. Windows gains a winget command and keeps
  the manual download route.

Accessibility: the prompt's explanatory line measured 2.2:1 on the card's
amber background against a 4.5:1 minimum, because the panel's usual grey for
secondary text does not survive the tint. Now 6.6:1. Keyboard focus was
invisible on all three controls and now matches the panel's focus ring.

Also folds in cleanups the repo's gates required: the Renders tab moves out
of StudioRightPanel (it was at the 600-line cap and every field it needed was
already on the shell context), StudioContextInput stops keeping a second copy
of the renderQueue shape, and the server tests share one temp-project helper.
2026-08-17 21:00:27 -04:00
Vance IngallsandClaude Sonnet 5 d6c4774ef4 feat(studio): instrument the audio FX rack, including work an agent did (#3229)
* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* fix(studio): stop the single-candidate auto-apply carve firing twice

Two auto-apply effects both fire when sourceOptions.length === 1: the
multi-candidate effect only guards length === 0, so a single candidate
passes it too, and the single-candidate effect passes its own guard right
after — both compute the same sources list and both call setCarve, so the
common case (one narrator, one bed) triggered two decodes, two FFT runs, and
two concurrent attribute writes for one decision.

The multi-candidate effect now defers to its sibling for exactly one
candidate, which already has its own detailed handling for that case.

Review by Miga (PR #3213).

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

* refactor(studio): break up the FX rack's largest functions and files

Fallow flagged 9 complexity findings and 2 file-size violations after the
telemetry stack landed. Extracts FxPresetRun, FxAddMenu, FxRackChain,
FxNodeOpenBody, FxNodeParams, and useFxAudition/useFxCarve/useFxLevelling/
useFxChainObserved out of propertyPanelFxSection.tsx and
propertyPanelAudioFxGroup.tsx, splits propertyPanelFxNodeRow.tsx's open-face
rendering into its own component, and dedupes a clone in studioTelemetry.ts.
Pure structural move — no behavior change; full test suite still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:05:51 -07:00
Miguel Ángel bea32b8aae fix(studio): stop a Studio edit from reloading the preview (#3137)
* fix(studio): stop a Studio edit from reloading the preview as if it were external

Every mutation route wrote the file without leaving a write receipt, so the
watcher's broadcast of Studio's own edit arrived with no identity on it. The
external-change coordinator could not tell that echo from an agent or an editor
writing the file behind Studio's back, so it took the safe branch and did a full
iframe reload. That reload hides the stage for the length of the reload, which is
what the flash after a text edit was.

Every mutation write now goes through one helper that records the receipt, and
the client claims the write before the request goes out rather than after it: the
server writes and the watcher fires while the request is still in flight, so a
token marked from the response can arrive after the echo it was meant to match.

Reproduced in the browser before and after, with the reload path traced end to
end. Before, a patch-element write logged `token: null` then a reload from the
coordinator; after, the same write logs the token and `suppressed: own write
token`, with no reload.

Adds `hf-reload-debug` (localStorage, off by default) alongside the existing
`hf-resize-debug`: it records each file-change decision and its reason, plus the
stack of whoever asked for a full reload.

* fix(studio): claim the timeline and caption writes too, not just the DOM ones

The receipt only helps when the client marked the token it sent, and the GSAP
mutation writers never sent one. A drag commits through gsap-mutations, so the
server minted a token the client had never seen, the change came back looking
like someone else's, and the preview did the full reload the receipt was meant
to prevent.

Same one-line claim on both GSAP mutation writers, the timing sync's mutation
call, and the caption auto-save PUT.

The rollback call stays deliberately unclaimed and says why: it runs because a
mutation did not converge, so the preview is on bytes nobody can vouch for and
the reload is the point.

Verified live: a drag-shaped update-properties on the timeline now logs
`suppressed: own write token` with no reload, where it logged a coordinator
reload before.

* refactor(studio): keep timelineTimingSync under the size cap

Claiming the timeline writes pushed this file one line past the 600-line
gate. Same change as the branch made later, landed with the commit that
caused it.

* fix(studio): cover remaining write receipt paths

* fix(studio): preserve batch write receipts

* fix(cli): emit every file in a watcher burst
2026-08-09 19:10:02 -04:00
Miguel ÁngelandCodex 96861cbafc perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails

* perf(studio): bound thumbnail decoding resources

* perf(studio): virtualize timeline thumbnail media

* perf(studio): prioritize timeline thumbnail work

* perf(studio-server): coordinate cancelable thumbnail generation

---------

Co-authored-by: Codex <codex@local>
2026-08-05 20:41:35 -07:00
James Russo bbdfee1166 fix(engine): validate remote download integrity (#2938) 2026-08-04 18:02:01 -07:00
Vance Ingalls 71fd96bbf1 Merge pull request #2854 from heygen-com/feat/canary-rollouts
feat(core): percentage-based canary rollouts + calibration experiment
2026-08-03 22:35:37 -07:00
Miguel Ángel 1d01b9f2cf fix(studio): exclude generated caches from project metadata 2026-08-02 17:37:51 +00:00
Miguel Ángel 3eb7b1ffd5 test(cli): clarify watcher exclusion coverage 2026-08-02 17:25:17 +00:00
Miguel Ángel bf739a4db2 fix(cli): ignore waveform cache in project watcher 2026-08-02 17:19:51 +00:00