R4 review finding, and the comment I wrote in R3 was simply wrong: the
probe session is NOT running for every render. probeStage's needsBrowser
gate launches one only for unknown duration, unresolved compositions, or
specific media cases — and hasRuntimeInsertedMedia matches only
createElement("video"|"audio"), never createElement("span"). So the exact
shape that motivated the live-DOM fix (a known-duration, media-free
caption comp building thousands of nodes in script) gets NO probe, falls
back to the static source scan, reads as ~2 elements, and could enter the
applied cohort at 40k live nodes. The R3 fix measured the right thing but
only for the population that already had a probe.
Now the count carries provenance and the band fails closed:
- resolveCompositionElementCount returns { count, source: "live" |
"static" }. Only "live" — an actual DOM measurement — may open the band.
- resolveDeShortBand gains a third decisive outcome, "unmeasured", for
the static case. It deliberately does NOT report skipped_elements: a
static undercount is not a real oversize observation, and putting it in
the control arm would contaminate the DiD just as putting it in the
treatment arm would. Neither cohort; never routes.
- composition_element_count_source ships alongside the count, so the
fleet rate of "static" sizes the population a future
conditional-probe-launch would unlock — which is the data PR B needs to
decide whether that launch cost is worth paying.
Regression coverage walks the real chain rather than a full render, using
the production functions in pipeline order: probeRequiresBrowser (newly
extracted from the inline needsBrowser expression, so the gate is
testable at all) returns false for the caption-comp shape → the resolver
reports static and a count under the ceiling → the band reports
unmeasured, not applied. Fault injection confirms it bites: removing the
one guard line fails exactly these three tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The routing surface the short-comp benchmarks validated is (motion x DOM
size x frames). After the baseline release, fleet telemetry carries DOM
size (composition_element_count) and frames on every render — but the
motion proxy, observability_init_tween_count, has 0% coverage on the
exact renders the band routes: parallel workers' console buffers (and so
the [FrameCapture:INIT] line the summary parses) only propagate to the
orchestrator on FAILURE. Single-worker screenshot renders report it;
the multi-worker clamp bucket never does. Verified against 7d of fleet
data: 35k screenshot renders carry tween counts, 0 of 9,600 band renders.
Fix rides the one channel parallel workers already return on success —
the per-worker CapturePerfSummary. Sessions record initTelemetry on
every init path; the perf summary now carries it; the orchestrator
max-merges across workers (same multi-session semantics the console
parser uses) and feeds it to the observability summary as a structured
fallback, console lines still refining when present.
With this, every band render carries full coordinates — (elements,
tweens, frames, path, speed) — which buys two reads: regressing wild DE
speed against element count on the existing 900+ inversions validates
the bench's 0.50ms/element slope BEFORE the routing flip, and any
post-flip misroute can be reproduced locally by feeding its telemetry
row straight into gen-crossover-comp's knobs (--movers ~ tween count,
--static ~ element count) and re-benching.
(Also drops a now-stale fallow suppression in render.ts — the test-only
reset export it guarded gained real test importers, so the issue it
suppressed no longer exists and the gate flags the leftover.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31% of fleet renders (24h, v0.7.78+) are DE-eligible comps clamped to
parallel screenshot purely because they sit under the 900-frame inversion
floor — the median fleet render is ~250-600 frames, below every DE entry
threshold. This opens a 250-899 frame band, gated on composition size.
Measured, not assumed. A controlled sweep (fixed synthetic content,
{250,400,600,900}f, single-DE vs parallel-screenshot-W4, 3 reps, capture
mode verified per row, AC power, load-gated) showed single-DE winning
1.16-1.24x at every size — but only for content in constant motion. A
follow-up 2x2 found motion and DOM size pull in OPPOSITE directions, so
neither alone predicts the winner (ratio = ss4/de1, >1 means DE wins):
24 movers / 0 nodes -> 1.05
320 movers / 0 nodes -> 1.24
320 movers / 7000 nodes -> 1.09
24 movers / 7000 nodes -> 0.96
24 movers / 20000 nodes -> 0.71
24 movers / 40000 nodes -> 0.55
DE's wall-clock scales ~0.50ms/element against parallel screenshot's
~0.22ms — drawElement repaints the whole tree per frame while fan-out
amortizes it — so the downside is NOT bounded and a bare floor drop would
have handed a 1.8x regression to large comps. Since motion only ever helps
DE, an element ceiling calibrated at the lowest-motion case is safe at
every motion level; crossover there is ~3.9k, and the default sits at 2500.
The predicate is untouched; the call site picks the floor. Above the
ceiling, or at 900+ frames, behaviour is bit-identical to today — the
change can only add inversions in the new band, never remove one.
Instrumentation, since this ships at full exposure rather than cohorted:
`composition_element_count` on EVERY render (the fleet distribution of the
gate variable is unknown — without it we cannot tell whether 2500 opens the
band for most short comps or almost none, nor re-derive the threshold from
real content), and `de_short_band` = applied | skipped_elements, unset when
the frame count made the band irrelevant, so a fleet perf shift is
attributable to this change rather than to content mix.
Safety is unchanged and already proven on this path: per-frame PSNR
self-verify with screenshot fallback, exactly as the 900+ band has shipped
default-on. Knobs: HF_DE_SHORT_MIN_FRAMES, HF_DE_SHORT_MAX_ELEMENTS (0
disables the band).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DE parallel-router breaker's tripped state lived in the same config
file as the install id, so the most common identity reset — deleting
~/.hyperframes — also re-enrolled the machine into an experimental path
that had already failed on it.
Mirror exactly two facts into a machine-local state file
(~/.local/state/hyperframes/install-state.json) that a config wipe does
not touch:
- markerAt: written unconditionally on every install, so the fraction of
fresh mints that find it directly measures recoverable id churn
(config wiped, machine persisted) vs unrecoverable (fresh
machine/container/new user). Emitted as install_predecessor_found on
telemetry events; absent (not false) on configs predating the field.
- deParallelRouterTrialFired: a breaker tripped by a previous install
stays tripped for the new one. Config corruption takes the same mint
path, so it survives that too.
The file deliberately holds NO identity — no anonymousId, no counters.
A wiped config still gets a fresh id unconditionally; only the safety
fact about the machine survives. Sync happens inside writeConfig so no
breaker write site can forget it; failures are swallowed (telemetry
must never break the CLI) but leave the memo unset so a later write
retries. `hyperframes telemetry` lists the state path for transparency.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(cli): persist authoring skill in hyperframes.json for durable render attribution
authoring_skill was stamped only on the first render through a workflow
passing --skill, so re-renders, `npm run render`, --batch, existing-project
renders, and general-video lost it — leaving 77-96% of real-human render
volume un-attributed and the skills-penetration metric misleadingly low.
Persist the owning skill in hyperframes.json: `init --skill` stamps it at
creation, `render` resolves the flag then falls back to the stored value, and
an explicit --skill seeds it (seed-once, never overwriting the creating
workflow's identity). Activate all render-producing creation workflows to
declare their skill at init.
Forward-only: does not rewrite historical telemetry.
* fix(cli): patch hyperframes.json in place when seeding the authoring skill
seedProjectAuthoringSkill is the only writer that touches an already existing
hyperframes.json — every other writeProjectConfig call site is guarded to write
only when the file is absent, which made the whole-file overwrite safe by
construction. Round-tripping the seed through normalizeConfig broke that: it
rebuilds the object from a field whitelist with no rest-spread, so any key
outside the schema was silently dropped, a media block was materialized in
projects that never had one, and key order was rewritten. hyperframes.json is
normally committed, so a render introduced a diff the user never asked for, and
any field added to the schema later would be deleted by a render on an older
CLI.
Parse the raw JSON, set authoringSkill, write it back, reusing the file's own
indentation. Unknown keys and formatting survive; the only delta is the key
being added. A corrupt config is now left untouched instead of clobbered.
Seed-once semantics are unchanged, still normalized so a hand-edited garbage
slug neither reaches telemetry nor wedges the seed.
Reported independently by both reviewers on #2762.
* fix(cli): create the docker build context with mkdtempSync
The `--docker` build context was created at a guessable path derived from
`Date.now()` in the world-writable OS temp dir. Another local user can
pre-create or symlink that path and have the build read a Dockerfile they
control. mkdtempSync gets a random suffix and 0o700 from the kernel, and it
creates the directory itself, so the separate mkdirSync goes away.
Pre-existing on main (alert #432, 2026-06-04, packages/cli/src/commands/render.ts),
surfaced against this branch only because the seed commit shifted line numbers in
the same file. Fixed here to unblock the CodeQL gate on #2762 rather than left for
a follow-up; the remaining 10 js/insecure-temporary-file alerts elsewhere in the
repo are untouched and still want their own pass.
* fix(cli): drop the check-then-use race when seeding the authoring skill
The seed tested for the config with existsSync and then wrote, which is a
check-then-use race: the file can be created or swapped between the check and
the write (CodeQL js/file-system-race).
Read once and branch on the failure reason instead. Only ENOENT creates a
config from scratch; any other read failure (permissions, I/O) now leaves an
existing file alone rather than overwriting it with a default, so this is also
strictly safer than the version it replaces.
Also replaces the `as Record<string, unknown>` assertion with an isJsonObject
type guard, per the repo's no-assertion convention.
Behaviour unchanged: all 4 seed regression tests still pass, and the
create/preserve/seed-once/corrupt-untouched paths were re-verified end to end.
Three review findings on the win32 drawElement PR:
1. gpu_renderer shipped the raw UNMASKED_RENDERER_WEBGL string — unbounded,
driver-authored, GPU-model-specific, and |-joined across parallel
sessions, i.e. high cardinality by construction, against this file's own
convention of sanitizing engine-sourced strings (deGateReason is a
bucket; error messages go through redactTelemetryString). Now bucketed at
the source by classifyGpuRenderer to <backend>/<vendor>
(metal/apple, d3d11/nvidia, swiftshader/other, ...), which is the whole
analytic signal the win32 rollout needs and nothing else. The raw string
never leaves the engine.
2. gpu_renderer reached render_complete only, so a crashed render — the
cohort the field exists to attribute — carried no backend. It now rides
RenderCaptureObservability (deGpuRenderer, sourced from the live probe
session like the de_* counters), so both render_complete and
render_error carry it and a hard failure still reports its GPU backend.
On render_complete the perfSummary value still wins by spread order.
3. Restore the fallow-ignore-next-line suppression above
__resetDeParallelRouterTrialStateForTests: CLI test files are not fallow
entry points, so removing it fails the CI dead-code audit (local
pre-commit passed only because of its changed-file scope).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widen the default-on drawElement clamp from darwin-only to darwin|win32
(still requiring a non-software-GPU browser). The darwin restriction was a
validation envelope, not an architectural limit — the CanvasDrawElement
Chrome flag ships on every platform, and every safety layer that made the
macOS default-on release (v0.7.38) survivable is platform-neutral:
compile-time gates, the SwiftShader init gate, per-render worker-encode
self-verification with screenshot fallback, and the blank guard. Worst case
on an unvalidated D3D11 backend is the same as on Metal: verify catches a
bad frame and the render re-runs on the screenshot baseline.
Why now: 30-day telemetry shows ~206k non-CI hardware-GPU Windows renders
(~78% of the win32 fleet, 18k installs) held on the slow screenshot path by
the clamp — the second-largest perf population after macOS, carrying ~1,550
capture-hours/month in the DE-eligible >=700-frame band alone at a measured
~2x speedup opportunity.
Instrumentation for the new cohort: drawElement session init now records the
raw WebGL UNMASKED_RENDERER_WEBGL string (detectSwiftShader generalized to
detectGpuBackend — same single evaluate, the string was previously read and
discarded) and threads it session -> CapturePerfSummary -> RenderPerfSummary
-> render_complete as `gpu_renderer`. drawElement damage proved
compositor-backend-specific throughout the macOS rollout, so D3D11-cohort
failures must cluster by ANGLE backend + GPU vendor (NVIDIA/AMD/Intel), not
just `os`.
The two DE clamp branches are extracted into a pure, unit-tested
`resolveDefaultDrawElement` (platform + GPU mode + worker-encode + explicit
opt-in), which also drops resolveConfig's cyclomatic complexity. The win32
streaming-encode compound tests collapse onto one shared helper.
Linux stays excluded: that fleet is headless/Docker SwiftShader, where DE
has no speedup and known rendering defects. Kill switches unchanged:
PRODUCER_EXPERIMENTAL_FAST_CAPTURE=false, --experimental-fast-capture=false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(check): opt-in --layout proseCoverageFloor for text_occluded
Keep the default prose coverage floor at 0.15 for all callers, and allow
stricter agents (e.g. Zephyr) to lower it via --layout "proseCoverageFloor=0.05"
without changing other layout gates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(check): collapse --layout comments and docs to one line
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): strict proseCoverageFloor parse + pin 0.07 floor tests
Reject trailing-garbage fractions that Number.parseFloat would accept, and
pin the existing ~0.07 coverage fixture for default vs floor=0.05 (atomic
labels unchanged) plus a collectLayout forwarding assertion.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): share parseNumberStrict across layout and frame-check
Sweep the sibling --frame-check tol parser (and caption fractions) onto the
same strict Number() helper so trailing garbage cannot prefix-parse.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(send-to-guide): enhance turns are free; render is the paid step
The shipped pricing model is import + enhance turns free, only the final
render charged (a monthly free-render credit, then per-minute). The guide
labeled enhance as 'the paid step', which misstates the model to the
authoring agent. Move the paid label to Render.
* docs(send-to-guide): state the tiered render billing contract + pin it in the guide test
Address review: the pricing line must teach Claude the real tiered contract,
not a single universal free render. Per heygen-server usage_limits.py: FREE
accounts get 3 renders/month (then blocked, not billed); paid plans are charged
20 credits per rendered minute at completion. Enhance turns are free.
Also pin the invariant in sendToGuideContract.test.ts: assert Enhance=free /
Render=paid + the tiered figures, and a negative assertion blocking the retired
'Enhance ... paid step' wording from returning.
CLI and Studio feedback were emitted as `survey sent` with `$survey_*`
properties, so every rating was ingested as a PostHog survey response even
though no survey definition, targeting, or popover backs them.
Emit `cli_render_feedback` and `studio_feedback` with plain `rating` /
`comment` properties instead. Same fields, same call sites, same opt-out.
* 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>
## What it catches
A gauge needle / clock hand / dial pointer / radar sweep that rotates about the **wrong pivot** — the recovered center-of-rotation sits far from the dial hub (e.g. `transform-origin` at the needle base or SVG element edge instead of the dial center). Visually the needle "wobbles" or orbits off-axis instead of sweeping cleanly about the hub.
This is a genuine gap in the current checks: `rotation_pivot_drift` (#2741) provably **cannot** catch it — a correct sweeping needle's bbox-center orbits identically to a broken one, so only a **dial-hub reference** distinguishes them. This is the separate hub-referenced check that analysis called for.
## How it works
- Sampler maps 2 material endpoints per frame via `getScreenCTM` (honors the actual rendered transform, independent of `svgOrigin`).
- Resolves the dial hub = shared center of the modal set of static concentric circles, or the arc-center of the largest static near-circular path (Kasa circle fit).
- Fits a circle to the endpoint trajectory to recover the true center-of-rotation; flags drift `> 0.35 * pointer_length`. One warning per hub.
- Never fires without a resolvable hub. Walks the rotation reference to the composition root (not the `<svg>`) so a pointer rotated by a `div` ancestor is measured correctly.
- Multi-body guard: `>= 2` bodies at distinct angular positions on one hub = orbit/atom system, not a dial → suppressed.
## Corpus evidence (autonomous geometry-fuzz run, 81 fuzzed diagrams)
- **7 / 7 true positives, 0 false positives across all 81 samples.**
- Assigned TPs: fuzz005, fuzz017, fuzz032. Bonus TPs: fuzz044, fuzz056, fuzz068, fuzz080.
- **The Gemini-3.6 video-judge itself MISSED all 4 bonus TPs** (`vlm_has_defects: false`) — the deterministic hub-reference check beats the VLM on this defect class.
- FPs driven to 0 by the two principled guards above: fuzz016 (planet arc rotated by a `div` ancestor) cleared by root-walk; fuzz055 (atom) cleared by the multi-body guard.
- fuzz080 reads as a false positive to the connector check but is a true positive here — confirms the architectural boundary between the two checks is drawn correctly.
## Validation
- Autonomous Gemini-3.6 **video**-judge fuzz run to surface candidate defects, then a **deterministic FP sweep** across all 81 rendered compositions (not VLM-gated — code inspection is the arbiter, since the VLM both over- and under-calls this class).
- 9 unit tests (`checkPipeline.offPivotRotation.test.ts`) + full check suite pass; `bun run build` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What
New cross-sample layout check `rotation_pivot_drift` — flags a rotating element that should spin **in place** but pivots about the **wrong point** (e.g. a wheel whose spokes use a hardcoded px `transformOrigin` instead of `svgOrigin`/`%`, so they swing off-center while every existing check still passes).
Motivating prod case: a portrait ad's spoked-wheel whose `#spokes` rotated about `transformOrigin:"250px 250px"` in a resized 460px container — spokes detached from the hub, shipped clean because no rule inspects rotation.
## How
- `layout-audit.browser.js`: `window.__hyperframesRotationSample()` reports each visible transformed element's bbox center + decoded rotation angle per layout sample. Skips `[data-layout-allow-orbit]`.
- `checkPipeline.ts`: accumulates samples across the seek grid; `detectRotationPivotDrift()` (modeled on `detectSweepStatic`) flags an element that (a) actually spins (angle spread > 20° over ≥3 samples), (b) is size-stable (bbox width ratio ≤ 1.6), and (c) whose bbox **center** drifts > `max(10% of its size, 2% of min viewport dim)`. Emits `warning`; not persistence-tiered (not demoted to info).
## FP guards
Real rotation required, ≥3 samples, size stability, `data-layout-allow-orbit` exemption, min area ~2500px². Center-drift (not bbox size) is the discriminator, so a correctly-centered spinner reads drift ≈ 0.
## Validation (`check --json`)
| Fixture | Expected | Result |
|---|---|---|
| off-transformOrigin spoked wheel | fire | **fired — 109px drift on `#spokes`** |
| non-spinning comps (node diagram, device tree) | clean | clean, no FP |
| correctly-centered spinner (`svgOrigin`) | clean | clean (spins 162°, drift 0) |
| `data-layout-allow-orbit` off-origin spinner | clean | clean (exempt) |
| off-`svgOrigin` control, no opt-out | fire | fired — 251px drift |
No false positives. `tsc --noEmit` clean, `oxlint` clean, `check.test.ts` + `layout-audit.browser.test.ts` = 112/112 pass.
## Note
`ROTATION_MAX_SIZE_RATIO` is 1.6 (not 1.3): a rotating anisotropic shape's axis-aligned bbox inherently oscillates (8-spoke star ~1.32×, square 1.41×), so a tighter ratio rejects legitimate targets. Center-drift stays the real discriminator; thin swinging bars are excluded.
Follow-up: a `detectRotationPivotDrift` unit test via the fake driver's `collectRotationSample` (mirroring the sweep_static tests).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* docs(guides): resolve the fidelity contradiction (preserve substance, adapt form)
The guide called the rebuild 'lossy by nature' while the checklist demanded 'content
match the brief exactly', with no rule for what to preserve vs adapt. On a live import
CD kept fonts/palette/hero but genericized the source's real figures (2.4M signals/sec,
+240% spike) into vague phrases. Resolve around one principle: substance (real copy,
exact palette/fonts, distinctive figures/data, product names, signature visuals) is
preserved verbatim; only the form (static page to timed multi-scene motion) adapts.
* test(guides): pin the Send-to fidelity contract against silent regression
Address review on #2620: the resolved 'preserve substance, adapt form' instruction is
load-bearing LLM-facing prompt text, but validate-docs only proves syntax. Add a semantic
pin (mirroring packages/cli figma skillContent.test.ts) asserting the three positive
concepts are present and the two retired contradictory phrases ('lossy by nature',
'content match the brief exactly') cannot silently return.
* style(guides): oxfmt the Send-to fidelity-contract test
Wrap the readFileSync call to oxfmt form (printWidth 100); unblocks required Format + its
preflight/preview-regression cascade on #2620.
* feat(media): alpha-capable authoring proxies
Alpha sources were refused a proxy before the codec map ever asked whether the
browser could decode them, so a ProRes 4444 alpha file (which no browser
previews at all) rendered black forever, while an alpha WebM (which previews
fine) was already covered by the browser-safe check on the next line. The alpha
veto earned nothing and cost the one case that needed help.
Alpha is now a target-codec choice rather than a veto: alpha sources transcode
to VP9 + yuva420p in WebM, everything else keeps the existing H.264/MP4 path
byte for byte. Only files no browser can preview are proxied, which is the rule
the runtime already followed everywhere else.
WebM cannot carry AAC, so the VP9 path uses Opus and drops the MP4-only
faststart flag. PROXY_PARAMS_VERSION moves to v3 so clients stop serving the
previously cached proxies.
Safari does not decode VP9 alpha and still shows black for alpha sources, as it
does today: this is better on Chromium and Firefox and no worse anywhere.
* fix(media): infer proxy variant for rescue
* fix(media): preserve alpha proxy hardening after restack
* feat(studio-server): serve H.264 proxies from the preview route
Wires the codec manifest and the transcoder into the preview surface: the route
negotiates a proxy via a query param and serves it through the existing range
and ETag machinery, composition HTML carries a codec map for the runtime, and
hostile assets pre-warm so a first play does not wait on a cold transcode.
Exposes the three subpath exports the CLI surfaces consume upstack.
Drops the TEMP fallow entry added with the transcoder: it has real importers now.
* fix(studio-server): publish media proxy exports
* fix(parsers): scan HTML comments linearly
* feat(cli): let projects opt out of automatic proxying
Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and
forwards the resolved value into the studio and preview servers and the vite
adapter. Lands before the runtime slice that turns auto-proxying on, so the
switch exists before there is any behavior to switch off.
* fix(cli): align media config schema
* feat(core): swap undecodable video to its proxy at runtime
Adds the browser-side half: before first load the runtime consults the injected
codec map and swaps a hostile source to its proxy, and if a video still reports
zero decodable width it rescues it reactively. An HEVC file carrying AAC fires
no error event, so zero videoWidth, not the error event, is the reliable signal.
Audio elements and alpha sources are never proxied, render mode never proxies,
and each swap evicts the element's stale sync state and reports once.
This completes the loop: auto-proxying is live for preview and studio from here.
The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice.
* feat(cli): serve proxies from play, present, and the static project server
Adds proxy negotiation to the CLI-side servers and gives play byte-range
serving it never had, so a swapped video can seek. The static project server
behind check, snapshot, compare and friends injects the codec map once, so all
of its callers inherit the behavior; snapshot forwards its own proxy flag.
* fix(cli): serve proxies for camera formats
* feat(cli): resolve proxies before check's timed browser phase
check pre-resolves hostile assets so a cold transcode cannot exhaust the
render-ready budget, and surfaces the runtime's proxy diagnostics as findings
so a swap is visible rather than silent.
* feat(cli): bake proxies into published archives
Published pages are static, so there is no server to negotiate with: publish
transcodes proxies for hostile assets into the archive and rewrites the video
sources that point at them. Audio elements keep their originals, since audio
decodes independently of the video codec.
Splits the archive build from the zip step so publish can transform between
them. cloud render keeps calling the unchanged composition and still uploads
originals, which its regression test pins.
* fix(cli): harden proxy pre-resolution
* fix(cli): surface publish proxy outcomes
* test(cli): remove ffmpeg from archive guard
* test(cli): normalize publish fixture path
* feat(studio-server): serve H.264 proxies from the preview route
Wires the codec manifest and the transcoder into the preview surface: the route
negotiates a proxy via a query param and serves it through the existing range
and ETag machinery, composition HTML carries a codec map for the runtime, and
hostile assets pre-warm so a first play does not wait on a cold transcode.
Exposes the three subpath exports the CLI surfaces consume upstack.
Drops the TEMP fallow entry added with the transcoder: it has real importers now.
* fix(studio-server): publish media proxy exports
* fix(parsers): scan HTML comments linearly
* feat(cli): let projects opt out of automatic proxying
Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and
forwards the resolved value into the studio and preview servers and the vite
adapter. Lands before the runtime slice that turns auto-proxying on, so the
switch exists before there is any behavior to switch off.
* fix(cli): align media config schema
* feat(core): swap undecodable video to its proxy at runtime
Adds the browser-side half: before first load the runtime consults the injected
codec map and swaps a hostile source to its proxy, and if a video still reports
zero decodable width it rescues it reactively. An HEVC file carrying AAC fires
no error event, so zero videoWidth, not the error event, is the reliable signal.
Audio elements and alpha sources are never proxied, render mode never proxies,
and each swap evicts the element's stale sync state and reports once.
This completes the loop: auto-proxying is live for preview and studio from here.
The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice.
* feat(cli): serve proxies from play, present, and the static project server
Adds proxy negotiation to the CLI-side servers and gives play byte-range
serving it never had, so a swapped video can seek. The static project server
behind check, snapshot, compare and friends injects the codec map once, so all
of its callers inherit the behavior; snapshot forwards its own proxy flag.
* fix(cli): serve proxies for camera formats
* feat(cli): resolve proxies before check's timed browser phase
check pre-resolves hostile assets so a cold transcode cannot exhaust the
render-ready budget, and surfaces the runtime's proxy diagnostics as findings
so a swap is visible rather than silent.
* fix(cli): harden proxy pre-resolution
The added `for...of` loop over `lintFeedbackComment` warnings pushed the
`run` function's cyclomatic complexity from 4 to 5, landing the CRAP
score at exactly the 30.0 Fallow threshold. Extract the loop into
`printFeedbackLintWarnings` so `run` stays a flat driver — the helper
carries the incidental complexity.
No behavior change; all 29 unit tests + typecheck + oxlint + oxfmt +
local `fallow audit --base origin/main` pass clean.
* feat(studio-server): serve H.264 proxies from the preview route
Wires the codec manifest and the transcoder into the preview surface: the route
negotiates a proxy via a query param and serves it through the existing range
and ETag machinery, composition HTML carries a codec map for the runtime, and
hostile assets pre-warm so a first play does not wait on a cold transcode.
Exposes the three subpath exports the CLI surfaces consume upstack.
Drops the TEMP fallow entry added with the transcoder: it has real importers now.
* fix(studio-server): publish media proxy exports
* fix(parsers): scan HTML comments linearly
* feat(cli): let projects opt out of automatic proxying
Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and
forwards the resolved value into the studio and preview servers and the vite
adapter. Lands before the runtime slice that turns auto-proxying on, so the
switch exists before there is any behavior to switch off.
* fix(cli): align media config schema
* feat(core): swap undecodable video to its proxy at runtime
Adds the browser-side half: before first load the runtime consults the injected
codec map and swaps a hostile source to its proxy, and if a video still reports
zero decodable width it rescues it reactively. An HEVC file carrying AAC fires
no error event, so zero videoWidth, not the error event, is the reliable signal.
Audio elements and alpha sources are never proxied, render mode never proxies,
and each swap evicts the element's stale sync state and reports once.
This completes the loop: auto-proxying is live for preview and studio from here.
The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice.
* feat(cli): serve proxies from play, present, and the static project server
Adds proxy negotiation to the CLI-side servers and gives play byte-range
serving it never had, so a swapped video can seek. The static project server
behind check, snapshot, compare and friends injects the codec map once, so all
of its callers inherit the behavior; snapshot forwards its own proxy flag.
* fix(cli): serve proxies for camera formats