Commit Graph
78 Commits
Author SHA1 Message Date
Leonel RivasandClaude Opus 5.5 42eb6e9ad2 fix(cli): replace pre-planted links when writing capture files (#4298)
* fix(cli): replace pre-planted links when writing capture files

writeCaptureFileSync wrote through whatever sat at the target path, so a
symlink or hard link planted in the capture directory redirected the write to
its target. Stage each file in a private directory next to the target and
rename it over the target instead, the same publication pattern the font
cache uses since #3669. Rename swaps the directory entry, so a planted link is
replaced rather than followed, while intentional overwrites such as the
deadline partial bundle keep working. Cleanup only removes the staging
directory this call created. A caller's own flag (wx for meta.json) is passed
through unchanged.

Two capture writes bypassed the writer and stayed exposed: the contact sheets
(sharp's toFile opens the path itself) and the BLOCKED.md failure note. Route
both through writeCaptureFileSync.

Fixes #4243

* fix(cli): address CodeQL findings on capture writes

Read the captured file before lstat-ing it in the regression tests so
CodeQL does not report a check-then-use race, and route both write modes
of writeCaptureFileSync through a single writeFileSync call, the one
write site the writer already had on main.

* fix(cli): route media previews through the capture writer

The Lottie and video previews in mediaCapture.ts still let Puppeteer write
the PNG itself via page.screenshot({ path }), which opens whatever sits at
that path, so a pre-planted link there redirected the write. Take the
screenshot as a buffer and write it with writeCaptureFileSync, like
screenshotCapture.ts already does.

Also restructure writeCaptureFileSync so its only write is the same
writeFileSync(path, ...) call main has. Publication now stages through a
separate helper that calls back into the writer with O_EXCL (wx) on a
fresh staging path, then renames over the target. Behavior is unchanged,
and CodeQL keeps tracking the existing network-data-to-file finding on
that line instead of reporting it as new.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2KDat7mo5Dr8wFkFzobyb

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 06:24:21 +01:00
Miguel Ángel 952e9228b5 fix(cli): bound capture runs and degrade WebGL navigation (#4215)
* fix(cli): bound capture runs and degrade WebGL navigation

* fix(cli): keep partial capture on deadline

* fix(cli): persist partial capture artifacts

* fix(cli): reduce capture phase complexity

* chore(cli): tighten capture comments

* fix(cli): restore post-capture phase calls

* fix(cli): parse extracted scripts before filtering

* fix(cli): remove next data through dom filter

* refactor(cli): move capture attempt into module

* refactor(cli): extract capture phases into modules

* refactor(cli): split navigation checks

* refactor(cli): deduplicate capture phase modules

* fix(cli): preserve bounded capture output on deadline

* fix(cli): secure capture artifact writes

* fix(cli): close remaining capture file alerts

* fix(cli): mark deadline captures unsuccessful

* refactor(cli): centralize capture phase schema

* style(cli): format capture watchdog test

* docs(cli): condense capture attempt header
2026-09-21 02:52:54 -04:00
Miguel Ángel c610d8491c fix(cli): the contact sheet png test no longer waits on a Windows font cache build (#4181)
* fix(cli): keep the contact sheet png test within its budget on slow runners

sharp's bundled Pango/Fontconfig backend enumerates the OS font
directories on first text render and builds an on-disk cache before it can
draw the sheet's labels. On Windows CI that first-render scan is
unbounded (measured 12s-103s here for otherwise-millisecond work), which
is what the 60s ceiling was really absorbing. Point Fontconfig at an
empty, per-run config on win32 so label rendering has nothing to scan,
and bring the budget down to reflect the real raster work.

* test(cli): keep the createContactSheet timing label to measure the Windows font fix

Diagnostic: the Windows job on this branch prints createContactSheet's wall time

with the one-font Fontconfig override, to compare against 3.7-18.4s without it.

* test(cli): temporary diagnostic, time the first svg and first svg text render

* test(cli): temporary diagnostic, first text render in a child process

* test(cli): set the Windows Fontconfig file in the vitest config, before workers start

* test(cli): drop the contact sheet timing diagnostics

* fix(cli): the vitest Fontconfig dir is created with an unpredictable name and removed on exit
2026-09-19 14:28:31 -04:00
Miguel Ángel e8fdfd0d65 fix(cli): install onnxruntime-node and google-genai on first use (#4156)
* fix(cli): install onnxruntime-node and google-genai on first use

Both carry deprecated transitive dependencies (boolean, node-domexception)
that showed up as npm warnings on every npx hyperframes install. They move
to devDependencies (types and the Intel-binding check still need them at
build time; the bundler externalizes them) and load through a new
on-demand loader that installs into the CLI's own cache dir the first
time a feature needs them, with one stderr line, no prompt, and a
manual-install command named in the error if the network is unavailable.

hyperframes doctor lists each as installed (with version) or
not-installed-yet.

* fix(cli): sweep stale install staging and bound network retries

* fix(cli): optional-package install only sweeps staging dirs whose process is dead

Staging dirs now carry a per-call suffix after the pid, and the sweep skips any dir
whose pid is alive, so concurrent first runs no longer delete each other's install.

The install error suggests the network only for network failures, and the manifest
path has one owner. install() takes an injectable npm runner so tests drive the real code.

* fix(cli): optional-package sweep also clears old-format staging dirs and tests a live foreign dir

The concurrent-callers test shared one pid, so it never exercised the sweep rule; a live foreign
staging dir test now does. Old-format dirs of a dead pid are swept, and a failed cleanup no
longer aborts a viable install.

* fix(cli): optional-package sweep names a stale dir it could not remove

A locked leftover is still skipped so the install proceeds, but the warning makes it visible.

* docs(cli): shorten the install docblock to the comment limit

Comment-only change.
2026-09-19 05:27:35 -04:00
Miguel Ángel 9981d9afb7 fix(capture): never silently render a webgpu composition blank (#4091)
* fix(capture): never silently render a webgpu composition blank

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(engine): keep quoted attribute values whole when reading the composition root tag
2026-09-18 18:06:23 -04:00
Miguel Ángel 3867d8da5d test(cli): time each native Sharp call in the flaky Windows contact-sheet test (#4070)
This test has hit its 60s ceiling on Windows CI runners before, and the log
only ever shows the bare timeout, not which of the four native Sharp calls
actually stalled. Wrapping each one in console.time/console.timeEnd costs
nothing when the test is fast and names the slow step the next time it
isn't. No behavior change.
2026-09-18 08:45:04 -04:00
James Russo 96637aeb1c fix(capture): share the render host policy and block localhost aliases (#3844) 2026-09-10 07:59:30 -04:00
James Russo 26916b7bdd fix(cli): validate captured Lottie archives and previews (#3811)
* fix(cli): validate captured Lottie archives and previews

* fix(cli): bound Lottie discovery before buffering

* fix(cli): bound Lottie discovery lifecycle and archive paths

* fix(cli): retain highest-priority Lottie candidates
2026-09-09 08:50:48 -04:00
James Russo 1aa5b9e4fa fix(cli): validate captured image and font downloads (#3809)
* fix(cli): validate captured image and font downloads

* fix(cli): bound nested assets and preserve distinct fonts
2026-09-09 07:15:29 -04:00
Miguel Ángel c5b7f6aeab fix(check): retry system Chrome after managed launch crash (#3701) 2026-09-08 18:11:13 +00:00
Miguel Ángel 1fa31d5dfb feat(cli): keep every icon a site declares and headline the bare mark (#3727)
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.
2026-09-05 22:35:45 -04:00
Miguel Ángel 7a07ea9ac3 fix(cli): fetch a page's assets as the same agent that loaded the page (#3726)
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.
2026-09-05 22:20:15 -04:00
James Russo 41551c7acb fix(cli): read caption images through checked file descriptors (#3724) 2026-09-05 20:43:36 -04:00
James Russo b49fba8dea fix(cli): preserve concurrent scaffold config creation (#3721)
* fix(cli): preserve concurrent package metadata during init

* fix(cli): preserve concurrent project config creation
2026-09-05 19:35:41 -04:00
James Russo e6d2816e1d fix(cli): publish capture metadata without overwriting files (#3720)
* fix(cli): create capture metadata exclusively

* fix(cli): publish capture metadata without following links
2026-09-05 18:47:53 -04:00
James RussoandXuanru Li 9ed50028c4 fix(capture): sample computed shadows on ordinary boxes (#3686)
Co-authored-by: Xuanru Li <157947275+xuanruli@users.noreply.github.com>
2026-09-04 20:28:17 -04:00
James RussoandXuanru Li a7cdd583e7 fix(capture): retain opaque and glass button styles (#3685)
Refresh the capture fixes proposed in #1880 with browser-backed regression coverage.

Co-authored-by: Xuanru Li <157947275+xuanruli@users.noreply.github.com>
2026-09-04 20:28:07 -04:00
Miguel Ángel a3954aa1cb fix(cli): persist what the server answered for a captured page (#3553)
* fix(cli): persist what the server answered for a captured page

A page that renders is not a page that succeeded. An error page has a title,
a palette, typefaces and a DOM, so every extractor downstream reads it happily
and produces a design system belonging to whoever wrote the error page rather
than to the site's owner.

The status was already read, once, to feed `detectBlockedPage`, and then
dropped. That helper cannot stand in for it: it decides whether the rendered
document LOOKS like a protection wall, over a minimal-DOM heuristic that a
rich error page passes. "Was this blocked?" and "what did the server answer?"
are two questions, and widening the first to carry the second would leave a
heuristic owning a fact.

So the response status is persisted plainly, as its own record, and every
consumer decides for itself what a non-success response means for its product.
Written before the blocked-page check runs, so the record's absence means
"navigation never produced a response" — a third state distinct from a status
of 404 and from a status of null.

* fix(cli): report the response status in the capture command's JSON output

The status reached `CaptureResult` and was then dropped at the CLI boundary,
which is the same read-once-and-discard that made the error page harvestable
in the first place. `--json` is the documented programmatic surface, and an
agent reading `ok: true` off a capture of a 404 has no way to see it there.
2026-09-04 18:03:35 +00:00
Miguel Ángel 7dc31bd2cc fix(cli): score apple-touch-icon-precomposed at the 180px default (#3607)
declaredSize() matched the rel token "apple-touch-icon" exactly, so the
legacy "apple-touch-icon-precomposed" spelling (one token, not two) fell
through to 0 instead of the 180px Apple default, even though the selector
already collects it. Same page, one spelling apart, opposite winner inside
tier 1 — it never drops a candidate and never beats the .ico tier.

Switch to a startsWith check on the token. Exact-token matching stays for
mask-icon, where a longer rel really would be a different asset.
2026-09-03 00:21:05 -04:00
Miguel Ángel 5a5e841c5d fix(cli): pick the highest-quality favicon and keep its transparent background (#3606)
* fix(cli): capture the best declared favicon, not the first one

Pages routinely declare a legacy 16px .ico first and the good asset (an SVG,
or a 180x180 apple-touch PNG) after it. The capture's page evaluate kept only
{rel, href} and the download loop took whichever candidate fetched first, so
the .ico won on every such page. The dropped sizes/type attributes are the only
evidence of quality: page.html on disk does not keep the <link> tags and only
the winner's bytes are fetched, so the choice was unrecoverable downstream.

Keep sizes and type, and rank candidates before downloading: SVG first, then
the largest declared size (an unsized apple-touch-icon counts as 180), then
.ico. The loop still falls through to the next candidate when one fails to
download, so the ranking changes which icon wins, never whether one lands.

Ranking is a pure function over the declared attributes, unit-tested against
the link shapes three sites actually publish.

* fix(cli): rank a pinned-tab mask-icon below real favicons

`link[rel*="icon"]` also matches `rel="mask-icon"`, Safari's pinned-tab
asset: a single-colour silhouette drawn in a browser-chosen tint, not the
site mark. It is served as an SVG, so ranking by format alone promoted the
outline above the page's actual colour favicon.

Give mask-icon its own lowest tier rather than dropping it, so a page that
declares nothing else still lands an icon instead of none.
2026-09-02 23:53:45 -04:00
Miguel Ángel ae7e530f2d feat(cli): capture reports why a referenced asset is not in the folder (#3598)
Capture drops assets for four reasons and reported none of them, so a folder
with thirty images and a folder truncated to thirty images were the same
object. Every drop site was a bare `continue`, `break`, `return null` or an
empty `catch`, and the only signals downstream were two hand-written warning
strings that fired when the budget was already gone before a download pass
started, which is the one case where the pass could not say how much it lost.

`downloadAssets` and `downloadAndRewriteFonts` now return an `AssetDropCounts`
tally beside their result, incremented at the single line that performs each
drop. `capture --json` carries it as `dropped`; the human summary prints a
`Dropped:` line when it is non-zero.

Four reasons, three decisions and one failure:

  size-floor        fetched, then judged too small to be a real asset
  budget-exhausted  the post-navigation clock ran out before this one
  cap-reached       30 inline SVGs, 30 fonts, or 6 faces per family
  unavailable       the request or the write failed

A break now counts everything it did not reach rather than the one it stopped
on, because "how many did we lose" is the question and one is never the answer.

The two budget warnings are gone. Both existed only to cover the case where
the budget ran out before a pass was called, so both passes are now called
unconditionally: a zero budget makes each loop break on its first item and
record `budget-exhausted` for the rest, which costs no network and produces a
real number instead of the word "some". The single remaining warning is derived
from the tally, so the prose and the count cannot disagree.

Measured on a live capture of a large marketing site:

  default budget    232 kept, 91 dropped (39 size-floor, 20 cap-reached,
                    32 unavailable)
  15s budget         30 kept, 299 dropped (279 budget-exhausted, 20 cap-reached)

Same page, same command, and until now both runs described themselves the same
way.
2026-09-02 23:24:59 -04:00
WaterrrForever 8eea3c913f fix(capture): let vision captioning authenticate the way a server can (#3561)
* fix(capture): let vision captioning authenticate the way a server can

Three defects in one phase, all of which end with a capture that reports
"Captioned N/N images" and then "0 images captioned with Gemini" — a
successful-looking run that hands the agent nothing to see by.

1. Credential. The captioner only accepted an API key. A server deployment
   holds a service account, not a key, and a rejected key is indistinguishable
   from an unset one here: every request returns empty text and no error. Vertex
   is now a first-class provider, ranked above the bare key and below an explicit
   OPENROUTER_API_KEY opt-in, configured by HYPERFRAMES_VERTEX_SERVICE_ACCOUNT +
   HYPERFRAMES_VERTEX_PROJECT_ID (region via HYPERFRAMES_VERTEX_LOCATION). It
   carries its own model default because the Gemini API's flash-lite preview id
   is not resolvable on Vertex.

2. Empty captions. Thinking tokens are drawn from maxOutputTokens, so a model
   left free to think can spend the whole budget and return no text — a
   successful request with no caption. thinkingBudget is pinned to 0; a one-line
   factual caption needs no reasoning.

3. Native abort. Rasterizing a batch of SVGs concurrently drove up to SVG_BATCH
   simultaneous librsvg renders through libvips and corrupted the heap:
   `free(): unaligned chunk detected in tcache 2` (SIGABRT) during this phase,
   twice in fourteen days, losing the whole capture each time. A native abort
   cannot be caught, so the concurrency is removed rather than handled —
   rasterization is serialized and libvips' worker pool is bounded, while the
   vision requests, which are the slow leg, stay parallel. Throughput barely
   moves: 225 captions across three real captures, 0 failures, 13-25s each.

* test(capture): pin the rasterization loop to one render at a time

The serialization fix shipped without a regression test on the grounds that native
heap corruption is not unit-testable. The corruption is not, but the property that
prevents it is: `sharp` is mocked to record how many renders are in flight, and a
six-SVG batch must never reach two. A deliberately slow caption stub makes
overlapping renders the faster path, so a future refactor that "optimises" the loop
back to `Promise.all` fails here instead of aborting in production.

Also covered: `sharp.concurrency(1)` is applied — serializing the loop while leaving
libvips' pool at the host core count still fans one render across every core — and an
unrasterizable SVG is skipped without breaking serialization for its siblings.

Verified as a real guard: reverting only contentExtractor.ts to origin/main fails 7 of
the 22 cases in this file.


* fix(capture): tell the truth in the asset-descriptions header when Vertex captioned

The provider gate in `contentExtractor` accepts Vertex when a project and a
service account are both set -- which is the configuration a server
deployment actually has. The header written next to the captions still
tested only for an API key, so a capture whose captions Vertex had just
generated was labelled "GEMINI_API_KEY not set -- descriptions below are
catalog-derived".

That header is not cosmetic: it travels into the context the template
editor reads, telling it to distrust captions that are real.

Mirror the same two variables here, and name every provider in the fallback
text instead of only the API key.

* fix(capture): hand libvips' worker pool back after the renders

`sharp.concurrency(1)` is process-global and was set once, for the whole life of
the process. The bound is right for the rasterize loop -- a native abort in
libvips cannot be caught, so the renders must not overlap -- but its scope was
every later sharp caller in the process, none of which asked for captioning, all
of them pinned to one thread from then on.

Now the host's value is read first and restored in a `finally` around the
rasterize loop, so a skipped SVG cannot cost the process its threads either. The
vision requests below are network work and gain nothing from a pinned pool.

The mock had to grow the getter half of sharp's API -- `concurrency()` with no
argument reports the current value -- since save-and-restore is untestable
without it. Verified as a real guard: dropping only the restore fails both new
cases.

Raised by Rames Jusso in review of #3561 and concurred by Magi.
2026-09-01 23:34:59 +08:00
heygengenesis[bot]andmiguel.sierra 9097d539b1 fix(cli): hide Windows child process consoles (#3529)
Rebase #3529 onto current main. Preserve all 16 issue-scoped Studio server, lint, and CLI child-process windowsHide options, including main's PowerShell null guards and stderr suppression in orphanCleanup.

Regression tests continue to assert windowsHide at each scoped spawn site. #3476 and #3430 remain out of scope.

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-08-31 18:11:20 -04:00
Xuanru LiandCursor 43dba22057 fix(capture): bound scroll/evaluate timeouts so heavy pages keep capturing (#3236)
* fix(capture): bound scroll/evaluate timeouts so heavy pages keep capturing

Fonts/Vercel-class sites were failing after navigation when a single in-page
scroll/evaluate hung until protocolTimeout. Drive lazy scroll from Node,
degrade on evaluate timeouts, and stop labeling those failures as bot blocks.

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

* fix(capture): enforce stage budgets and surface evaluate degradation

Bound each scroll/content evaluate with remaining stage time so a wedged
page.evaluate cannot outlive the advertised 15s/8s budgets, skip recovery
CDP calls after expiry, and return/propagate timed-out animation and
screenshot work so caller warnings are reachable.

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

* fix(capture): prefer TimeoutError instanceof for timeout classification

Use puppeteer-core TimeoutError as the primary signal for navigation vs
evaluate/protocol timeouts, with message checks only as a fallback for
string formatting and non-TimeoutError cases.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:26:22 -07:00
Xuanru LiandCursor 7860d19433 fix(capture): fall back from networkidle2 to domcontentloaded on nav timeout (#3224)
* fix(cli): fall back from networkidle2 to domcontentloaded on capture nav timeout

Sites like yahoo.com never reach network idle, so website capture hung for
the full 120s navigation budget. Prefer a short networkidle2 attempt, then
continue with domcontentloaded and the existing settle/scroll path.

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

* fix(capture): use remaining timeout budget for domcontentloaded fallback

Keep total navigation time within the caller --timeout instead of
re-applying a full 30s floor after networkidle2.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:31:59 -07:00
Miguel Ángel 9ae0007261 fix(cli): preserve capture failure diagnostics 2026-07-31 20:30:07 +00:00
Miguel Ángel ac9458888c fix(cli): omit skipped Lottie previews 2026-07-31 20:30:07 +00:00
Miguel Ángel b38e907404 fix(cli): narrow blocked page titles 2026-07-31 20:30:07 +00:00
Miguel Ángel dfc60797a2 fix(cli): report bounded vision failures 2026-07-31 20:30:07 +00:00
Miguel Ángel 22e3cca966 fix(cli): reject blocked website captures 2026-07-31 20:30:07 +00:00
Miguel Ángel d3607606ee fix(cli): validate capture budget milliseconds 2026-07-31 20:30:07 +00:00
Miguel Ángel 49091e6142 fix(cli): propagate live capture budget 2026-07-31 20:30:07 +00:00
Miguel Ángel 45aead84c9 test(cli): cover bounded capture stages 2026-07-31 20:30:07 +00:00
Miguel Ángel 765a5ae83f fix(cli): bound capture runtime stages 2026-07-31 20:30:07 +00:00
James Russo 3a6b7f0612 fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior

* fix: address WebGPU capture review feedback

* fix: retain overlapping GPU seek work

* fix: satisfy runtime seek completion types

* fix: drain concurrent GPU seek work

* fix: prevent WebGPU capture barrier starvation

* fix: keep WebGPU presentation active during render seeks
2026-07-30 21:52:30 -07:00
WaterrrForever e0dc255e8a fix(capture,audio,docs): defects found running product-launch-video end to end (#2892)
* fix(capture,audio): three defects found running product-launch-video end to end

Found while running the full product-launch-video workflow twice against a real
site (linear.app) to verify PRs #2880/#2881/#2882. All three are independent of
those PRs.

**Scraped SVGs were unusable as files.** `assetDownloader` wrote an inline
`<svg>`'s `outerHTML` straight to `assets/svgs/*.svg`. An inline SVG inherits its
namespace from the HTML parser, so `outerHTML` omits `xmlns` — valid pasted back
into HTML, but not a standalone document, and `<img src="logo-abc.svg">` renders a
broken-image icon. That is exactly how these assets get consumed. `toStandaloneSvg`
now declares the namespace on the way to disk (plus `xmlns:xlink`, but only when an
`xlink:` attribute is actually used). The filename hash moved to the bytes that
land on disk so it still cannot drift from content.

**`sfx: none` became a cue named "none".** `fetch-sfx` split the storyboard's
`sfx:` list and dropped only empty strings, so the absence marker reached the
engine as a real cue that could not resolve. The absence spellings are part of the
storyboard vocabulary; drop them.

**`bgm_pending` was lost translating neutral meta to product-launch meta.** A
detached Lyria/MusicGen generate leaves `bgm: null, bgm_pending: true` until the
track lands. `toProductLaunchMeta` returned only `{bgm, voices, sfx}`, so "not
ready yet" became indistinguishable from "silent by design" — and because
`fetch-sfx` rewrites `audio_meta.json` from the sidecar, a still-generating bed was
snapshotted away with nothing to signal it. The flag now survives, and `fetch-sfx`
warns when it snapshots a pending bed instead of leaving a silent film that the
storyboard claims has music.

Not included, deliberately: `assemble-index.mjs` rewrites `index.html` wholesale
and so discards the block `transitions.mjs inject` wrote, meaning any Step 6 rework
silently loses transitions. Fixing that means deciding whether assemble preserves an
injected block or inject becomes re-appliable — it touches both scripts and the
Step 5/6 ordering in SKILL.md, so it deserves its own change.

Validation: `node --test skills/product-launch-video/scripts/audio.test.mjs`
(13 pass, 5 new) · `vitest run src/capture` (85 pass, 5 new) · `bun run lint:skills`
· oxlint/oxfmt clean · `tsc --noEmit` clean

* feat(capture): re-add the full-page plate a scroll shot needs, at 1x

`product-launch-video` tells a scroll shot to animate a viewport over a full-page
capture. No such file existed: capture emits 15 viewport-sized scroll-position
tiles, and a plate is not substitutable by tiles — a viewport travelling down one
continuous image is the whole point.

An earlier `full-page.png` was dropped in 62b55171e because 1/8 agents read it and
the contact sheet covered the same ground. That measured it as a *comprehension*
artifact, on an eval where nothing was building scroll shots. The scroll shot is a
different consumer, so this brings the plate back — but not as it was, because two
things have to hold for it to be worth having:

- **Taken last.** After the scroll traversal, so lazy images have loaded and
  scroll-triggered reveals have fired. A plate shot on arrival is full of blank
  bands, which is a good reason for an agent to look once and never again.
- **Sticky chrome neutralised.** `fullPage` bakes a fixed header in at one
  position, freezing a nav across the middle of the plate. The viewport tiles keep
  sticky on purpose (natural browsing state); the plate cannot. Positions are
  recorded and restored in a `finally`, so the extraction passes that run afterwards
  see an unmodified DOM.

**1x, deliberately.** 2x is what you'd want to push in without softening text, but
doubling a long marketing page passes Chrome's 16384px screenshot cap precisely on
the pages that most want a scroll shot (linear.app: 10962 CSS px → 21924 at 2x). At
1x a 1920-wide plate is pixel-exact for a 1920x1080 viewport. A frame that needs
headroom captures its own region at 2x instead. Pages over the cap get no plate
rather than a silently clipped one, and the caller falls back to the tiles.

Validation: `vitest run src/capture` — 90 pass (5 new) · oxlint/oxfmt clean ·
`tsc --noEmit` clean

* docs(product-launch-video): point the scroll shot at the plate, make handoff fields binding

Two follow-ups from the same end-to-end runs, now that #2880 and #2881 have landed and
their sentences exist to edit.

**The scroll shot pointed at an artifact that did not exist.** #2881 said "use a 2x
full-page capture and animate the viewport over it". Neither half held: capture emitted
no full-page image, and 2x on a long marketing page passes Chrome's 16384px screenshot
cap precisely on the pages that most want a scroll shot. Both runs watched the agent go
looking, not find it, and improvise — once by re-capturing 2x strips per section, once by
using the native 1920x1080 tiles full-bleed. This PR's capture commit adds the 1x plate,
so the sentence can now name something real: the plate, its absence on pages too tall to
capture in one piece, the tile fallback, and why pushing in past 1:1 still wants a region
capture of its own.

**A constant field was being read as an absent one.** #2880 asks for x/y, scale, opacity
and direction/speed on every handoff. Across two runs on the same model, `opacity` went
0/12 then 12/12 — when the value never changes, leaving it out is a reasonable reading of
the instruction. But downstream an omission and "there is no handoff here" are the same
thing, so the field set has to be stated as binding even when constant. Same clause added
to the worker's side of the contract.

Validation: `bun run lint:skills`

* fix(capture,audio): close the three contract gaps raised in review

Review on #2892 (Rames, Magi) found the fixes correct inside the changed files but
incomplete at the contract level. All three hold up against source; two of the three
were reachable in production, and the plate one was self-inflicted by this PR.

**The plate guard checked a stale height.** `scrollHeight` was measured before the scroll
traversal and handed to the guard, but the plate is deliberately shot *after* it so lazy
content has loaded — and lazy loading grows the document. The guard's input therefore read
low on exactly the long pages it exists for, letting the check pass and a clipped plate
through, undetectable downstream because the skill only teaches the tile fallback when the
file is *absent*. `captureFullPagePlate` now measures the height itself at call time, and
verifies what Chrome actually produced by reading the PNG's IHDR before writing, since the
capture can trigger another round of loading. Over the cap, nothing is emitted.

**Assembly dropped the flag again.** `bgm_pending` survived into `audio_meta.json` but
`assemble-index.mjs` rebuilt its audio object from three named keys, so at the step that
actually builds the film "not ready yet" still looked like "silent by design" — this PR's
own framing of the defect, one layer further down. The flag rides along now, and a pending
bed with no file raises an anomaly instead of quietly assembling a silent cut against a
storyboard that promises music.

**The sibling adapters had both audio bugs, and there were two of them.** The review named
`faceless-explainer`; `pr-to-video` carries the same file. Its own test asserts the two are
byte-identical ("intentionally identical across the reusing skills"), so fixing one alone
broke that test — which is what caught the second copy. Both now carry the absence-sentinel
filter and the surviving `bgm_pending`, and `faceless-explainer` gets the same five
regression tests.

Also from review (Miga): the sticky-restore in `finally` is wrapped, so a page that broke
mid-capture cannot replace the real error with a cleanup one.

Validation: `vitest run src/capture` — 95 pass (5 new) · product-launch audio 13 pass ·
faceless-explainer audio 10 pass (5 new, incl. the byte-identity contract) ·
`bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean

* fix(capture,audio): meet the two review asks I under-delivered on

Follow-up to 194fb6995. Re-read Magi's review body rather than working from the summary,
and two of the three blockers were addressed in spirit but not to the letter.

**The plate probed before neutralisation, not after.** 194fb6995 moved the measurement off
the caller's stale value and into the function, but took it before forcing fixed/sticky
elements to `static`. The review called this out specifically and is right: dropping those
elements back into flow grows the document, so the probe could still read under the cap on a
page that is over it once neutralised. The probe now runs after neutralisation and before the
shot, inside the same `try` so restoration still happens on the early return. Added the exact
case asked for — initial height under the cap, final height over it — asserting no
screenshot is taken, no file is written, and the page is still handed back unmodified.

**Assembly warned where the review asked it to refuse.** An anomaly in a list is not
enforcement: assemble is re-run on Step 6 rework, long after the audio step's warning
scrolled past, and a warning still lets a silent film out the door over a snapshot whose own
JSON says the bed is generating. `assemble-index.mjs` now dies on `bgm_pending && !bgm`, with
`--allow-pending-bgm` as the deliberate escape for previewing mid-generate. Pinned with three
tests in a new `assemble-index.test.mjs`: refusal writes no index.html, the escape assembles
and says so, and a film that is silent *by design* still assembles untouched — the
distinction the flag exists to make.

Validation: `vitest run src/capture` — 96 pass (6 new) · product-launch audio 13 pass ·
assemble-index 3 pass (new file) · faceless-explainer audio 10 pass ·
`bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean

* fix(audio): carry the bgm_pending gate into the sibling assemblers

The remaining blocker, and one this PR created: the previous commit made all three copies of
the audio adapter *emit* bgm_pending, but only product-launch-video's assembler *reads* it.
So faceless-explainer and pr-to-video would do exactly what this PR set out to stop — parse
an audio_meta.json that says the bed is still generating and assemble the silent film without
a word. Producer fixed in three places, consumer in one, is worse than neither: before this
PR there was no flag to drop.

Both siblings now get the same three changes product-launch-video got — the flag carried
through the audio object, `die` on `bgm_pending && !bgm`, and `--allow-pending-bgm` as the
deliberate escape — plus the same three tests: refusal writes no index.html, the escape
assembles and says so, and a film that is silent *by design* still assembles untouched. That
last one is the one worth having; it proves the flag restored a distinction rather than just
adding a gate.

Applied as three separate patches rather than a file copy: these assemblers have diverged
(pr-to-video validates a bare `<template>` fragment where product-launch takes a `<div>`
root, which its fixture reflects).

`music-to-video` has the fourth copy of this assembler and is deliberately untouched: it has
no audio producer, and its assembler reads `{ voices: [] }` with no bgm path at all, so the
flag can never reach it.

Validation: product-launch / faceless-explainer / pr-to-video assemble-index — 3 pass each ·
product-launch audio 13 pass · faceless-explainer audio 10 pass · `vitest run src/capture`
96 pass · `bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean
2026-07-30 18:29:43 +08:00
Miguel Ángel fdc5932897 fix(cli): honor check navigation timeout (#2860)
* fix(cli): honor check navigation timeout

* test(cli): clarify diagnostic timeout precedence
2026-07-29 20:50:20 +02:00
Miguel Ángel 13ac9e3905 fix: remove vulnerable runtime dependency paths 2026-07-28 20:45:43 +00:00
Xuanru LiandClaude Opus 4.8 72e2f08f15 feat(lint): dense motion re-sampling for content_overlap (#2746)
* feat(lint): dense motion re-sampling for content_overlap

Transient text-on-text collisions during continuous motion (e.g. an
orbiting label card crossing the center card) overlap for a fraction of
a second that the sparse 9-point layout grid seeks straight past. The
content_overlap detector is correct; it just never gets a sample at the
crossing moment. Rerun ONLY content_overlap on an 8fps grid (text-only,
cheap) when the composition animates; findings feed the existing
persistence tiering unchanged.

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

* fix(lint): unconditional dense content_overlap pass + honor 500ms floor

Round-1 blocker: the dense motion-overlap re-pass was gated on sparse-grid
geometry fingerprints changing, so an animation aliased to the sparse grid
(identical fingerprints, yet colliding between samples) bypassed the pass —
exactly the transient false-negative it was built to catch. Remove the gate:
the dense pass now runs unconditionally (bounded, text-only), driven by the
composition timeline rather than a fingerprint heuristic.

Round-2 follow-ups:
- Persistence-tier drift: at 8fps, occurrences>=2 spans only ~125ms, not the
  ~500ms the design intends, and it short-circuited before the ms floor.
  content_overlap promotion now requires BOTH occurrences>=2 AND a literal
  firstSeen..lastSeen span >= 500ms, so the wall-clock floor is honored at any
  sampling density. Comment block updated to match.
- Sample cap scales to hold a true 8fps grid up to ~75s (raised 120 -> 600)
  with an explicit note that longer comps degrade below 8fps to stay bounded.

Tests:
- Replaced the trivial "warning at every sample" test with a real between-grid
  regression: a collision living only inside (3.5,4.5) — a gap the sparse grid
  seeks past — is detected and, held ~750ms, promoted to error.
- Replaced the now-invalid "skips when static" test with one asserting the
  dense pass runs even when sparse fingerprints are identical (aliased motion).
- Added a tiering regression: two dense occurrences spanning ~125ms stay a
  warning (not error). Both new guards verified red before the fix.

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

* perf(lint): settle-free geometry seek for dense content_overlap pass

The dense overlap re-pass did up to OVERLAP_MAX_SAMPLES full-settle seeks
(120ms paint settle each, ~72s of pure sleep at the ceiling) even though
collectOverlap only reads getBoundingClientRect geometry, valid
synchronously after the timeline setTime. Add a settle-free
DENSE_GEOMETRY_SEEK_OPTIONS + driver.seekGeometry used only by the dense
loop; the base grid keeps full-settle driver.seek.

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

* test(lint): document + cover content_overlap 500ms boundary for sparse callers

The occurrences>=2 AND heldMs>=500 promotion rule is a semantics change for
sparse callers (--samples 20, --at, short comps) whose two samples can land
<500ms apart. Document the change in the tiering comment and add boundary
tests: 499ms span stays warning, 500ms span promotes to error.

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

* fix(lint): make dense content_overlap seek genuinely geometry-only

Per review: DENSE_GEOMETRY_SEEK_OPTIONS only overrode settleMs, still
inheriting animationFrameSettle:double + waitForFontsMs:500 → ~3 frame
waits + font wait per seek → ~30s at the 600-sample cap. Geometry
(getBoundingClientRect) is valid synchronously post-setTime, so drop all
post-seek waits (animationFrameSettle:none, waitForFontsMs:0, settleMs:0).
Add options-level regression locking the geometry-only contract. Also fix
a stale comment name (detectMotionTextOverlap → collectMotionOverlapSamples).

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

* style: collapse multi-line comments to single lines

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:29:24 -07:00
James Russo 1b4cf12cd3 refactor(cli): add stderr diagnostics logger + oxlint guard (#2551) 2026-07-16 09:35:27 -04:00
James Russo d92d1d4f51 fix(cli): keep diagnostics on stderr for --json commands (#2522) 2026-07-16 09:14:39 -04:00
ViaandClaude f3b941f1e3 fix(cli): reduce CRAP score by extracting exit-path helpers; bump contactSheet Windows timeout
Fallow flagged the `uncaughtException` arrow at CRAP 30.0 (right at
threshold). Extracting the three exit paths — emitCliErrorEvent,
reportPostRenderTerminationEvent, exitAfterPostRenderTermination,
exitAfterCliFailure — pulls the two `_flushSync?.()` optional-chain
branches out of the arrow body and drops cyclomatic to 3. Same shape
already used on compileStage in a sibling PR. Behavior preserved:
EPIPE → exit(0), renderSucceeded → report + flush + exit(0),
default → commandFailed + track + flush + exit(1) for uncaught;
renderSucceeded → report + return, default → commandFailed + track
for rejection.

Windows contactSheet flake: `createContactSheet > writes PNG output`
timed out at 20025ms (default ceiling 20000ms) on Windows. Test does
milliseconds of real work — the wall-clock overhead is sharp's native
binary fork under runner I/O jitter. PR #2492 already tried lightening
the test; the flake persists at the default ceiling. Bumping the
per-test timeout to 60s absorbs runner jitter without hiding real
slowdowns (a genuine sharp regression would blow through 60s just as
readily as 20s). Targeted per-test bump, not a global suite bump.

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

— Via
2026-07-16 07:08:45 +00:00
James Russo dbdf03cd6b fix(ci): keep contact sheet test lightweight (#2492) 2026-07-15 14:38:57 -04:00
WaterrrForever b9be0b2625 feat(skills,studio,media-use): the intent layer, review loop, and user memory — BRIEF.md, companion mode, recipes; /website-to-video folds into /product-launch-video (#2133)
* feat(studio,cli): per-frame board comments, self-refreshing storyboard, status-aware preview landing

Per-frame comment boxes on the storyboard board batch into
.hyperframes/frame-comments.json (a resubmit wins per frame; unconsumed
comments on other frames are kept). Submitted-but-unconsumed comments
stay visible — a toolbar banner plus a per-tile echo — until the agent
consumes the file; the banner also says what to do next (reply anything
in the agent chat).

The board keeps itself current: GET /projects/:id/signature exposes the
watcher-cached project signature, the storyboard payload carries the
signature it was derived from, and the view polls at 2s (hidden tabs
skipped, re-checked on visibility), refetching in place with no loading
flash. Posters bake the signature into their URL so tiles fill in as
sketches land and a poster that failed mid-write retries on the next
version; the empty state upgrades itself when STORYBOARD.md appears,
and its handoff prompt now points the agent at the review loop and uses
the parser's real status vocabulary (outline, not planned).

preview lands the browser on the storyboard view while the board is the
review surface — any frame built, or pure planning (srcs declared, none
on disk yet) — and on the timeline once the video is assembled.

* feat(skills): the review loop — plan, sketch, build as one shared process

hyperframes-core/references/review-loop.md is the single source for the
three-pass collaborative review: the plan proposed on a live board
(§ 1), wireframe sketches marked built with one layout question (§ 2 —
real words on plain blocks, run no CLI; a confirmed board is itself a
valid deliverable when the user asked for a storyboard, not a video),
the build dressing confirmed layouts (§ 3, worker or inline), and the
final look (§ 4). Autonomous runs skip every gate and keep one question
before render.

The three narrative workflows' Steps 3/4/6 collapse to references plus
their sketch stand-ins (captured-asset blocks for product-launch-video,
plain code panels for pr-to-video); the confirmed-sketch handoff stays
in each frame-worker prompt. general-video plans on a board for
multi-scene narrative pieces in collaborative mode — its sketch pass is
layout-before-animation with the user watching. The router treats
"I want a storyboard" as a process request rather than a route, and
closes exploratory intake by recommending a route plus how the run will
review.

The supporting contracts land next door: the comments channel (silent
submit, one reply picks it up, check the file before the words) in
brief-contract § 1; the sidecar schema and the built status rung in
storyboard-format; the mode question asked first and alone in the three
workflows' Step 0.

* feat(media-use): user memory — remembered preferences and frozen recipes

Two tiers of memory on media-use's existing two-tier storage split.

Preferences (lightweight): confirmed brief answers — destination, aspect,
language, mode, voice, style preset — recorded to the project's
.media/preferences.json (committed, the team inherits it) and promoted
to the personal ~/.media/preferences.json once the same value is
confirmed in two different projects (a sightings ledger accumulates the
cross-project evidence user-side, since project files can't see each
other). prefs.mjs get/record; merge reads project-over-user; a changed
value restarts its provenance.

Recipes (heavyweight): one approved run frozen as a named, versioned
bundle — frame.md, the storyboard skeleton (structure kept: durations,
transitions, srcs, Video direction; statuses reset to outline; content
blanked to per-frame fill-ins naming the beat's role), and the confirmed
brief values. Named folders, not content hashes: re-freezing bumps
version and archives <name>@v<N>; a freeze is already confirmed, so it
promotes to the user tier immediately. recipe.mjs freeze/list/use, plus
resolve --type recipe --entity <name> delegating like grade/lut.

16 new node --test cases; the media-use lib suite is 168/168.

* feat(skills): wire user memory into the brief and the review loop

brief-contract § 2 gains Remembered defaults: read the merged
preferences before Round 2 and let a remembered value become the
recommended option with a receipt naming its source project. Memory
changes the default, never the question — every ask-marked field still
gets asked, and what the request says this time beats what was picked
last time. Record only what the user actually confirmed (a defaulted
voice nobody chose is not an answer; a "go" that accepts the
recommended defaults is). The first record announces itself once;
after that the receipts carry the reminder. In autonomous mode a
remembered value becomes the decided value, receipt included.

The three narrative workflows read the remembered defaults before
Round 2, record the confirmed answers at the Step 0 gate, record the
chosen preset at the Step 2 gate (pr-to-video excepted — its preset is
fixed), and fall back to the remembered voice when the request names
none. general-video's discovery reads the same defaults.

Recipes wire in at both ends: Step 0 checks for a matching recipe
before the mode question — one question, plural-aware, and adopting
one fills the brief, skips the design step, and drafts the storyboard
from the frozen skeleton while every review gate still runs. The
review loop's final look (§ 4) offers the freeze once after approval,
and the confirmation teaches the recall phrase — the name is something
the system reminds the user of, never something they must remember.
The router recognizes a named recipe or "like last time" as a route.

* docs(skills): the sketch pass names check, not the deprecated validate

* feat(skills): intent-layer references — process, route briefs, capability menu, BRIEF.md format

* feat(media-use): brief skeleton as the recipe's fourth artifact; flow/storyboard preference keys

* feat(skills): the intent layer conducts every brief — workflows execute BRIEF.md

* feat(skills): retire the mode preference key; sync catalog surfaces for intent layer

* refactor(skills): dedupe router vs intent-layer guidance — one owner per rule

* feat(skills): the design ask — own spec, pick by eye from showcases, or defer

* docs(skills): the design ask says the honest line on capture routes

* feat(skills): product-launch-video absorbs website-to-video as the tour angle

* refactor(skills): keep product-launch-video pristine — a tour is brief intent, not a pipeline branch

* feat(skills): production loop + genre lenses; general-video goes freeform (route yours, laws hold)

* refactor(skills): /hyperframes is the front door - route tables and scope lists leave the workflows

* docs(skills): review-loop pass across skill catalog

* fix(cli): pass project dir to openStudioBrowser in background-server path

* feat(skills): add pitch-round reference - verbalized sampling concept gate

* feat(skills): wire pitch round into intent layer - completeness triage + route eligibility

* feat(skills): editorial capability recommendations, handoff disciplines, menu-probe split

* feat(skills): pitches carry their machinery; source-only-formed requests pitch the telling

* feat(skills): companion goes director - ceiling treatment plus blueprint/rule citation discipline

* fix(scripts): sandbox npx-leak guard - private npm global prefix keeps npx on the branch CLI

* chore(skills): resync manifest hash after formatter pass reflowed general-video tables

* fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope

Two holes found by a live companion-run freeze: the agent-supplied --workflow
contradicted the run's actual workflow (recipe.json said faceless-explainer,
brief-skeleton said general-video), and the style_preset lookup missed because
the preference had been recorded under the bare key.

- freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a
  fallback for briefless projects and a contradicting flag is ignored (noted).
- recordPreference refuses a bare style_preset — the scoped key is the only
  writable shape; freeze tolerates legacy bare records via read fallback.
- review-loop § 4 / media-use SKILL / brief-format wording follow the machinery.
2026-07-15 21:19:14 +08:00
Miguel Ángel 6933e8acda fix(cli): consolidate snapshot and frame diagnostics (#2402)
* fix(snapshot): preserve exact requested times

* fix(cli): fail video snapshots without FFmpeg

* fix(cli): honor navigation timeout in diagnostics

* fix(cli): preserve snapshot alpha and create shot dirs
2026-07-14 01:46:44 -04:00
Vance Ingalls 8bbd5a6496 fix(cli): settle post-seek font subset loads before audit screenshots 2026-07-11 14:14:08 -07:00
Miguel Angel Simon Sierra 96cb5e39dd test(cli): type the guard test's mock so the full typecheck passes 2026-07-10 15:06:20 -04:00
Miguel Angel Simon Sierra c169dbaa54 test(cli): import the page-function guard in its new unit test 2026-07-10 14:36:01 -04:00
Miguel Angel Simon Sierra 2ceb0683a8 fix(cli): page closures survive keepNames transpilation; format motion-blur
Running the CLI from source (tsx dev script, as CI's smoke job does)
transpiles with keepNames, which rewrites named inner functions in
serialized page closures into __name(...) calls — a helper that exists
in the Node bundle but not in the browser realm. The unified seek
closure was the first validate-path page function with named inner
functions, so 'hyperframes validate' threw '__name is not defined' in
CI while the dist build worked. Every session opener now installs a
no-op __name shim via evaluateOnNewDocument before any page script
runs, immunizing all serialized closures regardless of build mode.
2026-07-10 14:30:54 -04:00
Miguel Angel Simon Sierra cea3458016 refactor(cli): single-source-of-truth pass over the check branch
Every duplicated decision gets one owner: rectToBbox lives in checkTypes
(was verbatim in pipeline and browser layers); the audit seek tuning is
one exported AUDIT_SEEK_OPTIONS consumed by check and the deprecated
inspect path; zoom padding/scale defaults export from the capture module
instead of re-literalized in three files; the optional run_id property
is built by one helper across all three telemetry events; check's
--max-transition-samples parsing reuses its own positiveInteger helper;
validate drops a leftover re-export and redundant explicit-default args
go away.

Tests: the contrast candidate round-trip gains a real integration
anchor (the actual browser script eval'd in-page, a wrapper asserting
finish receives the page-script bbox shape) replacing regex-over-source
as the primary guard; the redundant geometry source-golden and a
duplicated deprecation-envelope assertion are dropped.
2026-07-10 13:52:31 -04:00