* fix(cli): check and snapshot load GSAP from the bundled copy, not jsDelivr
* test(cli): bundled GSAP loads on a page whose own requests interception answers
* fix(cli): preview stops at once on Ctrl+C while a Studio tab is open
* fix(cli): a closed Studio tab leaves no timer that holds a stopping preview open
* fix(cli): name the tab-closed wait instead of commenting it
* test(cli): the tab-disconnect test checks the route's own keep-alive timer, not every timer
* docs: links point at hyperframes.studio, the app's new home
* docs: the registry guide publishes to hyperframes.studio
* chore: format README table, regenerate the skills manifest
* docs: leave the browser editor's card wording for its own change
The file-change test fetches an asset and never reads the response, so the file stays
open and Windows fails the later folder move with EPERM (it failed a merge-queue run).
The helper now reads each body to its end.
* feat(engine): encoders cap peak bitrate and place keyframes on a set interval
* style(engine): shorten the rate-cap comment
* style(engine): drop the doc lines the comment ratchet flagged
* feat(producer): mp4 renders honour a keyframe interval and a peak bitrate cap on every encoder path
* style(producer): shorten the delivery encoder file headers
* style(producer): drop a comment line from the segment manifest
* style(producer): shorten the integration test header
* test: delivery encoder tests follow the ffprobe argv contract and run in the integration lane
* feat: the render request, server and Lambda plan carry the keyframe interval and peak bitrate cap
* fix(studio): the Studio render route passes the keyframe interval and bitrate cap to the encoder
* chore(studio-server): drop a comment the helper's name already says
* feat(cli): render and lambda render take --keyframe-interval, --max-bitrate and --buffer-size
* chore(cli): tag the studio download link with where it was shown
The terminal hint, hyperframes open and the browser editor button now add
src=cli or src=browser-editor to the download link. The download route
already records src, so these downloads stop landing in no source.
* chore(cli): drop the DownloadSource doc comment the type name already says
* chore(cli): leave the no-build fallback link untagged, since only the download route reads src
* fix(producer): convert HEIC images to JPEG at compile time
Tiled HEIC photos probe as several video streams, so the media-type
preflight rejected them as image:video, and Chrome cannot decode HEIC.
Sniff the ftyp brands of every <img>, join and rotate the tile grid with
ffmpeg, verify the declared size, and swap in a cached JPEG.
* fix(producer): shorten the HEIC prep comments
* fix(producer): name host tool failures and keep a failed HEIC preview image as authored
Also: HEIC tests run in the integration lane; the primary grid is picked by default disposition.
* chore(producer): raise the package size budget for the HEIC prep
* feat(studio): load GSAP from the CLI's bundled copy by default
Studio preview now loads the GSAP, CustomEase and MotionPathPlugin scripts
it adds from files the CLI ships and serves same-origin at /api/vendor/npm,
instead of jsDelivr. HYPERFRAMES_GSAP_BASE_URL still wins when set, and a
composition on another GSAP version gets its plugin from jsDelivr.
Renders now honor HYPERFRAMES_GSAP_BASE_URL when they replace a missing
local GSAP script, and the setting accepts plain http only for loopback
hosts.
Closes#6205
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* refactor(studio): keep comment share flat for the GSAP local root
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): find the installed gsap with node resolution when run from source
The Windows CI lane installs with the hoisted linker, so gsap is not under
packages/core/node_modules there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(producer): load a nested composition's missing GSAP from the mirror
A sub-composition's missing local gsap/dist script was hoisted into the
render unchanged, so HYPERFRAMES_GSAP_BASE_URL (and the jsDelivr fallback)
only reached the entry document. Run the same rewrite on every
sub-composition before it is compiled, resolving its path the way the
inliner will hoist it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Carry the runtime's blur through the render pipeline and the Studio render
panel.
- engine: installPageMotionBlur sets the page-side off switch and the render's
device scale before any author script runs; CaptureOptions.pageMotionBlur
lets a caller stand the page blur down.
- producer: a composition that asks for data-motion-blur on the root the
runtime would pick forces screenshot capture, because html-in-canvas needs
a real paint; the probe session carries the request's pageMotionBlur like
every other session.
- studio-server and Studio: the render panel sends previewMotionBlur, the
render route turns "off" into pageMotionBlur once, and the Studio dev server
and the CLI server both receive the final value.
- mover scan: a large element that is see-through while it moves (a sweep, a
light leak) is no mover, including a large image or canvas with alpha; the
scan skips elements moved by a 3D individual transform property.
- cli snapshot injects video frames before the seek settles, matching the
engine's order.
Folded review fixes: R1, R2, R3 (scan half), R7, R16, R18, T3 (render
adapters).
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(cli): check, snapshot and Studio read project text as UTF-8
The preview servers sent HTML, CSS, JS and JSON with no charset, so Chrome
read a page without <meta charset> as windows-1252 and stills showed garbled
non-Latin text while the render, whose file server already sends utf-8,
was correct. The shared MIME table now carries charset=utf-8 for text types,
and the two hard-coded headers use it.
* fix(cli): snapshot shows accented text when the page's meta charset sits outside its head
A <meta charset> past Chrome's first 1024 bytes and outside <head> is ignored, so with no
charset in the header the page decoded as windows-1252. The browser test serves such a page
through the static server snapshot uses. play compares the media type without its charset,
so JSON assets keep their content-hash revalidation.
* perf(cli): commands run without esbuild, puppeteer-core or sharp until they need them
A host that bundles the CLI can now leave these packages out: each command
loads them only where it uses them, and sharp installs once on first use when
it is missing. The build check fails on any static import of them again.
* fix(cli): use a host's sharp from NODE_PATH and let hosts turn first-use installs off
* fix(cli): capture fails when sharp is missing, and one install serves every caller
A missing sharp now stops the asset download up front instead of counting every
image as unavailable. Concurrent first uses share one install, a failed install
is not retried in the same run, and preview thumbnails report why. Hosts can turn
off installs for a list of packages, e.g. HYPERFRAMES_NO_OPTIONAL_INSTALL=sharp.
* fix(cli): snapshot --describe still works without sharp, resizing only when it is installed
* style(cli): format the sharp loader and its tests
* build(cli): sharp is an optional dependency, so a host can ship the CLI without it
* build(studio-server): keep sharp out of the bundle now that it is optional
* fix(studio): a lost preview server or a superseded edit no longer files as a crash
* fix(studio-server): a render's scratch files no longer stream changes or list as compositions
* feat(studio): the header's desktop-app button says Install the desktop app and never dead-ends
The button now reads "Install the desktop app" beside Framey drawn as the app
draws it. Where the app is installed, a press opens this project in it; a
folder preview serves from a bare index.html gets its hyperframes.json first,
so the app no longer refuses it. Without the app, the button links straight to
hyperframes.dev's download, and a failed open lands there too. Platforms with
no build of the app show no button. Telemetry now records eligibility, one
impression, the click and the press outcome, with OS, app installed and
download available.
* feat(studio): the desktop-app button records eligibility, impression, click and outcome
* feat(studio): a desktop-app press carries its page id to the download and the app hand-off
* chore(studio): describe Framey's drawing without internal names
* feat(studio): the desktop-app button opens a guide that walks download, install and open
Without the app, the header button opens a card where Framey walks three steps: the
download opens in a new tab, the install is found by asking the preview server while the
card is open, and the project opens through the existing hand-off. A failed hand-off shows
the folder to copy and Try again. With the app ready the press opens the project directly.
Each step change is recorded through trackStudioEvent; a download is never counted as an
install.
* fix(studio): the desktop-app guide counts one install per page
The app removed and found again on the same page recorded install_detected twice; a page
latch keeps it to one, as the telemetry contract says.
* fix(studio-server): send Studio's history edits to the process that owns the project's history
* fix(studio-server): a forwarded history call gives up after 5 s, so a stopped owner cannot hang
* feat(cli): render telemetry reports how busy the machine's CPU was
render_complete and render_error now carry system-wide CPU busy share (average
and busiest 2 s window), cores busy in that window, and the one-minute load
average at start and end, for CLI and Studio renders. Nothing is sampled when
telemetry is off.
* test(cli): render tests use a private project folder instead of /tmp/project
* feat(studio-server): accept a crf override on the render route
The Studio render route only forwarded draft/standard/high, so a Studio or
desktop export could not ask for a specific CRF the way `hyperframes render
--crf` can. Film-grain finishing passes need CRF 12 to keep the grain.
The route now takes an optional integer `crf` (0-51 for mp4, 0-63 for webm),
rejects an out-of-range value with 400 instead of dropping it, ignores it for
mov, and both Studio adapters pass it to the render job.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore(studio-server): keep the crf change free of new comment lines
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(studio-server): accept videoBitrate, videoFrameFormat and hdrMode on the render route
Same options `hyperframes render` already takes (--video-bitrate,
--video-frame-format, --sdr/--hdr), so Studio callers are not limited to
the quality presets. crf and videoBitrate are an either/or, matching the CLI;
an invalid value is a 400. Parsing moves to helpers/encoderOptions.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(studio): export can encode on the GPU, like render --gpu
* test(studio): the 4K export assertion carries the GPU flag
* refactor(studio): the GPU flag needs no comment beyond its name
* fix(studio-server): only MP4 exports encode on the GPU, and render errors report it
* fix(studio): the disabled GPU option dims like the other export controls
* fix(cli): snapshot, check, layout and thumbnails honour software WebGPU like render
* test(cli): stub the local WebGPU helper in the studio server tests
* fix(engine): webgpu compositions are recognised after the bundled runtime script
* fix(engine): a hung GPU probe falls back to software in 15 s instead of 120 s
* fix(engine): the GPU probe keeps the configured launch timeout and bounds only its page
* build(parsers): declare the html-attribute-spans subpath in its descriptor
* build(gcp-cloud-run): the BeginFrame contract stage copies parsers, which the engine now imports
* test(cli): the screenshot launch pin follows the shared local WebGPU helper
* fix(engine): the GPU probe's wait for Chrome's first page falls inside its 15 s limit
* fix(lint): check passes valid sub-compositions and scene-relative asset paths
* fix(lint): keep the browser lint build free of node modules
* refactor(lint): tidy sub-composition lint helpers and guard bare node imports
* fix(lint): resolve stylesheet asset paths from the stylesheet's own folder
* fix(lint): catch every written form of a path that climbs out of the project
* fix(parsers): sub-composition asset paths written with backslashes load in preview
* fix(parsers): an encoded backslash stays in the file name and a leading backslash stays absolute
* fix(lint): an encoded %2f counts as a separator when checking for a climb out of the project
* fix(studio): serve files whose name has a bare percent sign
A browser sends src="100%.png" with the % unescaped, and decoding the whole path threw, so Studio answered 500. Only well-formed escapes are decoded now.
* fix(cli): serve files with a bare percent sign from the check and snapshot server
One shared decode rule for request paths; the image thumbnail route's malformed-escape 400 could no longer fire, so a name that is not a file is a 404.
* fix(cli): name a failed file with a bare percent sign in check
* fix(studio): keep apostrophes and dollar signs in file names when editing attributes
An attribute value is matched by its own quote kind, so src="it's.png" is replaced instead of duplicated, and every insert uses a function replacer, so $1 or $& in a file name is written as is.
* fix(studio): rename references spelled with %20 or & and read quoted media sources
WIP: not yet tested; see the resume note.
* fix(studio): match whole attribute names when editing media sources
* fix(studio): compare decoded names and escape replacement references
* fix(studio): preserve file references across URL and string consumers
* docs: clarify raw file references and URL fields
* fix(studio): keep raw path identity distinct from URL spelling
* fix: share attribute grammar and decode media URLs at their readers
* fix: preserve local filename identity in HTML URL output
* fix: decode entity references before rename candidate matching
* style: format rename entity handling and regressions
* fix: preserve platform file identity when renaming references
* fix(core): decode CSS escapes before rebasing asset URLs
* fix(core): consume CSS continuations before URL encoding
* fix(core): preserve CSS URL identity and escape rebased suffixes
* fix(core): express CSS suffix controls with Unicode categories
* fix(core): preserve URL backslash identity while rebasing
* test(core): respect platform backslash filename semantics
* fix(studio): a dropped file with %, # or ? in its name loads, and an encoded reference renames
* fix(studio): read an authored src as a URL so dropped files with spaces, %, # or ? load
* fix(studio): media routes find files named with ? or #, and the probe reads a src as a URL
* fix(studio-server): one rule for request file paths, so loudness finds files named with %, # or ?
* chore: drop the design note from the change
* fix(core): rename replaced clips whose src is URL-encoded
* fix(studio): recover previews after missed file changes
* fix(studio): keep queued reloads with the current owner
* fix(studio): keep a queued outside change when switching compositions
* test(cli): the project watcher closes its parent folder's watch too
* fix(studio): keep the project-folder change out of the shared signature check
* fix(studio): keep the edited file's draft and reload every session after a reconnect
A reconnect or replaced folder is a project-wide change: every open SDK session reloads, a failed save is recorded against the file the draft belongs to, and two queued changes to different files merge into one project-wide reload.
* fix(studio): refresh the preview cache when the .hyperframes folder is replaced
* fix(cli): report the files of a folder moved into the project
Replacing a populated folder such as .hyperframes/ now reaches the signature cache through its files, so the shared signature rule stays as on main and Studio's own writes inside .hyperframes/ still invalidate nothing.
* refactor(studio): name the merged file-change payload and share the test sender
* fix(studio): keep a failed draft's recovery copy until that file itself saves
* style(studio): rewrap the merged payload line after the rename
* fix(studio): deliver a held change's full reload when its block clears
A clean save, Use external and Keep Studio now all deliver the merged scope a blocked change owes, and a moved-in .hyperframes folder reports its manifests so a dropped one still clears the cached signature.
* fix(studio): keep restored drafts out of the reload scope and clear the lint gates
* fix(studio): retain held reload scope before React renders
* 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
* 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
* 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
* 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
* 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
* 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>
* 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>
* 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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
* 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.
* 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
* 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.
* 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