loadTranscript assigns id: w{index} on the srt/vtt branches but never on
the JSON branches: parseWhisperCpp and parseOpenAI drop the field and the
words-json branch defaults it to "". Every engine funnels through
loadTranscript, and transcribeAudio rewrites transcript.json from its
output, so CLI-produced transcripts ship without the stable word ids that
transcribe.md documents for caption overrides — per-word overrides have
nothing to key on.
Assign id: w.id || `w{index}` across the JSON branches, matching the
srt/vtt behavior. || also repairs the empty-string ids older CLIs wrote
to words-json files, which otherwise collapse every word onto one key.
Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
* feat(cli): render summary names the capture path, gpu mode and stage timings
* fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers
* fix(cli): render slow-path hint fires only when the gpu was auto-probed
* test(cli): isolate agent hint suite from ambient agent env keys
The suite runs inside agent sessions. Ambient env keys fill the 16-key
hint cap and fail assertions that a plain shell would pass.
* docs(music-to-video): drop leftover hard-kill mandate after lint rule removal
Frame workers still required a tl.set hard-kill that the dropped lint
rule no longer enforces. Align the self-check with montage.md.
* refactor(cli): share hint-key pattern and collapse render summary args
Export HINT_KEY_PATTERN for the test isolation strip. Read duration and
frame count from the perf summary instead of passing them beside it.
* test(cli): lock capture-mode preference and 16-key hint isolation
Session mode, including drawelement, must print over observability.
The hint suite now fails if ambient keys already fill the 16-key cap.
* feat(cli): default BeginFrame, agent blank, and looks quality
Local auto render opts out of the software-GPU screenshot clamp so
BeginFrame can run. init --agent scaffolds a centered Inter blank with
no prompts. --quality looks (now the default) is CRF 16; delivery is high.
* fix(cli): honor screenshot env and teach agent pitfalls on the happy path
Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true.
The agent blank fills its canvas so --resolution can resize it. Skills now
name the four pitfalls and tell agents to read the render summary line.
* feat(cli): default init to the centered blank
Bare init scaffolds the centered Inter stage. --agent is a hidden alias.
* fix(cli): pack from-file and use it for video init
TTY --video with no --example selects from-file. Pack copies that template.
* fix(cli): wire --video onto from-file even with --example blank
One resolver picks the scaffold. Spawned init --video asserts a-roll src.
* fix(cli): narrow looks CRF on the quality alias
Only looks carries crf. Typecheck failed on the union without a guard.
* style(cli): shorten render-pipeline summary comment
* refactor(skills): keep hyperframes-core as the HTML contract
Move brief, storyboard, review, production, dispatch, and
frame-worker docs to hyperframes. Slim remaining core references.
* fix(skills): restore camera recipes and root sizing
Restore Zoom, Ken Burns, crop, and clip-path recipes. #root is 100 percent.
* fix(skills): stamp size on the composition root
Runtime sizes the composition root, not html/body. Overlap pin requires is valid.
* chore(studio): remove the write-only self-write timestamp ref
`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.
Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.
Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.
* docs(studio): plan for loading the preview during the shell's first layout
* fix(runtime): a paused preview stays still after any seek
Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.
Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.
- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
now returns every timeline it unpaused to paused in a finally. The second
rearm after the child re-seek only ever changed the leaked state and is
gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
timed media element found running. A parked transport runs no ticks, so a
capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
requests playback through the player so adapter, rAF loop and flag agree.
Regression tests fail without each of the three changes.
* perf(studio-server): stop re-encoding videos the browser already plays on open
Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.
One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.
Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.
Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.
* perf(cli): serve the studio bundle compressed and cache hashed assets
The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.
The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.
* perf(cli): warm the preview route before the browser opens
The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.
The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.
* perf(studio): request the player chunk before the shell's first layout
The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.
* fix(studio): editing a text property no longer reloads the preview
Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.
The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.
A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.
shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.
Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.
* fix(runtime): paused-time media playback is borrowed, not banned
The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.
- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
video while paused to render grading previews. It went dark on the first
paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
music track for ~140 ms while paused so a playhead drag is audible. Same path
killed it.
A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.
Two corrections to the previous commit's reasoning:
The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.
Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.
Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing
* test(runtime): a lease ends when the transport plays or the borrower stops
Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.
* test(studio): the event-stream channel must open /api/events
* fix(studio-server): stop printing a proxy diagnostic line per clip on every render
Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.
The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.
The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.
The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".
Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.
* perf(cli): stop gzipping the studio bundle on loopback
Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.
The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.
The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.
* docs(runtime): say what the paused-media probe actually filters
* revert(cli): drop the preview prewarm that held the CLI event loop
The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.
Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.
Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.
Reverts packages/cli to origin/main exactly.
* fix(studio): let a remount retry the player chunk after a failed load
1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.
Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.
Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.
* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap
* fix(studio): stop one failed player import poisoning every later mount
The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.
The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.
* chore(ci): allow the deletion of the dead sdk-session reload test
* fix(runtime): the timeline resolver no longer unpauses children it cannot drive
Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.
The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.
Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.
* test(runtime): pin that the resolver unpauses a child only once the root holds it
The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.
* fix(runtime): the paused side stops every clip the transport drives
The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().
"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
* fix(catalog): name the better search tier when a search finds nothing
The hint that tells a scripted caller the on-device tier exists was wired
to the branch where the search succeeded, and was silent on the branch
where it found nothing. The caller with the most reason to hear it was the
only one who never did.
It also only ever reached stderr, so a --json caller never saw it at all.
The hint is now a returned sentence pushed into the same warnings array the
zero-result envelope already serializes, and printed from that one value, so
the terminal and the envelope cannot drift.
Withheld when the query parsed to no searchable tokens, where the advice is
already to search in English, and when a warning has already explained why
the tier cannot run here.
The consent gate is untouched: nothing downloads, and the sentence still
asks the caller to check with a person before enabling it.
* fix(review): pass the resolved model status, and pin both hint guards
Deriving the status inside the hint helper re-read and re-hashed the
on-device model from disk to answer a question the caller had already
answered a few lines earlier. It now takes the resolved value.
Both guards below the status check were invisible to the suite: every
other case pinned a status that already returned null, so deleting either
guard passed everything. Two tests now hold them, and each fails for its
own mutation.
* docs(skills): tell agents to read the tier hint the envelope now carries
The CLI skill said that under --json nothing about the offline tier is
printed at all. An agent following that sentence would never read the
warnings array this branch writes the tier sentence into, so the fix
would land in the code and never reach the caller it was written for.
It also described warnings as firing only when a tier was asked for and
could not run. That is now too narrow: a zero-result search emits the
hint with no tier requested.
Regenerate skills-manifest.json for the changed content hash.
## Summary
- `findPython()` correctly resolves `HYPERFRAMES_PYTHON` before falling back to the PATH probe — that part already works, confirmed by direct testing. But when the override is set and fails validation (nonexistent path, non-executable, non-Python-3 output, timeout), it silently falls through to the PATH probe with zero diagnostic. A user whose override had any subtle issue got a plain "Not installed" from `doctor` with no signal the variable was even seen.
- This is a corrected, narrower version of a report that originally claimed `doctor` ignores `HYPERFRAMES_PYTHON` entirely — that claim was refuted directly (the override resolution works). The real defect is the silent validation-failure path.
- Extracts the override-validation logic into `validatePythonOverride()` and adds `describeRejectedPythonOverride()`, which `doctor`'s TTS (Kokoro) and BGM (MusicGen) checks now call to append the rejection reason to their `detail` when applicable. `findPython()`'s own behavior (including its fallback) is unchanged.
PRINFRA-669
## Test plan
- [x] New `packages/cli/src/tts/python.test.ts`: `describeRejectedPythonOverride` returns null when unset / when the override validates; names the override + exception message when the override can't run; names the override + actual output when it isn't Python 3; `findPython` still falls back to the PATH probe when the override is rejected (unchanged behavior) and still uses a valid override directly.
- [x] Confirmed RED against the pre-fix source (tagged stash) — all 4 new `describeRejectedPythonOverride` tests failed with "not a function"; GREEN after restoring the fix.
- [x] `bunx tsc --noEmit`, `bunx oxlint`, `bunx oxfmt --write` clean on changed files.
- [x] `bunx fallow audit --base origin/main --fail-on-issues`: no issues in the 3 changed files.
- [x] Full `packages/cli` vitest suite: 3033/3038 passing (2 pre-existing unrelated failures — a PID/socket sandbox quirk and an agent-env-var-pollution test — plus 4 browser-test files failing at collection on a pre-existing `node:` builtin import issue under this sandbox's happy-dom setup; all confirmed identical on pristine `origin/main` and unrelated to this change, consistent with every other fix from this backlog-drain session).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
`keyframes --shot --layout strip`'s help text, its type's inline doc comment, and the CLI reference docs all described `strip` as an unqualified "filmstrip by time." The tool doesn't actually do that for the overwhelmingly common case.
## Why
A real per-time pixel filmstrip is only produced when the sampled selector is an SVG element (gated by an internal shape check — `typeof element.getBBox === "function" && typeof element.getScreenCTM === "function"`). Any other selector — including every nested sub-composition host, which is always a `<div data-composition-src>` — silently falls back to one live screenshot plus vector position markers instead.
This isn't a capture bug: for a non-SVG selector, real per-time pixel compositing was never implemented, only 3D bbox/marker sampling. But the documented behavior over-promised what the tool does, so a user following the docs on the common case (a DOM/sub-composition selector) sees root captions and empty image boxes where they expected the nested composition's actual content to move across frames — the diagnostic strip is misleading, even though the real render is correct.
## How
Reworded all three descriptions (CLI help text, `ShotOptions.layout` TSDoc, and the reference docs table) to state the SVG-only condition and the DOM/sub-composition fallback explicitly. No behavior changed — this is a documentation-accuracy fix, per the ticket's own framing that a doc-only fix fully resolves the reported symptom (a silent, misleading omission) for a P3.
## Testing
Added a test asserting the CLI help text no longer makes the unqualified "filmstrip by time" claim and does disclose the SVG-only condition — guards against a future regression back to the misleading wording. Verified RED (fails against the pre-fix string) and GREEN (passes after the fix) via a local before/after comparison.
- `bunx vitest run src/commands/keyframes.test.ts src/commands/motionShotLayout.test.ts` — 46/46 passing
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean
- Full CLI suite (excluding known-broken-in-sandbox browser-launch tests, unrelated to this change): 217 test files / 3025 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
`check`'s sweep_static guard false-positives on a composition that swaps between equal-size, equal-position opaque `<img>` elements — a common authoring pattern for revealing frame N of a still sequence from a paused GSAP cursor. The render itself is correct; only `check`'s verdict is wrong.
## Why
The sweep guard fingerprints every visible element's box + opacity + font-variation-settings per seeked sample. That's deliberately blind to pixel-only motion (a canvas repainting, a video playing) with no element moving, so an existing carve-out downsamples each visible `canvas`/`video` to 8x8 and folds its pixels into the fingerprint specifically to catch that class of motion.
That carve-out's element selector (`root.querySelectorAll("canvas, video")`) never included `img`. An img src/visibility swap between equal-size opaque images moves zero geometry and zero opacity, so it stays outside both the base fingerprint and the pixel-hash carve-out — the whole-run fingerprint reads byte-identical across every sample and `sweep_static` fires on an animating composition.
## How
Widened the selector to `canvas, video, img`. No other change was needed: `mediaPixelHash` already handles `img` correctly — `drawImage` accepts any `CanvasImageSource`, and its width/height detection already falls back to the element's bounding rect the same way it does for `canvas`/`video`.
**Scope note:** this repo has an open PR (#3707) touching the same function (`collectLayoutGeometry` in this same file) for a different, unrelated bug (text/counter fingerprinting). This change is deliberately isolated to the `canvas, video` → `canvas, video, img` selector line and a new comment above it — verified against #3707's current diff that neither touches this exact loop, so the two PRs shouldn't conflict regardless of merge order.
## Testing
Added a test mirroring the existing "changes the sweep fingerprint when visible video pixels advance" test, using an `<img>` element instead of `<video>` with the same pixel-mock approach.
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed
- Full CLI suite (excluding known-broken browser-launch tests unrelated to this change): 217 test files / 3023 tests passing
One local-environment caveat, disclosed for transparency: `layout-audit.browser.test.ts` (the file the new test lives in) can't execute in my local sandbox — it fails identically with or without this change (`No such built-in module: node:`, a happy-dom + Vite externalization issue affecting every test file in this repo that imports Node builtins at the top under `@vitest-environment happy-dom`, not specific to this change). I verified the new test's logic and mocking approach are structurally identical to the existing, CI-passing video test it's modeled on, and will confirm via this PR's CI run rather than a local one.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
`.hyperframesignore` negation rules (e.g. `!/.media/`) could never re-include a hidden (dot-prefixed) directory in the publish or cloud-render project archive, even though negation works correctly for every other kind of path.
## Why
`collectProjectFiles`'s walker called `shouldIgnoreSegment` first for every directory entry, and that check unconditionally excluded any name starting with `.` — before the project's ignore matcher (built from `DEFAULT_PROJECT_IGNORE` + `.hyperframesignore`, where negation is evaluated) ever ran on that path. A hidden directory was discarded at the walk step, so no negation rule downstream could ever reach it.
## How
- Moved the dot-prefix exclusion out of the hard `shouldIgnoreSegment` short-circuit and into the same ignore matcher that already parses `.hyperframesignore`, as a new default pattern (`.*`) in `DEFAULT_PROJECT_IGNORE`. Dot-prefixed paths are still excluded by default, but now via the same gitignore-style negation path as everything else, so a project's `.hyperframesignore` can override it.
- `shouldIgnoreSegment` is now reserved for the fixed, non-negotiable exclusions only (`.git`, `node_modules`, `dist`, `.next`, `coverage`, `.DS_Store`, `Thumbs.db`) — the set no `.hyperframesignore` rule should ever be able to reach.
- Added regression coverage for both directions: a `.hyperframesignore` negation re-including a hidden directory, and an unmatched hidden directory still being excluded by default (no behavior change for existing projects without an explicit negation rule).
## Testing
- `bunx vitest run packages/cli/src/utils/publishProject.test.ts` — 40/40 passing (2 new)
- `bunx vitest run packages/cli/src/commands/cloud/render.test.ts` — 10/10 passing (cloud render reuses the same archive builder)
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The two skills exempt from the catalog search were pinned only by the
sentence that declares the exemption, so the exemption would go stale the
day either gained a way to install a registry item. The test now also
asserts neither skill contains an install command or a registry path.
The templates list in the scaffold reference claimed to be lint-checked
but lacked the marker that arms the check, so a misspelt template would
have passed. It now carries the marker with its presets and skill names
allowlisted, and a misspelt template fails the lint.
* fix(motion-graphics): make the catalog search fire before hand-authoring
The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.
The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.
Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.
* fix(skills): search the component catalog before hand-building a look
Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.
All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.
- Eight workflows now run the search at the point they decide what to
build, before authoring. The two that compile through a closed
authoring vocabulary (embedded-captions, talking-head-recut) document
why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
treatment or transition) instead of the command name; the router table
and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
as a hand-author gap in two files while shipping in the registry;
stat-motion was named as an installable block and is not one; the
caption-* family count was one high; the registry discovery tables
claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
names an item the registry does not have.
* refactor(scripts): reuse native recursive readdir and the shared registry type
Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
lint passes.
* refactor(scripts): name the registry check's blind spots and stop self-arming
Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
empty array, so "not a snapshot" and "a clean snapshot" have one owner
and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
marker syntax in an example no longer arms the check on itself. The id
scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
identifiers outside backticks, and single-word item names. Measured on
the six marked files, dropping the hyphen requirement would monitor 3
more items and force 46 allow= entries for ordinary prose words, so the
requirement stays and the gap is stated instead of silent.
* chore(skills): regenerate skills manifest after catalog-search edits
* feat(registry): add 25 image carousel blocks (5 families × 5 variants)
Five carousel families, each with 5 style variants:
- Orbit (1–5): image cards on a spinning 3D Fibonacci sphere
- Path (1–5): cards following animated CSS motion paths
- Circle (1–5): circular carousel layouts
- Vision (1–5): Apple Vision-style spatial presentations
- Text Circle (1–5): circular carousels with text overlays
All blocks are 1920×1080 at 6s, with 12–24 configurable image slots.
Includes catalog preview thumbnails for each block.
Co-Authored-By: Jake Moran <jake.moran@heygen.com>
* refactor(registry): host carousel block images on the CDN
The registry is served straight out of this repository
(DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a
block ships is permanent history. The 25 carousel blocks added 421 JPEGs,
27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images
were distinct: the same 12-24 placeholders were copied into every block.
files[] entries gain an optional `url`. When set, the installer fetches the
bytes from there instead of joining the registry base. `path` does not change
and still says where the file lands relative to the item, so composition HTML,
target mirroring and `hyperframes add` behave exactly as before.
Keys are content-addressed, so the 396 manifest entries resolve to 23 objects,
and a changed image gets a new URL rather than a stale one cached behind
`immutable, max-age=31536000`.
The catalog preview renderer copies an item's directory and renders it, so it
needs the same materialisation step. Without it the preview draws every card
blank and reports success, which is worse than failing.
Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing;
catalog previews are rendered by CI and served from docs/images/catalog.
Verified: all 23 objects return 200 from the CDN with hashes matching their
keys; `hyperframes add carousel-orbit-1` against a local registry installs 24
real JPEGs; the preview render produces the album art, and produces blank
cards when the fetch step is removed.
* style(registry): format the carousel composition HTML
`oxfmt --check .` covers the whole tree, and these 25 files were never run
through it. The pre-commit hook only formats staged files, so nothing local
caught it.
* feat(catalog): publish the carousel blocks without republishing their images
These 25 blocks had no Catalog page. Every other item in the registry has one,
so they shipped invisible: installable by name, unfindable by browsing.
Generating them naively undid the change they were added by. The Catalog
payload copies an item's assets into docs/public/, which is tracked, so the 396
images this PR just removed came back as 43 MB one directory over — worse than
the 3 MB they started as, because each block got its own copy.
The copy exists because these compositions assemble `img.src` at run time out
of a variable value, so there is no `src="..."` in the markup for the payload's
asset scan to resolve. An unpredictable path can only be satisfied by serving
every file beside it, which is what `needsOwnDirectory` asks for.
An absolute URL needs no directory: the scan already skips any `https:`
reference. So for the payload path only, hosted files are left undownloaded and
the composition's variable defaults are rewritten to their URLs. The preview
renderer still downloads them, because it paints real frames and a missing file
is a blank card.
The explorer posts every value to the preview frame on mount, including
untouched ones, so the page's variable list carries the URLs too. Left as local
paths they would have overridden the payload's own defaults and asked the frame
for a file that was deliberately never published.
Result: 25 pages, 25 payloads, zero bytes of image added.
Verified: a spike item declaring no assets at all rendered its 24 covers from
the CDN, proving the variable-default path; payload generation for a carousel
block now writes no item directory and no shared asset; the preview render
still produces the album art. mint validate and mint broken-links pass on the
new pages. test:scripts is green.
* refactor(catalog): split the hosted-asset step out of prepareProjectDir
Two functions rather than one: finding the composition and rewriting its
variable defaults are separate jobs, and inlining the mode branch pushed
prepareProjectDir past the complexity gate it was already sitting on.
Behaviour is unchanged. Re-verified both paths after the split: the payload for
a carousel block still writes no item directory and no shared asset, and the
preview render still produces the album art.
* feat(catalog): give the carousels their own shelf
25 image carousels landed in Showcases and were 53% of it, so the scenes that
shelf exists for disappeared underneath them. That is the same shape the 24
editor themes made, and it gets the same fix they got.
Keyed on the first tag, which is this file's stated grouping rule, rather than
on the name. `screen-flow-carousel` leads with `product-demo` and stays on the
shelf that says what it is for; a future carousel that is not named
`carousel-*` still lands here.
Showcases 47 -> 22, Carousels 25, and no existing item changed shelf.
* fix(registry): centre the circle-5 carousel path in its composition
Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the
right edge and most of it fell outside the frame. Only a few cards were ever
visible, cropped, with two thirds of the composition empty.
The exported path carried absolute coordinates from a layout that was never
recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on
the composition centre. Handles are relative, so only the anchor points move
and the shape is unchanged.
carousel-text-circle-5 shares the identical path and had the identical fault.
The other three circle variants sit within 12% of centre, which reads as
authored placement rather than the same bug, so they are left alone.
* fix(catalog): rebuild the circle-5 payloads after recentring the path
The Catalog preview plays the payload, not the composition on disk, so
recentring the source changed nothing a reader sees. The payload still carried
the old vertices and the ring still hung off the right edge of the frame.
Verified the consumer this time, not just the producer: both payloads now
resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the
formatting pass did not reach them.
* feat(catalog): promote Carousels to its own section
It was a shelf inside Scenes & demos, which is where a scene type belongs by
kind but not by weight. At 25 items it is larger than Data & charts (17) and
Blocks (13), each of which is already a section holding a single shelf, so the
catalog's own precedent puts it one level up.
Pulling it out also takes the largest section in the catalog from 120 items to
95, which is the reason the shelf was added in the first place.
The two circle-5 pages change because their embedded source block carries the
recentred path; nothing else in them moved.
---------
Co-authored-by: Jake Moran <jake.moran@heygen.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* test(producer): pin unpinned drawElement stall retry
* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing (#3172)
* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing
A single drawElement frame could kill an entire render. On one comp
(caption-editorial-emphasis) drawElementImage returns normally and the renderer
then stops draining its task queue: the setTimeout(…, 0) that schedules
toDataURL never fires, the capture page.evaluate never settles, and 60s later
the stage watchdog fails the whole render. Deterministic on 152.0.7977.30,
always the same frame. Root-cause detail in PRINFRA-488.
Two gaps, both closed here.
1. Nothing below the 60s stage watchdog bounded a frame. Adds a per-frame
deadline (HF_DE_FRAME_TIMEOUT_MS, default 15000, 0 disables) around the WHOLE
frame operation, not just the drawElementImage call — the stall surfaces at
whichever page round-trip comes next, and was observed at both the capture
and the seek's background-image decode. Bounding one call missed it.
Deliberately NO per-frame screenshot fallback for this error: once the
renderer stops scheduling it is wedged for every subsequent round-trip on
that page. Measured — the screenshot fallback blew the same deadline. The
frame fails fast instead, so the recovery that actually works can run.
2. That recovery was ineligible. shouldRetryViaPinnedFallback only retried a
generic capture failure when the worker count was PINNED by the inversion or
the router; a comp that engaged drawElement on the ordinary single-worker
path had no whole-render fallback at all. A renderer stall is now retryable
on any routing — the failure is a property of drawElement itself, and the
retry re-renders on a fresh page via screenshot. Reported as
de_fallback_reason "de_renderer_stall" so it is distinguishable from
capture_error in telemetry, and counted per-session as deFrameTimeouts.
Verified end to end on the repro: previously exit 1 with "stalled: no frame
progress for 60000ms"; now the deadline fires, the render retries via
screenshot, and RENDER_OK with a valid 1920x1080 / 240-frame / 8.0s MP4.
Tests cover the predicate both ways plus the cross-package error match. Engine
1,481 and producer 587 green (audioPadTrim.integration flakes only under
full-suite parallel load — passes in isolation, and on clean HEAD it is green
too, so it is not from this change).
Refs PRINFRA-488
* fix(engine,producer,cli): count drawElement frame timeouts where they happen
`session.deFrameTimeouts` was incremented inside `captureFrameCore`'s catch,
but the deadline races that function from OUTSIDE it — `withFrameDeadline` is
applied by `captureFrameToBuffer`, and `DeFrameTimeoutError` is constructed
nowhere else. So the branch was unreachable: on a wedged renderer the counter
stayed 0, the engine-side stall log never printed, and
`CapturePerfSummary.deFrameTimeouts` reported 0 on every affected render. The
end-to-end recovery worked throughout — `isDeRendererStallError` matches by
name at the producer — only the observability was dead.
Moved the increment and the log into an `onTimeout` hook that fires from the
deadline itself, deleted the unreachable branch and the now-unused
`isDeFrameTimeoutError`, and threaded the count out to
`render_completed.de_frame_timeouts` so the rate is graphable apart from the
other `capture_error` fallbacks. A stall always costs a whole-render re-run,
which is why it deserves its own series.
Also corrected the `fallbackReason` docstring, which still listed only
`psnr | blank | oom | capture_error` after this branch added
`de_renderer_stall`.
`withFrameDeadline` is exported for a two-case fake-timer test: the deadline
fires and calls `onTimeout` once, and a resolving inner promise passes its
value through with no late timer. The first case fails if the counter moves
back inside the work promise.
(cherry picked from commit ec2b2b5b2a)
* fix(render): retry sequential screenshot stalls
* fix(render): bound parallel worker capture phases
---------
Co-authored-by: Vance Ingalls <vance@heygen.com>
* feat(lint): catch leftover marker heads and guessed marked shafts
Dash-draw on a marked path shows the arrowhead before the shaft exists.
A long marked path that misses every node in both user and screen space
is the same detach as the CTM-paste bug, without the counterfactual.
* feat(check): flag orphan connectors and unbalanced style tags
Catch a visible shaft while fewer than two nodes are on stage (enter-early /
exit-late), and extra </style> that dumps CSS onto the frame.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): judge connector_orphan by the shaft's own endpoints
The rule counted anchors stage-wide and skipped whenever two were on. That
tier is every visible text-bearing or opaque element under 15% of the
stage, so a title and a footer alone satisfied it — the check could not
fire on a composition that had any chrome, which is all of them.
Endpoints are now resolved per shaft, against candidates gathered by
layout instead of by visibility, so a node hidden with opacity:0 still
owns the endpoint that meets it and its visibility is what the rule tests.
An endpoint that meets no node is left to connector_detached.
* revert(lint): drop the marker rules this repo would only duplicate
marker_orient_typo and marker_dash_draw_on already run in Zephyr's Python
connector lint, down to the same regexes and the same message and fixHint
strings. Zephyr concatenates its local findings onto this bridge's result
with no dedupe, so shipping them here reports each one twice and counts it
twice, and every later edit has to land in two places.
The browser-check work stays: a marked shaft that meets no node, and an
orphan shaft, are both runtime facts this repo can see and a source-reading
rule cannot.
* chore(check): drop the comments added with the orphan rewrite
* fix(check): score orphan and detach on overflow-opted connector layers
Allow-overflow is a clip opt-out, not a lifecycle skip. Orphan now uses
the same connector names as detach so flow and arrow shafts are judged.
* fix(check): see axis-aligned shafts, and script tags closed with a space
connector_orphan gated on isVisibleElement, which rejects anything whose
bounding box has no height or width. A straight horizontal or vertical
connector is exactly that, and it is the common shape, so the rule was
blind to most of what it exists to catch: across 47 production
compositions it produced nothing. It now tests paint -- display,
visibility and the opacity chain -- and leaves size to the 80px chord
floor it already had. The same 47 compositions now yield three findings,
each naming the endpoint that is still dark.
unbalanced_style_tags stripped scripts with a regex that only matched
`</script>`. `</script >` is valid, so the script survived the strip and
a `"</style>"` string literal inside it counted toward the tag balance,
reporting an error on a composition whose tags are paired. Both the strip
and the closer count now tolerate whitespace before the `>`, which is also
what CodeQL flagged on this branch.
* fix(lint): count style tags in one pass instead of stripping scripts
CodeQL reads the `source.replace(/<script.../)` as an incomplete HTML
sanitizer. It was never one — the rule only needs to not count `<style`
tokens that live inside a script string. A single alternation scan, where
the script branch consumes the whole block, gets the same counts without
a replace.
* test(check): cover the two orphan guards that no fixture was crossing
Neutering `shaftIsPainted`'s opacity check, or the endpoint proximity
threshold, left the suite green. Both are false-positive guards: a
connector staged hidden before its reveal, and a hidden element that
happens to be the nearest thing to an endpoint without belonging to it.
The hidden-shaft case now runs over all four ways a shaft can be
invisible, so the display and visibility clauses are crossed too.
* test: pin the two boundaries raised in review
A closer inside an html comment is already dropped upstream by
stripHtmlComments, so the rule never sees it. The test fails if that
stripping regresses; adding a comment branch to the rule's own scan
would have reintroduced the polynomial-redos pattern that stripper
exists to avoid.
A node hidden with display:none has no geometry, so nothing can place it
near an endpoint and connector_orphan stays silent. connector_detached
does not pick it up either, since it allows a half-attached shaft by
design. That case is uncovered, and the fixture says so.
* fix(check): type the hidden-shaft fixture table as a style record
* fix(check): key connector_orphan by geometry and let a live node win the endpoint
Two defects found in review.
connector_orphan was persistence-tiered but absent from the geometry key,
so several id-less shafts orphaning at one sample each collapsed into a
single finding that then read as held rather than transient. On the 47
corpus compositions this recovers 9 findings across 5 compositions where
3 across 2 were reported; one composition fades its nodes and its
connectors on a shared stagger, so four shafts each outlive a different
node and only one of the four survived the collapse.
The endpoint scan took the nearest candidate and asked whether that one
was hidden, without asking whether a visible box was also in range. A
staged halo sitting on the node it belongs to produced a finding naming
the halo. A visible candidate within threshold now settles the endpoint.
Fixtures added for both, and for the two guards a mutation sweep found
unpinned: the connector-shape test and the dash-offset skip.
* test(check): kill every surviving mutant in the connector_orphan path
Seven guards still passed the suite when deleted: the four candidate
filters, the connector layer's own visibility gate, the defs/marker skip
and the chord floor. Each now has a fixture where that guard alone
decides the outcome.
The earlier sweep that missed them was mutating the wrong copy of a
shared line — connectorAnchorRects and connectorEndpointCandidates carry
the same filter text, and a first-match replace edited the detached one.
Mutations are scoped by enclosing function now.
* test(check): order the halo before the node it hides under
The halo fixture proved nothing. Both boxes contain the endpoint, so both
score gap 0, and the strict tie-break keeps whichever comes first in
document order — which was the visible node. The old code picked the same
winner and stayed silent too.
Putting the halo first makes the tie-break hand it the slot, so only the
visible-candidate check keeps the endpoint attached.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Combines two fixes to the layout audit:
- `staticIssueKey` no longer includes `issue.text` for `content_overlap`
and `text_occluded`; both builders now emit a unique selector for both
ends, so the selector pair is the identity. An animating subject
(count-up, typewriter, rotating word) no longer splits one held
collision into transient groups.
- A clipping box that is its own nearest constraint reports
`clipped_text` only, instead of also reporting `text_box_overflow`.
A non-clipping self-constraint still reports.
Combines two fixes to the motion sidecar path:
- An ambiguous selector no longer aborts the whole spec. It is reported
as a finding, the assertions that depend on it are skipped and named,
and every other assertion still evaluates. `assertionTargets` is now
exhaustive over the assertion kinds so a new kind cannot silently fall
back to an arbitrary first match.
- A sidecar that will not parse is reported as a spec finding instead of
ending the run, so the composition is still audited.
* fix(cli): clarify publish visibility and claim links
* style(cli): apply oxfmt to publish visibility test
* chore(skills): regenerate skills manifest for updated references
* fix(cli): stop an in-place re-publish claiming it made the project private
* test(cli): pin in-place visibility copy to the plain re-publish route
A brand band shows logo artwork on a transparency checker, where a badge (a
mark knocked out of a full-bleed disc or square) reads as a solid blob. The
capture kept only the first icon that downloaded, so which of a site's icons
survived was decided by ranking alone, and nothing recorded what the survivor
actually looked like.
Keep them all. Each declared icon is written as assets/icon-<rel>-<sizes>.<ext>
and classified as bare-mark, badge, or unknown, then the best one is copied onto
the historical assets/favicon.<ext> stem so a stem match still finds it.
Classification is deterministic and cheap. SVG is read from markup, not
rendered: these files routinely fill via CSS custom properties behind a
prefers-color-scheme query, and librsvg resolves neither, rasterising one real
favicon to a fully transparent image while Chrome draws it correctly. Raster
icons are sampled at the midpoint of each edge rather than the corners, because
a full-bleed disc has four transparent corners and would otherwise read as a
bare mark.
The headline prefers a bare mark, then falls back to the existing ranking.
Preference is binary on purpose: an "unknown" is not promoted above a badge,
or an undecodable .ico would outrank a good SVG purely for being unexaminable.
extracted/icons-manifest.json records every icon, its shape and the evidence
for that shape, plus which file became the headline, its rank, and why. A
choice whose losers are invisible is indistinguishable from having had no
choice, which is how a substituted icon went unnoticed in the first place.
Worth noting for consumers: sites often declare no bare mark at all. An
apple-touch-icon is composited onto an opaque tile per Apple's spec, so it is
a badge, not a mark. The manifest says so rather than implying the preference
was satisfied.
A capture is one session with two halves: Chrome navigates the page with a
browser User-Agent, then Node fetches the assets that page referenced. Those
halves sent three different identities — "HyperFrames/1.0" from the asset and
media downloaders, a bare "Mozilla/5.0" from the stylesheet inliner, and the
real Chrome UA from the navigation itself.
An origin is free to answer those differently, and anti-bot edges do. Capturing
one large site, GET /favicon.svg answers 403 text/html to "HyperFrames/1.0" and
200 image/svg+xml to the UA the very same capture had just navigated with. The
favicon ranker had already picked that SVG as the best declared icon; the 403
discarded it and the downloader fell through to the next candidate, so the icon
written to assets/ was chosen by the CDN's bot rules rather than by the ranker.
The capture reported it as one "unavailable" drop and carried on.
Hoist the navigation UA into CAPTURE_USER_AGENT and use it for every
out-of-band fetch the capture makes: favicons, images, og:image, fonts,
stylesheets, Lottie JSON and videos. One constant is what stops the two halves
drifting apart again.
Verified end to end against that site: before, assets/favicon.png (the
apple-touch icon) plus one unavailable drop; after, assets/favicon.svg, byte
identical to the file the site itself serves.
* fix(studio): prevent preview hang on burst external file rewrites
Two interacting bugs caused Studio to freeze when multiple processes
(generator, check, snapshot) burst-wrote index.html within seconds:
1. SSE listener leak: the /api/events handler added a watcher listener
per client connection but never removed it on disconnect. Reconnects
accumulated dead listeners, each triggering readFileSync on every
file change and writing to closed streams.
2. Generation starvation: processChange incremented generationRef and
awaited drainPendingChanges. A second event arriving mid-drain bumped
the generation, causing the first drain to bail at the generation
check. With rapid writes, no drain ever completed and Studio stayed
frozen on stale content.
Fix 1: use stream.onAbort() to remove the watcher listener when the
SSE connection closes.
Fix 2: gate processChange with a draining ref. While a drain is in
progress, stash the latest event. On completion, process the stashed
event — the last write in a burst always completes its reload.
Closes#3646
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): align coordinator tests with drain serialization
Update existing test to expect the new behavior: when two events
fire in quick succession, the first drain completes and triggers a
reload (previously it was silently discarded). The stashed event
then starts a second drain.
Also fix the burst-write test to use the drains array pattern and
explicit act() flushes for stashed event processing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): stash events with allowDuplicate and harden listener cleanup
Address Rames's review findings:
- Stash with allowDuplicate: true so re-dispatched events are not
swallowed by the duplicate guard (the identity was already written
on the way in, so the stashed event matched itself on re-entry).
- Wrap SSE keepalive loop in try/finally so the listener is removed on
both abort and throw, not just abort.
- Restore stale-completion guard test coverage lost in the rename.
- Use await act(async () => {...}) for burst dispatches so assertions
depend on the stash guard rather than scheduling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(studio): simplify drain serialization and restore SSE cleanup
Restructure processChange into intake + drain loop:
- processChange is now synchronous — validates, dedupes, checks own
echoes, enqueues the accepted payload, and starts the drain loop
- startDrainLoop runs while the pending slot is non-null, draining
one event per iteration via drainOnePending
- No recursive void processChange(...) from finally, so no
allowDuplicate escape hatch needed — stashed events never re-enter
intake guards
SSE listener: restore stream.onAbort alongside try/finally. Hono's
sleep() never throws, so finally alone doesn't fire on disconnect.
Both paths call removeListener (Set.delete is idempotent).
Tests: remove stale-drain test that contaminated subsequent tests by
emptying the shared roots array mid-test. Use sync act() for burst
dispatches — the stash decision is synchronous.
All 10 coordinator tests pass locally (NODE_ENV=test).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>