* fix(studio): playback keys do nothing while a dialog is open
With a modal dialog open over Studio (the lint results, for example),
Space, the arrow keys, J/K/L, M, I/O and A/E still reached the player,
so the film behind the dialog played and seeked.
The playback shortcut filter now ignores keys while a visible modal
dialog is in the page: an aria-modal dialog or a native dialog opened
with showModal, wherever focus sits. Dialogs left mounted but hidden,
display:none or inert do not block keys.
* fix(studio): a dialog hidden behind fullscreen preview leaves playback keys working
* test(studio): the no-checkVisibility case runs without an earlier stub
* perf(core): playback housekeeping runs on a timer, and rebinds stop re-sampling silent media
While playing, the transport re-posted the clip manifest every 20 frames and
re-bound media every 30, frame-counted, so a 120 Hz display did both twice as
often for a film that was not changing. Playing, those jobs now run when the
composition changes (rate-limited as before) and on a one-second poll for
what nothing can push.
A rebind re-probed every media element's volume by seeking the whole timeline
60 times per second of clip, about 1,500 seeks and 50-140 ms in one frame.
The probe seeks with events suppressed, so only a tween on the element that
names volume can move it; an element no tween touches is now answered from
the tweens without seeking.
* fix(core): a data-width or data-height change applies while playing and parked
postTimeline applies the root's sizing, and it now runs when the composition
revision moves. The revision only watched timing attributes, so a size edit
waited for a pause while playing, and on main it was missed while parked
too. The observer now also watches data-width and data-height. The tick
comment is trimmed to three lines, the tick reuses its playing flag, and the
cadence test is renamed to what it checks now.
* test(core): share one 120 Hz frame step in the transport park tests
The two playing tests each defined the same frame helper; one frame120Hz
now sits beside settle and all four loops use it. No source change.
* refactor(core): drop the playing poll doc comments
The comment ratchet does not let a file's comment share rise; the reason
lives in the PR body.
* fix(core): a playing clip's track, label and z-index reach Studio again
The playing path had dropped the periodic manifest post, and the
composition observer does not see data-track-index, data-timeline-label or
an inline z-index on an existing clip (watching style would fire on every
GSAP frame). On the playing poll the runtime now builds the manifest and
posts it only when it differs from the last one posted, so an edit lands
within a second and a steady film still posts nothing.
* fix(core): keep sampling volume when a tween drives a non-DOM object
A tween on a plain-object proxy whose setter writes the element's volume
names neither the element nor volume, so the fast skip dropped it and
WebAudio-routed preview played the static data-volume (0 where the film
asks for 0.375). The skip now applies only when every tween targets DOM
nodes; any other target falls back to the full volume probe, as on main.
* fix(core): skip the volume probe again for spacers, calls and counters
The non-DOM fallback fired on any plain-object or function target, so a
tl.to({}, {duration}) spacer, a tl.call or a tweened counter brought back the
full probe on ordinary films. It now falls back only when a tweened property
resolves to a setter on the target or its prototype chain, which is what a
gain proxy or a class with a volume setter uses; data properties skip.
* fix(core): post a clip edit made while playing when playback stops
A label, track or inline z-index edited while playing and then paused before
the next one-second poll was never posted: once parked, nothing watches it.
The tick where playback stops now runs the same manifest diff once and posts
only if the manifest changed.
* 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
* fix(studio): the live clip list says whether each clip is heard and refreshes when that changes
* fix(studio): a muted clip reads as muted and a timed wrapper does not take its video's sound
* test(studio): a timed wrapper ignores the data-has-audio of the video inside it
* refactor(studio): keep the clip change check and audio reader under the complexity gate
* fix(studio): a fade-only change refreshes the live clip list
* perf(studio): a film with no manual edits skips the edit reapply on each seek
The reapply runs after every timeline seek, so on every playback frame, and
ran six document queries plus the motion rebuild even when Studio had never
edited the film. One combined query now answers "any edits?" first.
* test(studio): the seek skip still clears a motion timeline after an undo
An undo removes a motion edit's marks while its timeline stays registered,
and the reapply is what kills it. A test now fails if the skip stops
treating that timeline as an edit. The moved comments are cut to three
lines and lose an all-caps word.
* perf(studio): the per-seek edit check no longer walks large films or adds a lookup with edits
The combined comma selector took Chrome off its lone-attribute fast path: about 150 us per
seek on a 1.5k-element film, against 6 us for the whole reapply on main. The check now uses
one selector per mark and caches its answer per document, rescanning only after a
MutationObserver sees a mark attribute or the tree change.
* test(studio): the seek edit cache sees inserted edits and ignores style writes
Covers the two observer settings the cache depends on: a node inserted with its edit mark
after a seek is reapplied on the next seek, and a style write between seeks causes no rescan.
* fix(release): name Release once in a changelog entry's tags
* fix(release): the release tag appears once in any case; four past entries lose the duplicate
* 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
* perf(core): skip hidden clips' content so the browser stops decoding their images
Hidden timed clips get content-visibility:hidden while their inline visibility is hidden,
from script evaluation on. A clip due within the look-ahead (2 s by default) keeps its
content live, so its images load before it shows.
* perf(studio-server): load preview images lazily so hidden scenes fetch nothing
* fix(core): stop the readiness scan waiting on and decoding later scenes' images
* perf(studio-server): keep first-frame images eager so the preview opens as fast as before
Only images inside a clip with a numeric start after 0 go lazy; a start the route
cannot read stays eager.
* perf(core): keep clips that start at 0 laid out before the first pass so the first frame is on time
* perf(core): open the preview on time by hiding only lazy images before the first pass
GSAP reads the computed style of the elements it animates while timelines build. Inside a
content-skipped clip every read forces a style update, which cost about 65 ms at open on the
bench composition. Before the first pass the rule now takes only lazy images out of layout, so
hidden later scenes still do not load theirs; after it, the skip rule keeps their content skipped.
* fix(core): hold a paused jump on the previous picture until the next scene's images decode
* fix(studio): swap in a reloaded preview only after its restore seek has painted
* fix(core): apply a held paused jump after 1 s when an image never decodes
* fix(core): keep hidden-scene image skipping and held jumps to Studio's preview, never renders
* fix(studio): reapply manual edits once a held paused jump lands
* fix(studio): copy a clip without the preview's lazy-loading marks
* fix(studio-server): mark preview images lazy without re-encoding the document's entities
* fix(core): share one preview-mark constant between the runtime and the preview route
* fix(studio): reapply manual edits a second time only after a seek that was held
* refactor(core): share the hidden-ancestor walk between clip visibility and image holds
* fix(core): import the studio preview mark from its own subpath
* fix(studio-server): find preview images with the linear html scanner
* chore(core): drop two doc comments the scanner's names already carry
* fix(studio-server): serve the preview mark as its own meta, apart from the GSAP fallback script
* fix(studio-server): capture thumbnails and frames from the preview without lazy images or the mark
* fix(core): let the full readiness scan wait on every image except lazy ones off the first frame
* fix(studio-server): keep an authored loading attribute in any letter case on preview images
* fix(core): leave template content out of findStartTags so preview images match the DOM
* fix(studio): send Ask agent the live markup without the preview loading marks
* chore(core): drop the template note from findStartTags, its test carries it
* perf(studio-server): splice lazy attributes into the preview in one pass
* fix(studio): keep authored image loading on copy and Ask agent by marking preview-made lazy images
* test(studio-server): follow the capture preview URL back to its sub-composition for every project id
* refactor: name the upcoming-clip and GSAP fallback marks once
* fix(studio-server): keep capture builds out of the on-disk preview store
* feat(studio): let a host app mount the timeline toolbar, undo buttons, meters and clipboard
* test(studio): lock in history button rules and trim duplicate export tests
* fix(producer): renders use the font named in a var() fallback
When a font-family starts with var(--x, A, B) and --x is not defined, the
render now reads A, B the way the browser does. A system font at the head of
that fallback gets Inter placed in front of it, the same rule a direct
system-font stack already follows. The rewrite stays inside the var()
fallback, so a defined --x still wins. A defined property whose value is
itself a var() is followed too. The distributed plan check reads the same
answer.
Fonts named only in such a fallback are best-effort: they are embedded when
they resolve, and one that does not resolve is logged instead of failing a
distributed render. Fonts named directly, or through a defined property,
still fail closed as before.
CSS-wide keywords such as inherit are no longer treated as font names to
fetch.
Compiled the way a distributed render compiles them, 28 registry items now
embed a font they name this way, and none newly fails.
* refactor(producer): fold var() fallback resolution into one branch
* fix(producer): fonts behind a var() chain or empty fallback add no new render failures
A family reached through a chain of defined var()s is optional like a
fallback-only one: fetched fail-closed, skipped only when no source serves
it. An empty var() fallback makes the declaration invalid, so the browser
inherits and the plan check no longer rejects the families after it.
* test(producer): a family named after a leading var() stays required
* fix(producer): a transient error on an optional font warns and renders the fallback
* chore(catalog): record the font requests var() fallbacks now make
* fix(producer): required fonts fetch before optional ones can spend the time budget
* fix(shader-transitions): page-side shader renders keep scene layout and fonts and leave no ghost
* refactor(shader-transitions): one loop stages both scene copies
* fix(shader-transitions): stage scene copies inside copies of their ancestors
* refactor(shader-transitions): pin the staged root in one assignment
* fix(shader-transitions): keep ancestor copies out of scene layout in renders
* fix(shader-transitions): scene copies keep wrapper effects and live animation times
* fix(shader-transitions): hold copied animations with one lookup per scene and drop the no-op shift
* test(shader-transitions): pin the element and pseudo-element parts of the animation match
* docs(shader-transitions): keep the staging comments to two lines
* fix(studio): read and write a clip's in-point the way playback does
* fix(studio): a group head trim keeps the minimum clip length off the grid
* fix(studio): a group head trim stops where a single-clip trim does and never goes below zero
* fix(studio): group members on the grid move together when a trim stops at its limit
* 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(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
* 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
* 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
* fix(producer): embed the Google font the page linked
The weight-only download drops axes the page's link asked for.
That file is written after the link, so the film draws a wider cut than the preview.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(producer): keep every weight the page's font link asked for
Google answers a 400..900 link as font-weight: 400 900, and only 400 was kept.
Heavier text then waited on the network. Large families are limited to the
characters on the page, @import is followed, and a failed link is not replaced
by a font found on this machine.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(producer): preserve italic supplements for bundled fonts
* fix(producer): preserve active authored font resources
* style(producer): condense font fetch comments
* fix(producer): preserve authored oblique font faces
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(engine): renders show the video frame the preview shows below the source frame rate
* refactor(engine): one condition owns CFR sampling at the output rate
* fix(engine): webm and mkv clips keep every frame when rendered at their own rate
* fix(engine): only fine timebases get the half-tick sampling shift, so coarse ones stay exact
* docs(engine): the sampling comment states the half-tick bound it keeps
* fix(engine): move the extraction cache to v6 so main v5 entries are not reused
* docs(engine): keep extraction comments within their share
* test(producer): regenerate four 25-to-30 fps baselines for the exact frame pick
* test(engine): name the v6 schema prefix in its test
* perf(studio): playback no longer relayouts the timecode and playhead every frame
The timecode shows MM:SS but was rewritten on every playback frame, and the
timeline playhead moved by style.left. Both forced a layout and a repaint per
frame. The timecode is now written only when its text changes, and the
playhead moves by a composited transform.
* fix(studio): the moving playhead stays on whole device pixels
As a composited translate the 1px playhead line is anti-aliased, so at a
half-pixel offset it drew as two half-bright columns, where the old left
position snapped it to one. The transform now rounds to device pixels. The
helper moves to its own module as a named function with full parameter
names, which also keeps timelineLayout under the file-size limit.
* refactor(studio): drop the timecode write comment
The comment ratchet does not let a file's comment share rise; the reason
lives in the PR body.
* fix(studio): the playhead moves smoothly while playing and rests on its tick
Rounding the playhead to device pixels on every update made slow playback
stair-step: at fit zoom on a two-minute film, 120 frames of playback gave
22 distinct positions where main gives 120. While playing the transform now
keeps fractional pixels (120 of 120). At rest (paused, or a seek while
paused) it rounds to device pixels the same way layout rounds the ruler
ticks, so the resting line is one full column on its tick at 1x and 2x.
Pausing snaps the line even when the paused time equals the store's.
* 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
* fix(studio): quick timeline saves no longer refuse each other as outside changes
* fix(studio): canvas edits and block writes wait their turn in the file queue
* fix(studio): animated clip moves and the last direct writes wait their turn in the file queue
* 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
* feat(studio-server): a host can make Cmd+Z undo the newest change whoever made it
* fix(studio-server): Cmd+Z for everyone leaves an agent turn that is still open alone
* style(studio-server): format the undo scope check
* refactor(studio-server): the undo scope option names itself
* fix(studio-server): an open turn's parts are known by id, and a new change ends redo for everyone
* style(studio-server): format the open turn check
* fix(studio-server): an open turn's part is known before its entry is announced
* 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
* perf(studio): the lint error badge stops pulsing after three beats
The badge pulsed forever, so a Studio with a lint error painted a frame on
every display refresh, idle or paused. It now pulses three times and holds.
* fix(studio): the lint badge counts findings, never files
The badge showed the manual result's finding count while its dialog was open
and the number of files with findings once it closed, while its screen-reader
label always said findings. The hook now owns one count, the findings in the
list the error pulse also reads, and the badge shows it in both states.
* fix(studio): the lint badge pulses again when its finding count changes
A CSS animation does not restart on a text change, so after its three
pulses a background lint that found more problems left the badge still.
The badge is keyed on its count, so a new count mounts a new element and
pulses three more times.
* refactor(studio): the lint badge names its pulse count, and App reads the hook as one object
The pulse count moves from a comment into a constant name, so the file's
comment share does not rise. App takes useLintModal as one object instead
of a nine-line destructure, which keeps App.tsx under 600 lines once
merged with main.
* 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
* fix(studio): list only real compositions under Variables, not generated preview files
* fix(studio): project color grading edits only real compositions, not generated preview files
* 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
* 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
* fix(engine): declare SDR extracted frames as sRGB so Chrome does not darken footage
* fix(engine): encode SDR mp4 frames with the BT.709 matrix they are tagged with
Chrome hands the SDR encoder BT.601 full-range JPEG captures (or RGB PNGs).
The encoders only converted the range, so swscale kept the BT.601 matrix under
the BT.709 tag (ffmpeg 5.1 to 7.0: CSS #C83C28 delivered as #D44824) or
converted YUV to YUV through a low-precision step (ffmpeg 8+: #101010 delivered
as #0B0D0D). Decode to RGB with the capture matrix, then encode with BT.709.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(engine): tag SDR frames plain sRGB so every ffmpeg writes the sRGB chunk
ffmpeg writes a PNG sRGB chunk only for BT.709 primaries and writes cICP only
from 6.1. On ffmpeg 5.1 (Debian bookworm) a BT.601-tagged source therefore got
cHRM plus gAMA 1/2.2, which Chrome decodes as a pure 2.2 power: 0x10 shadows
rendered as 0x07. Declaring BT.709 primaries too gives the sRGB chunk on every
version, so Chrome shows the frame code values for every SDR source. The test
now reads the PNG chunks instead of ffprobe, which reports no transfer for an
sRGB chunk on ffmpeg 5.1.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(engine): extract SDR jpg frames as the BT.601 full range JPEG readers assume
The default frame format for opaque SDR sources is JPEG. ffmpeg converted the
BT.709 source to full-range YUV without changing the matrix, and every JPEG
reader (Chrome included) decodes BT.601, so footage shifted: 0xC83C28 showed as
0xBB2D29. The old mp4 encode made the inverse mistake and hid it; with the
encode fixed, it would reach the output. Decode through RGB with the source
matrix, then encode BT.601 full range, keeping 4:2:0.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(engine): round the SDR mp4 colour conversion accurately
swscale's default rounding moved saturated colours up to 3 codes (pure red 254
delivered as 251 on ffmpeg 5.1 to 7.0); accurate_rnd brings that to 1. The
chunk encoder now decides the HDR-or-SDR filter once, and the 10-bit GPU pad
test sets hdr, which its name always claimed.
* style(engine): keep the colour filter reasons in names and tests, not source comments
The comment ratchet caps a file's comment share: the new filter module and the
extractor carry their intent in constant names, and the encode test states why
the conversion goes through RGB.
* fix(engine): keep colour edges sharp through the SDR colour conversions
Going through RGB upsampled chroma with swscale's default filter, so ffmpeg 4.4
to 7.x smeared colour across edges by about 2 px (red 254 next to blue came out
8 inside the blue). Nearest-neighbour resampling maps the half-resolution chroma
back one to one, keeps the flat-colour results, and makes accurate_rnd redundant.
The pixel tests now also check a red to blue edge.
* test(producer): regenerate goldens whose colours the old mp4 matrix bent
These four fixtures compare at 40 to 45 dB, and their goldens were rendered
with the BT.601-under-BT.709 encode, so the corrected colours score 34 to 37 dB.
Regenerated in the Dockerfile.test image. Each new golden is closer to the
colour its page declares, e.g. #00d4aa was 00bcab and is now 00d3aa, and
#087443 was 006643 and is now 087241.
* fix(engine): keep VFR frame timing when extraction applies colour filters
ffmpeg before 6.1 ignores frame durations in CFR resampling once any -vf is
set, so a VFR window ending on a still lost frames. Filtered VFR extraction
now resamples in one process exactly as before and filters in a second.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(engine): encode raw SDR layered frames in BT.709
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* test(engine): keep the new colour tests inside the format, comment and argv checks
* fix(engine): keep HDR and unusual pixel formats out of the two-process VFR path
The raw handoff dropped HDR10 light levels and misread semi-planar formats, and
decoder options rejected some ffprobe colour names. Only SDR sources in formats
nut carries unchanged now take it, their colour tags come back through setparams,
and a process that cannot start releases the other.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(engine): read untagged footage with the matrix Chrome plays it with
Chrome decodes an untagged matrix as BT.709 from 720 coded lines up and as
BT.601 below; extraction assumed BT.601 at every size, so HD footage without
colour tags shifted hue against Studio's own preview.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(engine): untagged VP9 and AV1 footage keeps BT.601, as Chrome plays it
Chrome applies the 720-line rule only to codecs it decodes through ffmpeg
(H.264, VP8); untagged VP9, VP9 alpha and AV1 play as BT.601 at every size.
Also widens the raw-pipe pixel formats to all verified ones, and reports the
pipeline process that failed first by settle order, not by per-process clocks.
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* test(producer): regenerate goldens for corrected page colours and untagged SD footage
* docs(engine): say which untagged codecs the matrix rule was measured on
---------
Co-authored-by: Kenneth Shortrede <95665480+sontiac@users.noreply.github.com>
* fix(studio): keep a clip in sync when its head is trimmed at non-1x speed
* fix(studio): keep head-trim sync at the media start, on snaps and through splits
* fix(studio): keep group head trims rigid and read in-points like the runtime
* 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-server): a thumbnail no longer fails when a file it reads is removed mid-request
* fix(studio-server): a thumbnail survives its cache folder being removed mid-request
* fix(studio-server): a stray file where the manifests folder goes no longer fails a thumbnail
* fix(studio-server): drop a comment the code already says
* fix(studio): an automation lane edit is refused on a locked clip and reports how it saved
Lane drags, Delete and paste save through the timeline's gated clip or group
save instead of the property panel's commit, so the host's canEdit applies and
the commit resolves saved, refused or failed. A landed save refreshes the lane
store and the panel's selection, as the panel's commit did.
* fix(studio): a refused automation save puts the preview and the lane back
A save that does not land (recording, no id, a failed write) reverts the live
preview it was given, the lane drops a draft whose save came back refused or
failed, the no-id and no-project failures toast like the others, and the panel
refresh reads the current selection. The panel commit's unused coalesce option
goes, and the lane value reuses automationAttrValue.
* fix(studio): a failed save restores the preview it was given, not an older one
Each timeline save claims the preview's before-value when it starts, so a
gesture begun while it is in flight records its own and a later failed save
cannot put back a value from before an earlier save landed.
* chore(repo): allow the automation gesture key files this branch removes
* fix(studio): a refused automation save leaves the lane store and paste mark as saved
Reverting a live value re-syncs the lane store from the restored preview, so
an overlapping save that landed first cannot leave the store holding a later
value that failed. A paste whose save is refused or fails gives back the
selection and paste chain it replaced.
* fix(studio): a failed save settles on what the file holds and a refused paste marks nothing
A save that does not land settles the preview and store on the file's value
(the claimed before-value only when the file cannot be read), and leaves a
gesture begun meanwhile alone apart from correcting its before-value, so two
overlapping failed saves cannot leave either on a value that never landed. A
paste selects and marks its span only once it lands and only over the
selection it was pasted at.
* fix(studio): a failed save reads the file only after writes queued to it land
The settle read goes through the same per-path queue as the writes, so a save
that fails while another is queued settles the preview, the store and any
newer gesture's before-value on what that queued save wrote.
* fix(studio): an older failed save never settles over a newer edit on the same lane
Live-preview bookkeeping moves to createLiveLanes, one owner for both timeline
saves: each lane key carries a generation that every live write and save start
advances, and a save settles the preview and store (on its landed value, or on
the file after a failure) only while it is still the newest. A paste marks its
span only if it is still the latest paste.
* test(studio): a newer live drag keeps its preview when an older save recovers
* fix(studio): every finished save records what the file holds, even under a newer edit
A save's settle now always writes the file's value (landed, or read back after
a failure) into the lane store and a live gesture's before-value, and moves the
preview only while nothing newer has touched the lane. The clip store is set
from that value rather than copied from the preview. A paste marks its span
only while its clip is still the selected one.
* fix(studio): an older automation save never overrides a newer gesture on its lane
A failed save's recovery read could settle after a newer save started, and the
save's own optimistic preview patch and failure unwind ran over a newer drag.
Every preview write a save makes now goes through the lane's ownership check,
only the newest save records its value, and a refused drag hands the lane back
to the newest pending save.
* fix(studio): timeline attribute saves land in the order they start
An attribute save read the file before joining its write queue, so an older
save with a slow read could land after a newer one, overwrite it on disk and
leave the lane showing the newer value. The save now joins the queue first and
reads, patches and writes inside it. A save moves the preview only while it is
the newest save and no drag is live, which replaces the per-lane generation.
* refactor(studio): name the in-queue history write for the queue it needs
* test(studio): pin save start order and a newer release's preview mid-flight
* fix(studio): a drag keeps its points when an older save lands under it
An older save that landed mid-drag wrote the lane store, the lane dropped its
draft, and the release committed the older points. The lane now keeps its draft
while a gesture is live. A save that cannot read the file back settles on the
last value read or saved on its lane, never on an unsaved one, and an older save
that lands records its value there even when a newer save is pending.
* fix(studio): a lane's first failed save still puts its drag back
With the claimed-value fallback gone, a first save on a lane that failed and
could not read the file back settled on nothing, leaving the drag playing. A
save begun with no other save pending now records the value under its drag as
verified, since that is what the file last settled on.
* fix(studio): only a before-value read with no save pending counts as saved
The value under a drag was trusted when its save started with nothing pending,
but it was read when the drag began, possibly over a paste that later failed.
A lane now marks a before-value clean only when no save was pending as the drag
began. A group lane's preview carries its store mirror, as its live writes do.
* fix(studio): a lane's saved value never crosses into another project
The lane bookkeeping outlives a project switch and its keys had no project, so
a value saved in one project could settle into another project's clip with the
same key when a save there failed and could not read the file back. Lane keys
now carry the open project, and a save moves the preview or store only while
the project it was made in is still open.
* fix(studio): a failed save reads its file back from its own project
A save that failed after a project switch read its recovery from the newly
opened project and recorded that project's value as its own. Each save now
captures its project when it starts and reads back from it.
* fix(studio): a failed save never brings back a value an undo removed
A lane kept its last verified value after its saves settled, so after an undo a
save that failed and could not read the file back settled on the removed value.
The value now lives only while a save on the lane is in flight, and each save
records what its own in-queue file read found. The group preview's store mirror
is no longer needed and is gone.
* test(studio): pass the file-read callback in the direct attribute-save tests
* fix(studio): a drag whose before-value a save settled counts as clean
When a settle rewrites a live drag's before-value to a verified value, that
value is known saved, so the drag's own save can fall back on it even when its
read and read-back both fail. Adds a test for a failed save reading back from
its own project while another save is in flight across a switch.
* fix(studio): a save started in one composition never lands in another of the same project
Live lanes were scoped to the project only, so a save held across a switch
to another composition of that project settled its value into the new
composition's preview (clip and group lanes) and store (group lane).
Lanes are now scoped to the project and the composition open when the save
starts. createLiveLanes moves to its own module for the 600-line cap.
* fix(studio): an undo during or after a lane gesture stays undone when the next save fails
A live gesture records the lane's value before it starts, and a later save
that cannot read the file settles on that value. An undo mid-drag, or after a
gesture that ended without saving, left that value stale, so the save brought
back what the undo removed. Every undo and redo now re-reads each open lane's
value from the files it restored.
* fix(studio): undoing group automation clears it from the members' lanes at once
The group-info cache only noticed a change when its MutationObserver callback
ran, so the soft restore's store sync, which reads it synchronously, mirrored
the pre-undo group automation onto the members. A read now counts mutations
the observer has not delivered yet.
* fix(studio): an undo reaches every lane and pending save it rewrote, in any composition
The undo re-read only covered lanes in the open composition and live gestures.
History is project-wide, so undoing from another composition left the
rewritten composition's lanes on the pre-undo value, and a save released while
the undo was in flight kept that value as its fallback. The re-read now covers
every lane of the project on a restored path, including saves still pending,
and an undone file it cannot read makes the value untrusted instead of dropped.
A missing element reads as unreadable, the same as a save's read-back.
* fix(studio): a cancelled fade, stretch or preset audition leaves no stale lane value behind
A cancel put the gesture's start back through a live write, which left the
lane's before-value in place until the next save trusted it, even after the
property panel, the source editor or an agent had changed the file. The fade
cancel, the edge-stretch cancel and leaving a preset audition now end through
the lanes' revert, so a before-value exists only during a live gesture.
* test(studio): pin that a group write is read back fresh in the same task
* style(studio): drop two comments the names already carry
* fix(studio): a failed group save reads its own file back; fade cancels work without revert
- The group save's failure read-back used the file of whatever composition was
loaded when the write failed. It now reads the file captured when the save
started, as the clip save does.
- A fade cancel writes its start back live before it reverts, like the other
cancel sites, so a host without the optional revert callback still puts the
fade back.
- The edge-stretch cancel comment says what the code now does.
- The save test harness passes the revert callbacks, as the app does; without
them the cancelled-gesture tests exercised a host that lacks them.
* refactor(studio): trim three long comments on live automation saves
* fix(studio): the dev server leaves catalog installs to hyperframes preview
* test(studio): name the dev server's install test for what it checks
* fix(studio): a finished install replaces its 'Adding' toast instead of sitting beside it
* test(studio): pin that the Adding toast goes after the install's own toast
* 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
* fix(engine): export draws a video in exactly the frames export visibility shows it
* test(producer): guard that chunk frame planning snaps clip windows like export
* refactor(engine): set the render fps once per frame table and name its clip window
* chore(engine): trim frame table comments
* 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