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>
Two routing surfaces pointed agents at media "policies" and "contracts" without
naming a file. An agent that goes looking and finds nothing fills the gap with
an invented rule.
- skills/hyperframes/SKILL.md: "Load its media-treatment policy" now names
references/media-treatments.md and states the policy governs how footage is
treated, never whether media may be used.
- skills/general-video/SKILL.md: "adoption, resolution, provider, provenance,
and reuse contracts" now names references/resolve.md and
references/setup-providers.md. Drops "provenance", which is ledger
bookkeeping in operations.md, not a contract to follow.
- The scaffolded project templates carried the same unnamed pointer and ship to
every hyperframes init; both updated and kept byte-identical.
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.
trackRenderComplete requires `gpu: boolean`; the two new opt-out test
calls omitted it, failing Typecheck in CI. The fix already existed on the
stacked branch, so only this base branch was broken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review findings on the floor/telemetry PR:
1. powerStateFields() is spread into the properties object at the CALL SITE,
so it ran before trackEvent's own `if (!shouldTrack()) return` guard —
telemetry-disabled installs paid two blocking `pmset` subprocess spawns
per render for an event that was then discarded. Now short-circuits on
shouldTrack() (memoized, so no cost on the tracked path). Regression test
asserts pmset is not sampled when telemetry is off; fault-injection
verified it fails without the guard.
2. The DE parallel router pinned workerCount to 3 and skipped calibration
even when verified parallel DE STREAMING — the entire reason for the pin
— could not run for that render. The common case is a composition over
streamingEncodeMaxDurationSeconds (240 s default): the duration cap
disables streaming before the router's force flag is consulted, so the
render got a hard-coded 3 workers chosen by a benchmark for a path it was
not on, instead of the calibrated count. shouldPreferParallelDrawElement
now takes parallelStreamingAvailable and withholds the bet without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
HF_DE_PARALLEL_MIN_FRAMES default 2000 -> 700, re-calibrated by a controlled
crossover sweep (fixed content-per-frame, three synthetic profiles x
{350..3000f} x {single,par2,par3} x 3 reps, resolved worker counts and capture
modes verified per run): par3 beats single at EVERY size in every profile —
+17-21% at 700f rising to +28-34% at 3000f. That includes a
24-sub-composition profile built specifically to reproduce the 'workers
re-pay init' failure the original 2000 floor guarded against (92k tweens,
~2.5s pollSubCompositionTimelines per worker): workers initialize
concurrently, so duplicated init costs CPU, not wall-clock, and the comp
still parallelizes +19% at 700f. Below ~700f the win thins toward +10%
while paying three hardware-GPU browsers, so a floor remains. par2 loses to
par3 in every cell of every profile — the router's existing 3-worker pin is
confirmed, not changed. Harness:
plans/drawelement-fast-capture/de-crossover-bench.sh (docs repo).
Also adds on_battery / low_power_mode to render_complete and render_error.
The DE fleet is macOS laptops, and bench sweeps on an M4 Pro caught the SAME
render flipping between ~9.6 and ~17.2 ms/frame power-management regimes
with no existing telemetry signal to segment by — the router soak reading
this change needs that dimension to interpret perf on the machines users
actually render on. Sampled per event (volatile), pmset-based, darwin-only,
null-safe on failure.
Router stays default-off behind HF_DE_PARALLEL_ROUTER; this tunes what it
will do when the soak clears it to flip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What
Extracts the reliability-critical telemetry **delivery layer** — the in-memory event queue, async `flush()`, and the exit-time detached-child `flushSync()` — out of `packages/cli/src/telemetry/client.ts` into a new `transport.ts`.
`client.ts` stays the CLI-facing **policy** layer:
- `shouldTrack()` opt-out checks (dev mode, `DO_NOT_TRACK`, `HYPERFRAMES_NO_TELEMETRY`, config)
- `trackEvent()` system-metadata enrichment
- `showTelemetryNotice()` first-run disclosure
…and re-exports `flush` / `flushSync`, so `events.ts`, `index.ts`, and the `cli.ts` exit handlers keep importing from `./client.js` **unchanged**.
## Why
This is the code path that had the process-exit data-loss bug fixed in #2105 — render telemetry was ~6× undercounted and geographically US-skewed because the old drain-first flush emptied the queue before delivery confirmed, and the render command's `process.exit()` teardown killed the in-flight request. Isolating the delivery mechanism into its own focused, dependency-light module (only `./config` + node builtins) keeps that subtle, reliability-critical path in one place and reduces `client.ts` to just policy.
Follow-up to the render-telemetry-gap investigation. A delivery-health canary was also added to the [CLI Observability dashboard](https://us.posthog.com/project/356858/dashboard/1634055) — `render_complete ÷ successful render commands`, which should sit ~1.0 and would surface any regression of this class immediately.
## How
Pure code motion — **no behavior change, public API identical**. `transport.ts` owns the queue and stamps each event's dedup `uuid` + ISO timestamp in a new `enqueue()`; `trackEvent()` enriches with system metadata then calls `enqueue()`. `buildPayload`/`flush`/`flushSync` bodies are moved verbatim.
## Test plan
- [x] `vitest run src/telemetry/client.test.ts src/telemetry/events.test.ts` → **38/38 pass** (client.test.ts still validates queue-retention, uuid idempotency, and the detached-child flushSync path through the public API — unchanged)
- [x] `oxlint` clean, `oxfmt --check` clean
- [x] `tsc --noEmit` — no new type errors in `telemetry/`
- [x] `bun run build` succeeds
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* 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)
The media_in_subcomposition rule blanket-errored every <video>/<audio>
inside a sub-composition, claiming nested media is "never seeked/decoded
and renders blank/black". This is false: the runtime discovers media with
a flat document.querySelectorAll("video, audio"), resolves each element's
host composition via closest("[data-composition-id]"), and rebases its
local data-start by the accumulated absolute start of every ancestor
composition (packages/core/src/runtime/{media,startResolver}.ts). Media
seeks and decodes at any nesting depth, verified end to end through the
producer render path.
- Remove the rule and flip its test to assert nested media is NOT flagged.
- Drop the now-dead media_in_subcomposition clause from the registry
components test.
- Drop the equivalent pre-render guard from the faceless-explainer and
pr-to-video assemble scripts.
- Correct the reference docs (hyperframes-core SKILL, data-attributes,
variables-and-media, composition-patterns; hyperframes-cli
lint-validate-inspect): media works at any depth. Preserve the one real
constraint, that a sub-comp timeline cannot reach host-root elements, so
host-root media motion is authored on the main timeline.
## 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.