Commit Graph
931 Commits
Author SHA1 Message Date
Miguel Ángel 27f1cfd23e fix(lint): Studio's lint panel and the CLI label a finding by the composition it names (#4618)
* fix(lint): a missing image, audio or mask file names the composition that references it

* test(cli): a missing file is reported once per composition that references it

* fix(lint): Studio's lint panel and the CLI label a finding by the composition it names

* refactor(lint): drop a comment and unused first-entry lines from the grouping change
2026-09-28 06:43:16 +00:00
Miguel Ángel 7fedc689b8 fix: run the configured ffmpeg and ffprobe everywhere and say when an upload was not checked (#4611)
* fix(studio-server): ffmpeg and ffprobe run from the configured binary; unchecked uploads say so

* fix(studio-server): the upload result type lists unchecked files

* fix(media-use): spawn the configured ffmpeg and ffprobe, and name a missing configured ffprobe

* style: format the ffprobe path test, doctor spawns and upload check

* fix(studio): show the not-checked upload notice as info, not an error

* fix: a configured ffmpeg or ffprobe path that cannot run fails loudly

* fix: refuse a folder as the ffprobe path, and report broken paths through --json

* test: reuse the file manager test setup and split the upload stream check

* fix(media-use): a configured ffmpeg or ffprobe must run -version, and a relative one runs itself

* fix(media-use): a configured ffprobe path must name ffprobe, not another tool
2026-09-28 06:09:00 +00:00
Miguel Ángel fa044a89e5 feat(cli): tag telemetry and feedback with the app that launched the CLI (#4632)
* feat(cli): tag telemetry and feedback with the app that launched the CLI

* fix(cli): keep the client tag to a short slug and correct the telemetry docs

* test(cli): read the delivered batch through the shared helper

* refactor(cli): let the tag pattern alone bound the client tag's length

* docs(cli): describe the client tag generically as the launching app's tag

* style(cli): format the client tag test cases

* test(cli): use a neutral example value for the client tag

* chore(skills): regenerate the skills manifest for the telemetry reference
2026-09-28 05:11:11 +00:00
Miguel Ángel a380036903 docs(cli): render help says when page-side compositing is actually used (#4572)
* docs(cli): render help says when page-side compositing is actually used

* docs(cli): page-side compositing help names webm and mov and the fast-capture flag

* docs(cli): say plainly that gif keeps page-side compositing
2026-09-28 04:03:48 +00:00
Miguel Ángel 7eb00137a2 fix(lint): a missing image, audio or mask file names the composition that references it (#4616)
* fix(lint): a missing image, audio or mask file names the composition that references it

* test(cli): a missing file is reported once per composition that references it
2026-09-28 02:47:22 +00:00
Miguel Ángel 693c385508 fix(cli): commands exit right away even when the network or DNS is broken (#4604)
* fix(cli): commands exit without waiting on update checks or telemetry

The update and skills checks run in a detached child that refreshes the
caches for the next run; the parent only reads the caches. finalizeCli no
longer awaits the telemetry flush: the exit handler flushSync() delivers
what is queued. The events beacon, feedback and figma error paths stop
awaiting flush() too.

* fix(cli): retry a failed background check at most once an hour

Before starting the background worker, the CLI stamps a per-check attempt
time and skips a check it attempted within the hour, so a dead network no
longer starts a worker on every command. The stale-pin notice writes onto a
fresh read, detached spawns set windowsHide, and a test ties the worker path
to its build entry.

* fix(cli): a failed-check stamp dated in the future no longer silences update checks
2026-09-28 02:22:12 +00:00
Xuanru Li 09ab947e1a fix(layout): flag text a clipping card cuts off (#4577)
* fix(layout): flag text a clipping card cuts off

A short card with overflow hidden cut the words off, and the check
dropped text_box_overflow because that parent clips.
The text element's own box does not clip, so clipped_text stayed quiet.
A deliberate mask still clears with data-layout-allow-overflow.

* fix(layout): ignore a text line parked fully outside its box

A reel row that never enters the window was failing with the cut-off card.
Only a line that still meets the box and sticks out of it is an overflow.

* fix(layout): report visible text outside non-clipping cards

* fix(layout): distinguish line boxes from parked font extents

* test(layout): type browser audit test interfaces
2026-09-28 02:08:08 +00:00
Miguel Ángel 03e3755aaa fix(studio-server): background removal writes only where it checked, even through planted links (#4540)
* fix(studio-server): background removal writes only where it checked, even through planted links

* test(cli): read the cutout through a no-follow handle instead of checking then reading
2026-09-28 02:07:30 +00:00
Miguel Ángel 5cbef29d3a feat(cli): transcribe with Parakeet on macOS, Linux and Windows after an opt-in install (#4583)
* feat(cli): download Parakeet with models install parakeet and transcribe with it

`hyperframes models install parakeet` installs the sherpa-onnx runtime from
npm and the Parakeet TDT 0.6B v3 int8 model from Hugging Face, pinned to a
revision. Every file is checked against its pinned size and sha256 under a
temp name before it is renamed into place. Re-running it is a no-op. On
Linux with glibc older than 2.32 it refuses up front.

`transcribe --engine auto|parakeet` now prefers that engine when it is
installed, then an installed parakeet-mlx runner, then whisper. Audio is cut
into about 60 s windows at the quietest 100 ms near each cut, and decoding
runs in a child process. If the child cannot load the runtime or crashes,
transcribe falls back with one stderr line saying why. The JSON output and
transcript.json shape are unchanged.

* fix(skills): report the real word count and engine from transcribe fallbacks

The media-use transcribe script read `words` from the CLI's transcript,
which is a flat array, so its whisper path always reported an undefined
word count. The embedded-captions script labelled every CLI transcript
whisper.cpp even when `--engine auto` ran Parakeet; it now uses the engine
the CLI reports.

* fix(cli): pin the sherpa-onnx native package to the runtime's exact version

* test(cli): type the sherpa install test doubles

* fix(cli): transcribe audio with no speech as 0 words instead of failing

Parakeet writes an empty word list for a music bed, a silent intro or a
very short clip, and loading it failed with "Unrecognized JSON transcript
format". An empty list is now read as a transcript with no words, as
whisper's empty output already is. A non-empty list still has to look like
words.

* fix(cli): re-decode a Parakeet window that skipped the start of its speech

At some window lengths the model silently drops the first clause: on a
looped speech clip, 4 of 167 lengths between 55 and 60 s, and a 61.91 s
window, lost their first 9 words. A window with a token gap of at least 1 s
that is as loud as speech is decoded again with 0.5 s of leading silence,
and the result with more tokens wins. Clean windows are unchanged.

Decoding with 1.5 s of overlap before each cut was measured too and was
worse on 573 s of audio (2.62% WER against 0.98%), so it is not used.

Empty audio no longer makes a zero-length window, which aborted the
decoder.

* fix(cli): stop Parakeet on Ctrl-C and name the repair when its install breaks

- Ctrl-C during a Parakeet transcribe now stops with exit 130 instead of
  starting whisper, and a SIGTERM to the CLI also stops its decode child.
- Ctrl-C during models install parakeet aborts the download and the npm
  install, keeps no partial file, and exits 130. A staging dir left by a
  killed install is swept by the next run.
- When Parakeet fails under --engine auto, the warning leads with the
  Parakeet error and names hyperframes models install parakeet. If the
  fallback fails too, the Parakeet error is reported rather than
  whisper_unavailable, so --optional no longer hides a broken install.
- An explicit --engine parakeet no longer falls back: it fails with the
  Parakeet error and the repair command.
- The install refuses up front on systems with no native package
  (naming the six supported ones) and on musl, instead of a raw npm error.
- 24-bit and float WAV go through ffmpeg, since sherpa-onnx reads only
  16-bit PCM. The decode timeout scales with the audio length.

* docs(cli): point Parakeet setup at models install parakeet

The transcribe docs and the media-use references named parakeet-mlx as the
only way to get Parakeet. They now name hyperframes models install
parakeet, and the CLI reference gets a models section.

* test(cli): cover the npm cancel signal and decode timeout, and fix a doubled period

The Parakeet failure line printed "failed.. To repair" when the decoder's
own message ended in a period. Tests now also pin that Ctrl-C reaches the
npm install and that the decode timeout scales with the audio length.

* fix(cli): report unreadable audio as an input error, not a Parakeet failure

Prepare the WAV once before the Parakeet fallback and hand the same file to whisper,
so ffmpeg runs once. Ctrl-C while ffmpeg runs now ends as a cancel (exit 130)
instead of starting whisper.

* fix(cli): recover a clause Parakeet drops after a pause by decoding the gap alone

Each loud gap without tokens is decoded on its own with 0.5 s either side, and only
its new tokens are spliced in. The padded whole-window retry stays as the fallback.
A window with no tokens at all is no longer decoded twice.

* perf(cli): size the decode timeout from the WAV header instead of reading the file

* docs(cli): say a broken Parakeet install still fails transcribe --optional

* fix(cli): treat Ctrl-C during audio preparation as a cancel and drop the partial WAV

ffmpeg traps SIGINT and exits 255 rather than dying by the signal, so that status now
counts as a cancel unless the timeout fired. A failed or stopped ffmpeg removes the
temp WAV it was writing. The decode timeout now sizes a prepared WAV from its file
size, so a large header can no longer hide its length.

* fix(cli): splice a re-decoded gap by whole words so its neighbours are never doubled

The gap decode splits edge words into other tokens than the window decode did. Match
neighbours as whole words within 0.3 s of each gap edge, add only whole words that start
inside the gap, and decode the gap behind 0.5 s of silence like the padded retry.

* fix(cli): count a cancel only when ffmpeg says it received a signal

ffmpeg 7 and later also exit 255 when a file is not permitted (EPERM), so exit 255
alone read a permission error as a cancel. ffmpeg's stderr is now captured, a cancel
needs its received-signal line, and any other failure reports ffmpeg's last line.
The output path is passed to the shared runner explicitly.

* fix(cli): keep a spliced gap word that has no leading space

The filter dropped such a word; it is now kept and given its word boundary.

* refactor(cli): trim three long comments in the Parakeet decoder and installer

* fix(cli): repair a Parakeet runtime missing its native binary, and pick Parakeet by language

The runtime dir is per platform and arch. Installed now needs the runtime
manifest and the native sherpa-onnx.node; a half-installed dir is removed
before npm runs, since install() keeps any dir holding the runtime manifest.
The decode worker flattens a multi-line loader error onto its prefixed line
and stops between windows once orphaned. transcribe opens its cancellation
scope before audio prep, so Ctrl-C under --json cleans up. auto uses
Parakeet only for its 25 languages and only where the host can run it;
--engine parakeet with another language fails.

* fix(cli): sweep reused-pid staging, name failed model downloads, keep ffmpeg's cause

sweepStaleStaging also removes a staging dir older than 6 hours whose pid
is alive, since containers reuse pids. A failed model download names the
file, the host and a retry. runFfmpeg keeps ffmpeg's stderr on its error,
so transcribe shows "Output file does not contain any stream" for a video
without audio, and reads a cancel only from ffmpeg's own signal line.

* fix(skills): make media-use --engine whisper run whisper and report the engine that ran

The whisper path now passes --engine whisper when asked, or --engine auto,
and reads the engine from the CLI's --json line, so a CLI Parakeet run is
no longer labelled whisper. Docs state the language rule, the real repair
behaviour and the platforms Parakeet runs on.

* test(cli): pin that a long Parakeet decoder error is kept whole up to a bound

* fix(cli): report a broken Parakeet runtime with the repair instead of skipping it

* refactor(cli): name the staging age limit instead of commenting it

* fix(skills): let media-use --engine parakeet use the CLI Parakeet when parakeet-mlx is absent

* fix(cli): repair a Parakeet runtime that fails to load, and read Ctrl-C after whisper

models install parakeet now loads the runtime in a child process, the way
the decode worker does, and reinstalls it when that fails, so a missing
shared library, a corrupt binary or missing runtime JS is repaired. It
reports changed: false when the runtime loads. transcribe keeps its cheap
manifest check. After a failure, transcribe lets the event loop turn once
before reading the Ctrl-C scope, since a signal that landed during the
synchronous whisper fallback is only handled then.

* fix(cli): read Ctrl-C from the whisper child, and re-check the Parakeet runtime after a repair

A whisper fallback killed by SIGINT or SIGTERM is a cancel (exit 130), the
same rule decode and runFfmpeg use; the loop-turn wait is gone. The
runtime probe treats a child stopped by Ctrl-C as a cancel and keeps the
runtime; a reinstall is probed again and fails with the loader's error if
it still does not load. media-use keeps the CLI's last lines, stdout
included, when hyperframes transcribe fails.

* fix(cli): give the user-stop rule one owner, SIGHUP included, and end a cancelled install at 130

stoppedByCancelSignal in renderCancellation.ts uses the scope's own signal
list (SIGINT, SIGTERM, SIGHUP) and is false whenever Node stopped the child
(a code, killed, or a spawn error). decode, runFfmpeg, the runtime probe and
the whisper fallback all route through it, so a probe killed by SIGHUP keeps
the runtime. isWhisperTimeoutError now keys on ETIMEDOUT only, which Node
sets on execFileSync's own timeout, so a SIGTERM reaches the cancel rule.
A cancelled models install sets exit 130 and returns instead of failing, and
the probe says what went wrong (timed out, crashed, no output).

* refactor(cli): let the stop-rule helper name carry its meaning
2026-09-27 23:21:16 +00:00
James Russo 0a5e3c7fc4 feat(plugins): ship portable agent bundles and installation guides (#4608)
* feat(plugins): ship portable agent bundles and installation guides

* fix(plugins): align new manifests with current release

* test(plugins): split caption fixture setup and cleanup

* test(plugins): pass render fixture path as an argument
2026-09-27 21:58:51 +00:00
James Russo ff6e2103d7 fix(skills): keep plugin workflows on their installed release (#4607)
* fix(skills): keep plugin workflows on their installed release

* docs(skills): clarify plugin manifest discovery

* fix(plugins): suppress stale notices and isolate plugin context
2026-09-27 21:05:28 +00:00
Miguel Ángel 3db3da7bb2 fix(studio-server): history saved by 0.8.78 survives the update with every entry (#4601)
* fix(studio-server): history saved by 0.8.78 opens with every entry, and an unreadable id is never overwritten

* test(studio-server): a malformed history id is refused and kept, not replaced

* refactor(studio-server): name the 0.8.78 record check instead of commenting it

* docs(studio-server): hosts keep project histories in historyCache, which reopens a replaced folder

* fix(studio-server): an unreadable 0.8.78 record keeps its history, and another path to the folder is not a copy

* style(studio-server): format the 0.8.78 history tests

* fix(studio-server): name the history id error, and a recorded path that cannot be read is not a copy

* style(studio-server): format the 0.8.78 history fixture
2026-09-27 15:38:12 +00:00
Miguel Ángel 627e9476af fix(cli): a failed command sends one error report, naming its real cause (#4586)
* fix(cli): a failed command sends one cli_error, naming its real cause

* chore(cli): drop a comment the parameter name already says

* fix(cli): a failure caught by its own command is still reported, and only once

* test(cli): normalize-audio test passes the flags it requires

* style(cli): format the failure tracking test

* fix(cli): one function unwraps and reports a command failure, once

* docs(cli): shorten the command failure reporter's comment
2026-09-27 13:06:42 +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 2b1bb8b731 test(cli): CLI tests run with a home folder of their own, never the user's (#4587)
* test(cli): CLI tests run with a home folder of their own, never the user's

* style(cli): format the test home check

* test(cli): the skills test lane also runs with a home folder of its own

* style(cli): format the test home check

* ci: the test reachability map follows the skills lane's new command
2026-09-27 12:30:03 +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
Miguel Ángel 950cccdac0 test: cli and studio-server tests remove the temp folders they create (#4566)
* test: cli and studio-server tests remove the temp folders they create

* test(cli): each temp folder is removed where it is made, even when a test fails

* test(cli): the one-liner helper relies on its project's own cleanup
2026-09-27 04:06:05 +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 16dc05e9fb fix(cli): stop background update and skills checks from holding a finished command open (#4556)
* fix(cli): skip background checks in unattended runs and never hold exit on a silent DNS

* fix(cli): probe DNS only on background checks so foreground upgrade and skills commands still wait

* fix(cli): keep update checks for agents and skip them only for CI, dev and the opt-out

* refactor(cli): give the update-check opt-outs one owner shared with auto-install

* docs(cli): stop the auto-install notes claiming runs without a terminal are skipped

* fix(cli): run telemetry flushes one at a time so an overlapping flush never resends an event

* fix(cli): let a telemetry flush run even when the one before it threw

* fix(cli): cap exit wait at one telemetry attempt when an overlapping flush already failed
2026-09-27 02:05:49 +00:00
Miguel Ángel d290c05b09 fix(cli): --yes in an unattended run no longer overrides a no saved by another process (#4547)
* fix(cli): --yes in an unattended run no longer overrides a no saved by another process

* fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no

* fix(cli): the thin-results download offer also needs a real terminal

* fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes

* fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no

* fix(cli): a settings lock that cannot be released no longer fails a write that landed

* fix(cli): a leftover settings lock is never taken over, and doctor names it

* fix(cli): a background update that cannot be recorded is not launched again every run

* fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing

* fix(cli): the thin-results offer stops when a yes cannot be saved

* fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once

* fix(cli): the leftover-lock warning follows the attended-terminal rule
2026-09-27 01:39:17 +00:00
Miguel Ángel 76723c614c fix(cli): snapshot shows a short video's last frame instead of going black (#4552)
* fix(cli): snapshot holds a short video's last frame instead of going black

* fix(cli): snapshot falls back to a video's real last frame when a held tail lands past it
2026-09-27 01:05:56 +00:00
Miguel Ángel 984ad28c7c fix(core): clips that meet within float rounding count as meeting in edits and playback (#4535)
* fix(core): clips that meet within float rounding count as meeting in edits and playback

* fix(engine): hand a shared clip boundary to the incoming clip in export, as preview does
2026-09-26 22:14:14 +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
Miguel Ángel c238abfe6f fix(media-use): tests never write into the person's media library (#4459)
* fix(media-use): tests never write into the person's media library

* fix(media-use): guard the media home only in this repo's test runs

* test(media-use): probe the media home from a plain child process

* fix(media-use): keep miss logging best-effort when home is unresolvable

* test(media-use): miss logging survives an unresolvable media home

* docs(media-use): describe the media library without naming an app

* chore(media-use): refresh the skills manifest hash
2026-09-26 14:13:56 +00:00
Miguel Ángel 7c017a08de feat(studio-server): history can copy the files before or after any change into a folder (#4493)
* feat(studio-server): history can copy the files before or after any change into a folder

ProjectHistory.checkout(entryId, side, emptyDir) writes the project as entry entryId found it (before) or left it (after) into an empty folder, leaving the project and its history untouched, so a host can play a change's before and after side by side. The entry's own files come from its recorded change and the rest from the entries logged before it, so a change stays exact when entries were logged out of write order.

* fix(studio-server): checkout refuses an unreadable folder and every replay names its side

* fix(studio-server): checkout lays out only trees that existed and never writes into the project

An entry's tree now includes later-logged entries whose writes it continues (an outside change that turned a file into a folder before an agent turn edited inside it), each file replayed in the order its versions chain. Checkout refuses a folder inside the project (symlinks included), refuses a tree with a path that is both a file and a folder before writing anything, and empties the folder again if a copy fails. manifestAt is back to main's replay.

* fix(studio-server): history logs every change in the order it was written

A window now settles pending writes, the held claim and the outside group before it opens, and commits a held claim before its own entry when it closes, so the log order is the write order and every replay (checkout, restore, peek, reopen) sees files as they were. Removes the dependency replay added for checkout. Visible change: an outside burst that spans an agent turn is two entries, and a drag held across a turn ends at the turn.

* fix(studio-server): checkout recognises its project folder whatever letter case the path uses

* fix(studio-server): overlapping turns, operations and claims log every pending change in write order

settle() (a window opening, an undo, restore or step) and every claim commit the held claim, the outside group and each open window's pending changes as a part, oldest first; the windows stay open. A held drag ends when another write is pending. One commitPending owner replaces the claimed-paths-only cut.

* feat(studio-server): undo is per author, and a turn part undoes alone keeping later edits

* test(studio-server): pin per-author redo and name a person's open window on Cmd+Z

* refactor(studio-server): split Cmd+Z target lookup and claim steps under the audit's thresholds

* fix(studio-server): a file replaced by a folder logs its removal before the folder's files

* fix(studio-server): undo across a file/folder swap works, and a later swap stays the person's

* fix(studio-server): an undo blocked by a file added since refuses before touching any file

* fix(studio-server): an undo never writes through or replaces a link standing in its way

* fix(studio-server): undo and redo across a letter-case file/folder swap work in every direction

* fix(studio-server): a letter-case file/folder swap after an idle turn stays one checkable entry

* fix(studio-server): decide letter-case folding from the project's own disk

* docs(studio-server): keep three history comments under the line limit

* fix(studio-server): checkout checks and fills one resolved folder, so it never overwrites a nonempty one
2026-09-26 09:11:32 +00:00
Miguel Ángel 2be82f0099 fix(cli): use onnxruntime-node installed beside the CLI when it is the pinned version (#4507)
* fix(cli): use onnxruntime-node installed beside the CLI when it is the pinned version

An optional package (onnxruntime-node, @google/genai) installed next to
the CLI at the pinned version is now loaded from there, before the
~/.cache/hyperframes/optional copy and without an npm install. Any other
version is ignored and the cache path runs as before. doctor reports
that copy's version.

* refactor(cli): name the beside-the-CLI lookup instead of commenting it

* fix(cli): a bad optional package copy beside the CLI no longer breaks doctor

The lookup reads the first copy Node would load and treats an unreadable
manifest as not the pinned copy, so doctor and the loader fall back to
the cache instead of throwing or checking a different copy.

* fix(cli): let Node pick the optional package copy before checking its version

The beside-the-CLI lookup now asks require.resolve which copy it would
load and checks that copy's manifest, so an empty folder or a same-named
file earlier on the path can no longer make the check and the loader
disagree.

* test(cli): cover a symlinked optional package copy beside the CLI

* fix(cli): find a symlinked optional package when Node preserves symlinks

Under --preserve-symlinks require.resolve returns the symlink path while
the candidate folder was compared by its real path, so a pinned copy
Node loads was rejected. Both sides are now real paths. A test runs the
lookup in a Node subprocess with --preserve-symlinks.
2026-09-26 01:38:48 +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 3746c9821a fix(cli): timeline ids never writes over a save that lands while it stamps (#4494)
* fix(cli): timeline ids never writes over a save that lands while it stamps

runIds now passes each file's version to applyFileMutations, like every other timeline command, so a save that lands while ids are minted makes the command stop and roll back instead of being replaced by the stamped old content.

* fix(cli): timeline ids refuses a conflicting save with the same JSON refusal as other timeline edits

* fix(studio-server): a refused file batch never rolls back over another writer's save

applyFileMutations checks every file's version before its first write, so a batch that is already stale writes nothing, and a rollback restores only files that still hold this batch's write; a file whose own write failed is still restored.
2026-09-25 18:37:09 +00:00
Miguel Ángel 2734ee2c02 feat(cli): hyperframes history lists, undoes and restores a project's recorded changes (#4449)
* feat(cli): hyperframes history lists, undoes and restores a project's recorded changes

* fix(cli): an agent's history turn ends after 10 idle minutes on every path

A turn left open no longer takes the person's later edits: the engine
files a CLI turn's writes by the same idle rule as a preview window.
Studio claims take their paths back from an agent's window; refusals,
--json shapes, --limit and time parsing follow the review.

* refactor(cli): the project-history opener lives in its own module

The history command and other commands open a project's history the same
way: through a running preview, or directly under the engine's lock.

* docs(skills): the history loop lines sit in one marked trial block per skill

* fix(cli): a turn marker without its last write time has ended

Idle-limit tests set file times instead of racing the clock, and the
restore refusal test picks a ref no entry id starts with.

* docs(cli): the history entry shape comment names no other app

* refactor(cli): history reuses the busy error class and its who helper

* fix(cli): undo --who reverts the agent's whole last turn and nothing older

When the open turn recorded nothing, undo fell back to the agent's
earlier turn; when a mid-turn command split the turn, it reverted only
the newest part. The turn now keeps the entries it filed, and undo --who
reverts exactly those or refuses. Engine tests pin the write timing
rules, and a date that rolls over (Feb 31) is refused.

* fix(cli): an undo --who that meets a conflict leaves the whole turn as it was

It redoes the parts it already undid, lists the newer entries as they
are now, and offers --just-this and --back-to-before for the whole turn.
The last turn is recorded atomically before its marker is cleared.

* chore(cli): history keeps each file's comment share at or under main's

* fix(cli): history keeps log order past a turn's idle limit and undoes a turn's cut parts

* fix(cli): history drops unused imports and splits undo and reopen steps
2026-09-25 14:26:09 +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 fba943ea3a fix(cli): render --quiet stops printing producer info and trace lines (#4478) 2026-09-25 10:39:01 +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 7c634426fd docs(cli): explain how catalog search works and which model it downloads (#4462)
* docs(cli): explain how catalog search works and which model it downloads

* docs(cli): name the model catalog search was chosen against

* docs(cli): keep only live rules in the word-search header comment

* docs(cli): correct the hosted vector size in the local vector build comment
2026-09-25 04:43:54 +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 8af615a48e fix(cli): catalog --json carries each item's preview video and poster (#4443)
The catalog already loads every item's manifest, preview included, but the
--json rows dropped it, so an app listing the catalog had no way to show an
item before add downloads it. Rows now carry preview when the item has one.
2026-09-24 18:14:13 -04:00
Miguel Ángel 359106aa3a fix(cli): the block snippet from add carries the block's own composition id (#4432)
* fix(cli): the snippet hyperframes add prints for a block passes hyperframes check

* fix(cli): the block snippet carries the id the block's own root declares

* fix(cli): warn when an added block declares no composition id for its snippet

* fix(cli): add <tag> --json reports each block's warnings
2026-09-24 17:39:13 -04:00
Miguel Ángel 7556b95ac6 feat(cli): warn when canvas content touches the frame edge (#4427)
* feat(cli): warn when canvas content touches the frame edge

* test(cli): pin which frame edge the canvas warning reads

* test(cli): type the canvas edge stub like the file's other getContext stubs
2026-09-24 16:04:44 -04:00
Miguel Ángel 8dabc8af63 fix(registry): glass-shard-title renders when mounted, and 4 skipped blocks install again (#4423)
* fix(registry): glass-shard-title renders when mounted, and 4 skipped blocks install again

* fix(registry): glass-shard-title loads in Studio and ?play, and its docs payload fails loudly

* test(scripts): pin the glass payload's hdr guard for any quote style
2026-09-24 13:08:15 -04:00
Miguel Ángel fed45bac7c fix: serve projects and assets whose names contain @ % # & or ? (#4415)
* fix(studio-server): serve projects whose names contain @ % # & or ?

Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.

* fix(studio-server): thumbnails and waveforms stay inside the project for any name

The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.

* fix(cli): play and present serve assets whose names contain @ % # & or ?

Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.

* fix(studio-server): a thumbnail request for the project folder answers 404

An empty thumbnail path resolved to the project directory and crashed reading it (500).

* fix(studio-server): read the thumbnail's composition with one open

Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.
2026-09-24 10:38:56 -04:00
Miguel Ángel 7e8d2bd1f3 fix(cli): stop check --at false alarms and name a stale CLI version (#4417)
* fix(cli): stop check --at failing still frames as a frozen timeline

* fix(cli): warn when the running CLI is older than the project's pin

* fix(cli): keep judging spread samples when check --at is combined with other gates
2026-09-24 10:09:21 -04:00
Miguel Ángel 6c9216e8fd fix(producer): report render progress from the first frame, at most four times a second (#4414)
* fix(producer): report render progress from the first frame, at most four times a second

Capture loops reported every 30th (or 10th) frame, or every single frame, so a render sat silent through browser warm-up and the first 30 frames, then flooded on the sequential paths. One helper now owns the cadence for every capture stage: first frame, last frame, and every 250 ms in between. Browser GPU check, capture calibration and worker start-up each report their own step.

* fix(producer): always report the first captured frame and keep one telemetry code for browser start-up
2026-09-24 08:46:45 -04:00
Xuanru LiandCursor 549a2f51bb fix(layout): flag nowrap text that still sticks out of a container that does not clip (#4379)
* fix(cli): flag nowrap text that still sticks out of a container that does not clip

Cause: container_overflow only inspected containers that clip, so overflow:visible
removed the box from the check while nowrap text stayed wider than that box.

Rationale: a nowrap text child is still outside its container when the container
stops clipping. data-layout-allow-overflow still clears the finding.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(layout): ignore font-height spill and wrapping siblings

A container that does not clip was checking every child on both axes
once any child refused to wrap. Font-height spill and a wrapping sibling
then looked like overflow.

* fix(layout): skip an empty decoration next to a nowrap label

The nowrap-only check looked at whitespace and not whether the child
had text. An empty decoration inherited nowrap and was reported as overflow.

* fix(layout): flag nowrap text only when it is wider than the box

A narrow label that sits or slides outside a box that does not clip
was reported as overflow. Only a line wider than that box is overflow.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 01:29:11 +00:00
Xuanru LiandCursor 625bdd66fa fix(layout): keep covered text flagged when the occlusion waiver is on a wrapper (#4380)
Cause: text_occluded walked from the covered text up to any ancestor with
data-layout-allow-occlusion, so a mark on a wrapper cleared text a sibling
node was still covering.

Rationale: the waiver still clears the finding when it is on the covered text
itself. A mark on an outer wrapper no longer does.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 01:28:42 +00: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 0aa3cb82aa fix(cli): add --vars refuses a value of the wrong type instead of writing it (#4369)
* fix(cli): add --vars refuses a value of the wrong type instead of writing it

* fix(cli): a refused --vars writes none of the item's dependencies either

* fix(cli): keep a file edited while add was still downloading the rest of the plan

* style(cli): shorten the --vars refusal comment
2026-09-23 19:29:16 -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
d49e7b95e0 feat(media-use): resolve brand logos from theSVG (#4291)
* feat(media-use): resolve brand logos from theSVG

Replace the svgl and simple-icons tiers of the `logo` cascade with a
single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand
SVGs with color, mono, light/dark and wordmark variants.

The tier fetches one manifest pinned to a glincker/thesvg commit on
jsDelivr, memoizes it per process, and matches entities locally by
normalized slug, title, or alias. That drops the hand-kept svgl alias
and simple-icons slug maps. GitHub org avatar and favicon stay as the
fallbacks for brands theSVG lacks.

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

* fix(media-use): sanitize fetched SVGs and gate theSVG by license

freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized;
the theSVG tier is now the sole SVG-returning logo provider, so an
inline script tag or on-event handler would land verbatim in a user
project. Route .svg destinations through the existing Figma sanitizer
(new @hyperframes/core/sanitize-svg subpath, synced via
package-subpaths).

thesvgMatch never checked theSVG's own license field: entries theSVG
itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an
explicit no-redistribution-license string) could still resolve and
freeze into a project. Gate matches to the ten SPDX ids theSVG's
submission form accepts; a rejected entry falls through to a lower
ranked theSVG match, then to the github/favicon tiers.

Also caps the theSVG manifest fetch at 20MB, streamed, matching
freeze.mjs's existing cap discipline. It had a timeout but no bound.

* chore(skills): regenerate skills-manifest for media-use/motion-graphics

The original submission edited skills/media-use and
skills/motion-graphics docs without regenerating skills-manifest.json;
its own CI never ran (fork approval gate), so the drift went
unnoticed until this pass's first CI run caught it.

* fix(media-use): drop the sanitizer's workspace import, add parity test

freeze.mjs importing @hyperframes/core/sanitize-svg broke the
test-skills CI lane: packages/cli/src/media-use tests run with node
builtins only, zero install, by design (media-use is vendored
standalone into other projects, e.g. packages/desktop). Port the
sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the
now-unused core export.

Parity between the port and packages/core/src/figma/sanitizeSvg.ts is
enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs,
run in the test:scripts lane, which does have an install) over a
hostile-SVG corpus, so the two cannot drift silently.

* fix(media-use): close three sanitizer bypasses, add fetch timeout

Independent review of the maintainer pass found the SVG sanitizer
(packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own
port) was itself bypassable: an unquoted href/xlink:href survived,
inline style="..." attributes were never checked, and a href bound
through a renamed XML namespace prefix (xmlns:x=".../1999/xlink",
x:href=...) slipped past the literal "xlink:href" match.

Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of
literal-spelling regexes: parse each tag's attributes in any quoting
form, resolve the local name after stripping any namespace prefix,
then drop by local name (any on* handler, any *href whose value is
not a #fragment or data:image/ embed, any style attribute containing
url(, @import, expression( or javascript:). Ported the fix into
media-use's svg-sanitize.mjs identically. Legitimate url(#id)
references on non-style attributes (clip-path, fill) are untouched.

Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching
every other fetchMedia call in this package - it had a size cap but
no deadline.

New cases for all three bypasses in both sanitizeSvg.test.ts and the
parity corpus, each reproducibly failing against the prior head.

* fix(media-use): replace regex SVG sanitizer with a DOM allowlist

Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses
in the same shape each time (unquoted href, style= url(), aliased
xlink prefix; then presentation-attribute url(), unquoted '=' handling,
SMIL animate/set href rewrite, self-closing-slash tag corruption,
data:image/ navigation via <a>). Denylisting attribute names against a
hand-rolled tokenizer was the wrong approach regardless of how careful
the regex.

Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real
DOM walk (linkedom, already a dependency of core and cli) with an
allowlist: known-safe elements and attributes only, everything else
dropped by construction rather than pattern-matched as dangerous.
href/xlink:href (any namespace prefix) kept only for a same-document
fragment, or data:image/ off non-<a> elements. Any attribute value or
<style> text containing url() kept only when every url() argument is a
fragment; @import/expression(/javascript: dropped from style content.
Serializing from a real DOM means output is always well-formed, so the
self-closing-slash corruption class of bug is gone by construction.

media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs)
can't import linkedom directly: its CI test lane runs bare `node --test`
with no install step, so even an external dependency fails to resolve,
not just a workspace import. It's now a generated file instead of a
hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core
source (esbuild, minified, zero remaining imports) and
scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build
on drift. Verified the generated file runs with plain node in an empty
directory with no node_modules on the path.

Every payload from both independent-review reports is now a
table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus
in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the
generated media-use copy agree and each independently rejects every
non-local url(), javascript:, data: href on <a>, and on* handler.
Mutation-proved against the real pre-fix blob, not a synthetic case.

Sampled 56 real theSVG icons across all 7 collections to check the
allowlist doesn't over-strip legitimate content: found and fixed three
missing attributes (fx/fy, enable-background, a blanket data-*
allowance) and one real behavioral bug - <style> elements were being
dropped outright, silently unstyling any Illustrator-exported logo that
colors paths via CSS classes. Fixed by keeping <style> on the allowlist
and content-checking its text the same way as an inline style=
attribute, with a regression test using that exact shape.

Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests
39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean.

* fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint

Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs,
generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's
correctness rules 504 times and fails `bun run lint`. Same shape as the
existing registry/blocks/**/*.iife.js exclusions: a generated, minified,
non-hand-edited bundle, not hand-written source.

* fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt

Same generated esbuild bundle that needed an oxlint exclusion in the
previous commit also fails  (minified, no formatting to
check against), cascading Preflight into Player perf, regression,
Windows render verification, and preview-regression on CI. Same
established pattern as the existing registry/blocks/**/*.iife.js and
skills/**/*.min.js entries in .prettierignore.

* refactor(media-use): reduce complexity flagged by fallow audit

isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker
check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's
per-function thresholds after the rebase pulled in a stricter baseline.
Extracted isXmlnsAttribute/isKnownAttribute as named predicates and
survivingUnsafeMarkers as a named per-output helper; no behavior change,
same guard order, same failure strings. Regenerated svg-sanitize.mjs.

* refactor(media-use): make the parity test's marker check table-driven

Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each
function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers
still scored 9 (CRAP 90). The four regex markers are now a table, and the
data:image-on-<a> and non-local url() checks are small named helpers.
Same checks, same order, same failure strings.

* fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg

The url()/@import checks scanned raw text for the literal "url(" and
"@import". A browser decodes CSS escapes first, so fill="\75 rl(...)",
url\28...\29 and @\69mport all loaded an external resource, and
image-set("https://...") loads one with no url() at all. Reproduced in
headless Chrome: 8 of 8 payloads fired a request through the old code.

CSS can only load a resource through a function or @import, so CSS text
now passes only if it has no @import, every function is on a compute-only
allowlist (color, transform, filter and math functions), and every url()
is a same-document fragment, all checked on escape-decoded, lowercased
text. Same fix reaches media-use through the regenerated bundle.

Checked against all 13,384 files of the pinned theSVG corpus: 13,383
sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)".
The parity oracle now fails on any surviving attacker host, so it no
longer shares the sanitizer's parsing blind spot.

* refactor(media-use): share one capped body reader for freeze and manifest

fetchJson copied freezeUrl's declared-length check and streaming size cap
line for line. Both now call readCappedBody in media-fetch.mjs, which both
already import; it has its own tests, since freeze's cap had none. The
freeze cap errors now read "freeze failed for <url>: ..." (no caller or
test matches the old wording). Also fixes freeze.mjs's comment, which
still called the sanitizer a lexical pass.

* style(media-use): format the parity test corpus

* fix(media-use): make the sanitizer's CSS scans linear on hostile input

The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function
check /([-\w]*)\(/: on attacker padding these backtracked cubically and
quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000
character word took 7.7s), and freeze runs the sanitizer synchronously on
bodies up to 256MB. Both are now single-pass indexOf scans (a million
characters in under 15ms), with the same verdict on ordinary input. A url()
value holding "(" is rejected, so a nested url( can't hide inside one.
A size test holds both payloads under 2s.

* test(media-use): size payloads that fail the old scans fast instead of hanging

* fix(media-use): stop the freeze timeout from aborting long downloads

The 10 s AbortSignal.timeout on freezeUrl also aborted the response body,
so any stock video, generated video or LUT that took longer than 10 s to
download failed. The timer now covers only the wait for response headers
and is cleared once they arrive; the 256 MB cap still bounds the body.

* refactor(media-use): trim logo-provider comments to the repo limit

The tier list and the thesvgMatch doc block ran past the four-line comment
cap that the merge check enforces. Same facts, shorter blocks.

* refactor(media-use): name the header wait and restore the variants note

Renames the freeze timeout controller to headerWait so it no longer reads
as HTTP headers, puts the theSVG variants fact dropped by the comment trim
next to the line that picks the default mark, and keeps thesvgMatch on the
file's JSDoc style.

---------

Co-authored-by: Imad Phelps <imad.phelps@heygen.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-23 16:56:16 -04:00