Both reviewers flagged the same gap: seed injection was gated on
telemetryShouldTrack(), but canary EVALUATION was not. An install with
DO_NOT_TRACK=1 was still bucketed and still had real code paths flipped
(e.g. HF_DE_PARALLEL_ROUTER), silently and unmeasurably.
A canary is a measured rollout — we enrol a slice precisely so it can be
compared against everyone else. An install that sends nothing can't be
compared, so enrolling it buys no signal and only changes that user's
code path, on an experimental feature, without their knowledge. That is
the wrong side of an opt-out.
Resolves to a new `telemetry_opt_out` reason BEFORE bucketing, so no
cohort is assigned at all. Distinct from `excluded` because "why is my
canary off" has a very different answer for CI than for opted-out, and
the reason never reaches telemetry by construction.
Covers every opt-out route: persisted preference, the runtime env vars
and dev/telemetry-disabled builds via policy.ts, and Studio's
hyperframes-studio:telemetryDisabled.
An explicit HF_CANARY_* / ?hf_canary_*= override still wins — a
deliberate local choice, not silent enrolment, and the documented way to
exercise a canary with telemetry off.
The CLI check mirrors shouldTrack() rather than importing it: client.ts
already imports canary.ts for canaryEventProperties, so depending on it
would be a cycle. Both read the same two inputs, so they cannot disagree.
Tests: 9 new across CLI and Studio (preference off, each runtime
override, no bucket assigned, override still honoured, flag properties
all-false). Fault injection: removing the CLI gate fails 6, removing the
Studio gate fails 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopts the #2904 pattern for bucketSeed. The seed rides in
install-state.json, which now lives beside config.json in ~/.hyperframes
rather than in ~/.local/state/hyperframes/.
Review rejected persisting state outside the config dir to defeat a
user's reset, and that objection is sharpest for the seed: it is the one
field that would turn install-state into a persistent pseudonymous
identifier surviving `rm -rf ~/.hyperframes`.
The carryover still earns its place, just against the churn that
actually happens. config.json is rewritten on every command and every
render, and readConfig recovers from any parse/permission/IO failure by
minting a fresh identity — so a re-mint would reshuffle cohorts
mid-rollout. A no-schema file written once at mint is decoupled from
that without leaving the directory. Config re-mint: cohorts hold.
Directory deleted: cohorts go too, deliberately.
A pre-move seed is adopted by the same migration, so installs already
carrying one do not have a live cohort reshuffled under them.
Tests: seed survives a re-mint, does NOT survive deleting the config
dir, and migrates from the pre-move path. Prose in config.ts, canary.ts
and canary-rollouts.mdx corrected — it still claimed cohorts survive a
wipe. Docs gain a removal-path section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cohort membership now survives a config wipe. Canaries bucket on a
dedicated bucketSeed (fresh random UUID, distinct from anonymousId by
design) that is mirrored write-once into the install-state file and
inherited at mint: a wipe re-rolls the telemetry id but never the canary
assignment. This removes cumulative-exposure drift for the recoverable
churn bucket entirely — the residual drift comes only from fresh
machines, containers, and genuinely new users — and keeps before/after
comparisons valid across a reinstall.
The seed is never emitted in telemetry (only the resulting true/false
assignments are), so it does not link the old id to the new one
server-side. The residual linker is the flag vector itself (k bits for k
live canaries), documented as such. An explicit reset still works by
deleting the state file, and the no-identity test now also asserts the
seed differs from the anonymousId.
Cross-surface coherence: the CLI's studio server injects the seed as
window.__HF_CLI_BUCKET_SEED (same telemetry gate and script-escaping as
the distinct id, and on the /api/telemetry-identity fallback), and the
Studio binding buckets on it when present — without this the CLI would
bucket on the seed while Studio bucketed on the distinct id, splitting
one machine across cohorts (calibration check 4 would catch exactly
this). Standalone Studio still buckets on its localStorage id: the
browser has no second storage location, so that id doubles as the seed.
Legacy configs are backfilled once (lineage seed if the state file has
one, else minted) and persisted immediately — an unpersisted seed would
re-roll cohorts every process. Safe to ship in the same release as the
first canaries: no prior release emitted canary properties, so the
bucketing-unit change is unobservable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against packages/*/src env reads: 57 distinct HF_*/PRODUCER_*
toggles pre-existing this branch (the raw grep said 59, but two of those
are HF_CANARY_TEST_* fixtures introduced by this branch's own tests).
The number is cited externally now, so it should match what the repo
actually has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registers two canaries that gate nothing — `calibration-10` (10%) and
`calibration-50` (50%) — so the rollout mechanism can be proven against real
traffic before any real feature depends on it. Zero behavioural risk: they are
read by nothing.
They answer what the unit tests structurally cannot. The tests bucket
generated UUIDs and weight every install equally; real render volume is
heavily skewed toward a few heavy installs, and real install ids churn (~25x
more distinct ids over 30 days than in any single day on the desktop render
population).
Four checks, pre-registered in the docs so the read is not post-hoc:
1. ACCURACY — does 10% land at 10%, install-weighted AND event-weighted?
2. DRIFT — how fast does CUMULATIVE exposure climb above target as ids churn?
The instantaneous share is flat by construction; the set of installs
enrolled at some point is not.
3. STABILITY — does any install ever change cohort? Must be zero. Percentages
are held FIXED for the window precisely so a flip is unambiguously a bug;
during a real ramp a false->true flip would be correct instead.
4. CROSS-SURFACE — do the CLI and Studio bindings agree for the same install?
A CLI-launched Studio adopts the CLI id, and 16,961 installs currently
share an id across both surfaces, so this is measurable.
Plus an independence check: overlap between the two calibration canaries
should be ~p1*p2 (~5%), not ~min(p1,p2) (~10%, which would mean every canary
lands on the same unlucky cohort).
The docs also record what calibration CANNOT fix: per-install cohorts never
flip, but a person who wipes their config gets a new id and a fresh roll.
Preventing that needs stable identity across resets, and both candidates were
rejected — hardware fingerprinting correlates the cohort with hardware (fatal
for a rendering experiment, and it survives uninstall) and account identity
covers only ~3.6% of local rendering installs. The drift is therefore a
measured, accepted limit, and the point of calibrating is to size it and pick
canary window lengths accordingly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the single `canaries: "a,b"` telemetry property with PostHog's own
flag shape, one property per registered canary:
$feature/canary-de-parallel-router: "true" | "false"
PostHog treats `$feature/<key>` as a first-class flag property, so breakdowns,
funnels split by cohort and the experiment surfaces work on a canary with
nothing configured server-side. The decision still happens locally: the render
path forbids render-time network calls, behaviour must not depend on analytics
being reachable, and neither the CLI nor Studio ships posthog-js (both
hand-roll a batch POST, so there is no SDK to evaluate a real flag with).
Decide locally, analyse natively.
Two decisions worth recording:
- BOTH ARMS ARE EMITTED. A non-enrolled install reports "false" rather than
omitting the property. Absent means "this build predates the canary", which
is a different fact from "this install is control" — collapsing them makes a
ramp unreadable, because you cannot separate a control group from an old
version.
- KEYS ARE NAMESPACED with a `canary-` infix. A real PostHog flag namespace
already exists in this project, owned by the web app (`enable-chat-tab`, set
by posthog-js from `$lib=web` events). Namespacing guarantees a canary key
can never alias a real flag key and have the two fight over one property.
Values are the strings "true"/"false" to match how PostHog records boolean
flag values, so the property is directly comparable to a real flag.
98 core / 1437, 166 cli / 2194, 269 studio / 2982 green; tsc clean across all
three packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Studio (browser) binding so a canary can span the CLI and the editor,
and fixes a bundling mistake the studio test suite caught.
## The binding
Same public API as the CLI — `isCanaryEnabled("name")` — so a call site reads
identically whether it runs in Node or the browser. Three inputs differ:
- UNIT ID: `resolveStudioDistinctId()`, which already adopts
`window.__HF_CLI_DISTINCT_ID` when the CLI launched Studio. A CLI-launched
Studio therefore lands in the SAME cohort as the CLI: a rollout spanning
render and editor is coherent for that user instead of enrolling their
terminal but not their editor. A test pins that the id is passed through
unmodified — prefixing or re-hashing it would silently break that parity.
- OVERRIDE: no `process.env` in a page, so `?hf_canary_<name>=on` mirrored
into sessionStorage. Session scope is deliberate. A URL is the right carrier
(shareable — "support: open this link"), but persisting a URL-borne override
to localStorage would let one click silently pin a browser into a cohort
forever, long after anyone remembers why. Closing the tab is the reset;
`=reset` clears it explicitly.
- EXCLUSION: `navigator.webdriver` stands in for the CLI's `is_ci`. Automated
browsers mint a fresh localStorage id per run, so they would hop cohorts
between runs — noise in the signal, nothing learned about real users. An
override still reaches them, which is how you test a canary under Playwright.
Studio's `trackEvent` now attaches `canaries` to every event, mirroring the CLI.
## The bundling fix
Importing the `@hyperframes/core` barrel into studio browser code broke two
unrelated hook test files with an esbuild TextEncoder invariant violation. The
barrel re-exports the whole core surface (parsers, lint, studio-server), so it
drags a Node-oriented dependency graph into a browser bundle — the test
failure was the symptom, the bundle bloat was the bug.
`@hyperframes/core` now exposes `./canary` and `./canary-registry`, declared in
packages/core/package-subpaths.json (the generated source of truth for exports —
hand-editing package.json is reverted by the sync script) and marked
`environments: [browser, bun, node]`. Both the studio AND cli bindings import
the leaf modules; the CLI gets the same benefit for a different reason, since
this resolves on the startup path — the reason the producer is lazily loaded.
Verified: the two hook files pass again; 269 studio files / 2982 tests, 98
core / 1433, 166 cli / 2194 green, `bun run lint` clean including the subpath
check. Fault-injection confirms both design decisions are pinned — swapping
session for local storage fails the scope test, prefixing the unit id fails the
CLI/Studio cohort-parity test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the canary primitive.
Telemetry: every event now carries a `canaries` property listing the cohorts
the install is enrolled in, attached in `trackEvent` so it lands on ALL
events rather than renders only — a staged rollout is only as useful as the
ability to split any metric by cohort. Resolved after the shouldTrack guard,
so opted-out installs never pay for it, and omitted entirely (not null or "")
when the install is in no canary, since PostHog treats those as real values.
Test hardening, after validating the shipped code against 60k synthetic and
101 real fleet install ids:
- Pin FNV-1a against canonical vectors, AND assert the shipped canaryBucket
actually uses that hash. Without the second assertion the first is
tautological — it would only prove the test's own copy is correct while
canary.ts drifted to a different hash, silently reshuffling every live
cohort. Fault-injection confirms only this assertion catches a hash change;
the distribution tests stay green because a perturbed hash is still
well-distributed.
- Tighten the share test from a 0.6x-1.4x band to +/-1 percentage point.
Measured error was 0.16pp at n=60k, so the old band would have passed a
badly skewed hash.
- Add chi-square uniformity across all 100 buckets (chi2 89.0 vs 148.2
critical at p=0.001). A lumpy hash yields roughly the right total share
while overloading some buckets, so the share test alone cannot catch it.
- Assert N concurrent canaries enrol binomially rather than in lockstep:
8 canaries at 10% put ~43% of installs in none and zero in all eight,
matching binomial(8, 0.1). Correlated slices would put ~10% in all eight.
Also verified 88,443 of 88,448 fleet install ids are well-formed UUIDs; the
5 that are not fail closed, which is the intended direction.
Docs: docs/contributing/canary-rollouts.mdx, registered in docs.json (an
unregistered page is invisible in the nav).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a reusable staged-rollout primitive so a change can ship to a stable
slice of installs instead of all-or-nothing.
The gap it fills: the repo carries ~49 HF_*/PRODUCER_* booleans and every one
is binary — a feature is either off (and therefore unexercised on real
traffic) or on for everyone (and therefore a fleet-wide bet). The
parallel-drawElement router sat in that gap for weeks: default-off produced
almost no signal, and flipping it default-on would have exposed 100% of
eligible installs at once.
Shape:
- packages/core/src/canary.ts — pure evaluator. No fs, no network, no
`process`; the caller supplies the unit id and overrides, so it imports
cleanly into the CLI, producer, engine, studio-server, the browser-side
studio bundle and the embeddable player. FNV-1a rather than node:crypto for
the same reason.
- packages/core/src/canaryRegistry.ts — every rollout in one table (name,
percentage, owner, description, sunsetAfter), so "what is rolling out, to
whom, owned by whom" is answerable without grepping 49 env vars.
- packages/cli/src/telemetry/canary.ts — supplies the three things only the
CLI knows: anonymousId, the HF_CANARY_<FEATURE> override, and is_ci.
Day-to-day API is `isCanaryEnabled("name")`.
Three properties the tests pin, because getting them wrong is subtle:
- Slices are INDEPENDENT per feature: the bucket hashes `feature:unitId`, not
the id alone. Bucketing on the id would hand every concurrent experiment to
the same unlucky cohort and make two rollouts unreadable apart.
- Ramping is INCLUSIVE: `bucket < percentage`, so widening 10 -> 25 keeps the
original cohort and before/after comparisons survive the ramp.
- It fails CLOSED: no unit id, unknown name, or CI install means not enrolled.
A canary exists to bound blast radius, so "we don't know who this is" must
never mean "enrol everyone".
Registry entries also carry a sunset date, and a test fails once one is past
due — a canary that outlives its rollout is a permanent fork of the product
with none of the review a permanent fork would get.
Ships with de-parallel-router registered at 0%: inert, and ready to ramp in a
patch release once #2840 lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(lint): route asset-src skips through a shared isUnresolvedAssetPlaceholder predicate
Follow-up to the templating-token fix. The __UPPER__ + templating-token skip was
copy-pasted across the asset-src sites and had drifted: two non-lint sites carried only
the __UPPER__ half, and htmlCompiler's comment still claimed it "matches lint's skip"
after lint's skip became a superset. Extract one isUnresolvedAssetPlaceholder(rawSrc) in
@hyperframes/parsers/asset-resolution (both placeholder shapes, checked on the raw value)
and route every site through it: the four project.ts lint sites, hevcPreviewLint, and the
two previously-missed post-substitution sites (studio-server mediaCodecMap, producer
htmlCompiler). Remote/inline handling stays per-site (audio uses a narrower check).
Behavior-preserving for the lint sites (full suite green); the two non-lint sites are
post-substitution so they don't false-positive today, but now share one definition and
can't drift again. Adds unit tests for the predicate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(parsers): refresh hasUnresolvedTemplatingToken aside for the shared predicate
The parenthetical said the __UPPER__ shape keeps its own inline check at each call
site; this branch folded it into isUnresolvedAssetPlaceholder, so point there instead.
Addresses review nit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- parseFrameRate now rejects malformed ratios (e.g. "30/", "30/0") instead of NaN.
- Add "--" before file paths so names starting with "-" are not parsed as options.
- cICP PNG chunk no longer returns before IHDR supplies width and height.
- Add regression tests for option injection, frame rates, and cICP ordering.
* docs(prompting): correct workflow one-liners against skill contracts
general-video leads with its positive identity and companion mode;
faceless-explainer keys on invented visuals instead of TTS;
talking-head-recut uses the 'graphic overlays' trigger term;
motion-graphics gains its input side and overlay output;
music-to-video stops implying images are required.
* docs(prompting): make vocabulary video grids readable
Replace the 4-5 column table hack with a 3-column CSS grid,
switch demo clips to autoplay muted loops (no black poster frame,
no player chrome over tiny videos), and align cells at 16:9.
* docs(prompting): document the opening interview and run-shape questions
The guide taught prompt shapes but never prepared readers for the
conversation that follows: the intent interview, the two run-shape
questions (storyboard, automation vs companion), the just-build-it
skip, and BRIEF.md as the resumable artifact. Add that section to the
overview, a disambiguation note on the storyboards page, and free up
'companion' as a reserved term in media-and-audio.
* docs(guides): make BRIEF.md the pipeline's Step 3 artifact
Step 3 (Strategy & Messaging) listed no output while describing
exactly what BRIEF.md now captures. Name the artifact in the step
table, project tree, step body, gate, and iterating list, and fix
SCRIPT.md's step label in the tree (Step 4, not 3).
* docs(quickstart): realign the setup surface with the skills catalog
The quickstart drifted from docs/guides/skills.mdx, CLAUDE.md, and the
prompting overview — it had never been updated when those surfaces were:
- `--full-depth` on both install commands, with the reason inline. Without
it `skills add` fetches the skills.sh registry blob, which lags `main` by
hours, so a reader following the quickstart installs stale skills.
- `check` in the `/hyperframes-cli` row, and a validate step in the manual
dev loop, which went preview → render with no gate at all. The prompting
overview calls `check` "the step people skip and regret" and states both
`lint` and `check` must pass before rendering.
- `/hyperframes-keyframes` in the core-skills table (8 rows → 9).
- `/figma` in the optional-workflow list (10 → 11).
* docs(skills): close the catalog drift class and complete the music-to-video input
Follow-up on the two review nits from #2872.
`/music-to-video`'s SKILL.md names three inputs — an audio file, a video to
pull audio from, or a track generated from a mood brief. Every compressed copy
of that description carried only the first two, and the third is the one that
makes "a complete video needs zero assets" true. Fixed on all eight surfaces
that state it, so no surface is now more correct than its siblings: the
prompting overview and quickstart setup tables, docs/guides/skills.mdx, the
README catalog, root CLAUDE.md + AGENTS.md, both CLI project templates, and the
router's own routes/music-to-video.md Input line (whose Interview must-haves
already listed all three).
The drift was structural, not accidental: the sync set declared in
docs/guides/skills.mdx and in CLAUDE.md's "Skill catalog maintenance" named
four surfaces and never the two setup tables, so those two were free to rot
while the declared four stayed correct. Both declarations now name them, and
both say the set applies to a *changed contract* — a reworded description —
not only to an added or renamed skill.
skills-manifest.json regenerated for the touched route file.
* docs(claude): point the routing-surface rule at routes/, not the moved stubs
Item 3 of "Skill catalog maintenance" still sent readers to
`references/workflow-catalog.md` for a workflow's input/output/trigger
contract and `references/route-briefs.md` for its interview entry. Both are
now "moved" stubs — the contract and the interview entry live together in
`references/routes/<workflow>.md`, one read per candidate route.
Same failure class the previous commit fixed at item 1: a maintenance rule
outliving the layout it describes. Swept the tree for other pointers at the
two stubs; there are none, so this closes it rather than fixing one instance.
* fix(lint): don't flag unresolved templating-token asset srcs as missing
The asset-src rules (audio_src_not_found, missing_local_asset, CSS url(),
and data-composition-src) treat any src that isn't a remote URL or an
__UPPER__ placeholder as a resolvable local path and error when the file
is absent. But the linter runs before any build/templating step, so a src
that still carries a late-bound templating token (<<token>>, {{ token }},
${token}) cannot be resolved statically and is not a missing file.
Add a shared hasUnresolvedTemplatingToken() predicate (alongside the
existing __UPPER__ tolerance) and skip such srcs at each asset-src site,
so unresolved templating tokens no longer surface as false-positive
"file not found / the rendered video will be silent" errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(lint): check raw src for templating tokens before cleanAssetUrl; cover video/img/css + hevc
Addresses review. At the <video>/<img>/<source> and CSS url() sites the token skip
ran AFTER cleanAssetUrl(), which splits on ?/# and truncates inside a ${...} expression
(e.g. `${asset?.url}` -> `${asset`), so those sites still emitted the missing-asset
false positive for valid unresolved templates. Move hasUnresolvedTemplatingToken() onto
the RAW value at both sites, and extend the same skip to hevcPreviewLint (same
pre-substitution, cleanAssetUrl-first shape). Add regression tests for video/img/css
(including ?/# inside ${...}) and clarify the predicate docstring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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
CodeQL (incomplete multi-character sanitization, code-scanning/803) on
the script-stripping regex added in c61a24b51 — a failing check, and
correct: a single replace can reform the pattern it just removed, since
`<scr<script>ipt>` leaves a whole `<script>` behind.
The security framing does not apply — the stripped string is counted and
discarded, never rendered, inserted, or served — but the incompleteness
is real for this use: a reformed tag survives into the match pass and
perturbs the element count the routing gate reads. Suppressing a gate
over a technicality when the fix is four lines is the wrong trade.
Now loops to a fixed point. Terminates by construction: each iteration
either strictly shortens the string or changes nothing and exits.
Regression covers the reform case and an unterminated `<script>` that
must not spin; fault injection confirms the reform test fails under the
old single pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on #2891. Two of them bite directly on this PR's own
purpose — making the fleet element-count distribution readable — so they
are fixed rather than noted.
countElementTags counted `</` + letter anywhere, including inside inline
JS. A compiled comp containing `const h = "</div>"` or a template literal
building `</span>` inflated the count once per occurrence. Compiled comps
embed large inline scripts, so the bias is systematic, not noise, and it
lands entirely on the ~83% of renders with no probe session — precisely
the cohort this PR exists to characterize. Script and style bodies are
now stripped before matching; losing their own closing tags costs 1-2
counts against a threshold in the thousands.
The new elementCount fell back to 0 when its page.evaluate threw,
following the tweenCount pattern beside it. For this field that pattern
is wrong: evaluate failures concentrate on the huge-DOM compositions the
field is meant to observe, and a 0 there is indistinguishable from a
legitimately empty comp, so the fleet p50/p99 would absorb both silently.
It is now undefined on failure, the INIT console line omits the token
entirely rather than emitting a zero, and the parser reports absent —
mirroring the live/static provenance split the routing resolver already
uses.
Also documented: the "every render reaches this path" claim holds only
for renders that survive to end of init, so the tail is survivor-biased
and should be read as a lower bound; and the two element-count fields now
say plainly which is which — composition_element_count gates routing,
observability_init_element_count is the observational counterpart — so
the follow-up analysis can't query the wrong one.
Nits: envInt is integer-only per its name, both live-DOM reads use
getElementsByTagName (live collection length, no NodeList materialized on
the 40k-node tail), and the attribution block notes that it runs with
routing off by design.
Fault injection confirms the new tests bite: disabling script stripping
fails 4, and the zero-vs-undefined case is pinned separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The short-comp routing gate can only read a live element count when a
probe session exists, and the first v0.7.83 data shows that is far rarer
than estimated: 17% of renders (86/503), not the ">=28%" the video-presence
proxy suggested. The other 83% fall back to a static source scan, which
is exactly blind to the shape that motivated the live count — small
markup, thousands of script-created nodes.
That leaves the fleet element-count distribution unknowable for most
renders, and the observed distribution is already surprising: p99 ~900,
max 1,420 against a 2,500 ceiling calibrated on 7k/20k/40k synthetic
nodes. Either the ceiling is close to irrelevant, or the large-DOM tail
is hiding in the 83% we cannot see. Both readings change what PR B
should do, and neither is decidable from probed renders alone (they are
a biased sample — they got a probe *because* they carry media or
unresolved compositions).
So measure it where every render already goes: capture-session init.
`collectSessionInitTelemetry` gains a querySelectorAll("*") count beside
the tween count it already collects, riding the same channel to
`observability_init_element_count`. This is observational only — capture
has begun, far too late to route on — and it deliberately does not feed
the gate. It answers the distribution question the gate cannot.
Coverage for this channel is proven rather than assumed: the tween-count
fix that shipped in v0.7.83 took the clamped-parallel bucket from 0/272
renders to 217/217, and 23.1% -> 100% overall.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EaseCurveSection.tsx was 635 lines against CI's 600-line cap, which has
been failing the File size check on main for four consecutive runs and
blocks release cuts.
Moves the two self-contained presentational pieces into a sibling
EaseModeControls.tsx, following the pattern the directory already uses
(easeCurveSvg, easePresetLibrary, EaseParamFields): the mode radio group
(EaseModeToggle) and the preset grid (EasePresetGrid), plus the mode
vocabulary they own — EASE_MODES, the EaseMode type, MODE_LABELS,
DEFAULT_EASE_BY_MODE, and the DEFAULT_CURVE/Pts pair those defaults are
built from. Both components are stateless: they take the current
selection and emit a committed ease string, so nothing had to be
rewired. Only the symbols the parent still references are exported —
EASE_MODES and DEFAULT_EASE_BY_MODE became file-internal, since the
components that consume them moved too.
No behaviour change. EaseCurveSection is now 556 lines, the new file 110.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Renders tab format popover rendered as an in-flow absolute panel inside
the right panel, which is overflow-hidden, so it was sliced at the panel
edge. Portal it to the body and position it with the shared floating-panel
helper instead.
The ui/Tooltip bubble clamped only its centre point to the viewport, so a
wide bubble near an edge still hung off-screen (the timeline Selection tool
tooltip lost 32px on the left). Clamp with the measured bubble width.
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>
## What
Removes six Studio feature flags that have been default-`true` for 7+ weeks. Each is reachable under two env names, so this deletes **12 `VITE_STUDIO_*` env vars**:
| Flag constant | Env names removed | Default-on since |
|---|---|---|
| `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`, `VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | 2026-05-12 |
| `STUDIO_INSPECTOR_PANELS_ENABLED` (+ its `STUDIO_PREVIEW_SELECTION_ENABLED` alias) | `VITE_STUDIO_ENABLE_INSPECTOR_PANELS`, `VITE_STUDIO_INSPECTOR_PANELS_ENABLED` | 2026-05-12 |
| `STUDIO_BLOCKS_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_BLOCKS_PANEL`, `VITE_STUDIO_BLOCKS_PANEL_ENABLED` | 2026-05-18 |
| `STUDIO_GSAP_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_GSAP_PANEL`, `VITE_STUDIO_GSAP_PANEL_ENABLED` | 2026-05-28 |
| `STUDIO_KEYFRAMES_ENABLED` | `VITE_STUDIO_ENABLE_KEYFRAMES`, `VITE_STUDIO_KEYFRAMES_ENABLED` | 2026-06-05 |
| `STUDIO_RAZOR_TOOL_ENABLED` | `VITE_STUDIO_ENABLE_RAZOR_TOOL`, `VITE_STUDIO_RAZOR_TOOL_ENABLED` | 2026-06-10 |
## Why
Every one of these shipped as a rollout gate, went to `true`, and then stayed. Because none of them was ever flipped back, the `false` branch was unreachable in practice while still costing a real import, a real conditional, and a real "what happens if this is off?" question at ~90 call sites across 25 files.
The bigger cost is what the dead branch kept alive. Removing the flags also removes the disabled-Studio code paths that only existed to serve them:
- the greyed-out, `disabled`, "Manual editing is temporarily disabled" Inspector button in `StudioHeader` (and the `STUDIO_MANUAL_EDITING_DISABLED_TITLE` constant behind it)
- the inspector-off reset `useEffect` in `useDomSelection`, which force-cleared selection and redirected the right panel to Renders
- three selection kill-switch early-returns in `useDomSelection` (`applyDomSelection`, `handleTimelineElementSelect`, `applyMarqueeSelection`)
- the tab-redirect branch in `normalizeStudioUrlPanelTab`, whose `options.inspectorPanelsEnabled` parameter had no production caller at all (only tests passed it)
## How
No behavior change: every flag was removed by keeping its default-`true` side.
The call-site edits are three mechanical boolean shapes (`X && rest` → `rest`, `rest && X` → `rest`, `!X || rest` → `rest`), applied by script for uniformity. Everything else (ternaries, `if` guards, unreachable blocks, JSX wrappers that had no other condition) was done by hand and the whole diff was read line by line afterwards.
`resolveStudioBooleanEnvFlag` and the `import.meta.env` / `window.__HF_STUDIO_ENV__` plumbing stay: three flags still use them (`STUDIO_FLAT_INSPECTOR_ENABLED`, `STUDIO_SDK_CUTOVER_ENABLED`, `STUDIO_SDK_RESOLVER_SHADOW_ENABLED`). Its unit tests kept their coverage but now exercise a live flag pair instead of retired env names, so no dead `VITE_STUDIO_*` string is left in the repo.
Net **-191 lines** (236 insertions, 427 deletions across 25 files); most insertions are reindentation of JSX that lost a wrapper.
### Deliberately not in scope
Flags authored by other people are untouched, even where they look similarly settled:
- `VITE_STUDIO_ENABLE_FLAT_INSPECTOR` / `VITE_STUDIO_FLAT_INSPECTOR_ENABLED` (default true, but not mine)
- `VITE_STUDIO_SDK_CUTOVER_ENABLED`, `VITE_STUDIO_SDK_CUTOVER_FAMILIES`, `VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (SDK cutover canary, still soaking)
- `VITE_HYPERFRAMES_NO_TELEMETRY`
Mine but genuinely long-lived configuration rather than rollout gates, so they stay: `VITE_STUDIO_DISCOVERY_PORTS`, `VITE_HYPERFRAMES_FEEDBACK_INTERVAL`, `VITE_HYPERFRAMES_NO_FEEDBACK` (a documented user opt-out), plus the `HYPERFRAMES_*` binary paths, API URLs, cache sizes, and timeouts.
`VITE_STUDIO_ENABLE_MOTION_PANEL` / `VITE_STUDIO_MOTION_PANEL_ENABLED` were already retired from production code before this PR; they only survived as placeholder names inside the resolver's unit tests, and this PR swaps those out.
## Test plan
- [x] Unit tests added/updated - dropped the two tests asserting removed flag defaults; retargeted the `resolveStudioBooleanEnvFlag` cases at a live flag pair; updated `studioUrlState` tests for the narrowed `normalizeStudioUrlPanelTab` signature (now also asserts an unknown tab returns `null`).
- [x] Manual testing performed - see below.
- [ ] Documentation updated (if applicable) - not needed; no removed name appears in `docs/`, `skills/`, or `registry/`. (`docs/changelog.mdx` has one historical entry naming `STUDIO_KEYFRAMES_ENABLED`; changelog history is left as written.)
```
packages/studio: bunx vitest run # 280 files, 3116 tests pass, 1 skipped
packages/studio: bunx tsc --noEmit # clean
bun run build # green (all packages)
bunx oxlint <25 changed files> # 0 warnings, 0 errors
bunx oxfmt --check <25 changed files> # clean
```
Two extra checks, because part of this diff was script-generated:
1. Zero references to any removed flag constant or env name remain anywhere outside `docs/changelog.mdx`.
2. Diffed every string literal in each changed non-test file against `origin/main`. The only differences are the intended removals: the 12 env names, `"Manual editing is temporarily disabled"`, the `"cursor-not-allowed …"` disabled class, the 3-column `"1fr 1fr 1fr"` grid, and the `"renders"` redirect literals. No user-facing label, tooltip, or class string changed by accident.
R3 review finding: a string scan of compiled.html — however the regex is
tuned — cannot see elements a composition's own script creates at
runtime. The repo already has a production shape that hits this exactly:
style-10-prod's per-transcript-word caption generator builds one <span>
per word via document.createElement, measuring 2 source tags against
thousands of live nodes after init. That's the same unbounded-undercount
failure class as the earlier <img>/SVG counterexamples, but this one
has no static-scan fix — the elements simply don't exist as tags in the
string.
resolveCompositionElementCount() now prefers the live DOM size, queried
from the probe session that's already running for every render at this
point in the pipeline (its Chrome gets reused for capture on the common
single-worker path, so this costs one extra CDP evaluate, not a browser
launch) once that session's init sequence has completed — session.page
.evaluate(() => document.querySelectorAll("*").length) sees runtime-
generated DOM the source scan never could. countElementTags remains as
the fallback for the rare case with no initialized probe session
(evaluate throws, session absent, or not yet initialized).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(producer): keep large local fonts file-backed
* fix(producer): avoid local font file races
* fix(producer): bound local font stream reads
* fix(producer): cache large font file-backed decisions
Both reviewers (Rames, Magi) independently flagged the same thing: by the
time the return statement executes, hadFired is always false — the guard
above already returns early for every case where hadFired was true. The
merge expression wantFired || hadFired || undefined was defensively
correct but misleading; it reads as "OR the two together" when the
function has already established only one of them can be true here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review-blocking issues from Miguel's R2 pass (both confirmed by
running the counterexamples directly):
1. countElementTags still undercounted unboundedly. The void-element fix
covered HTML tags but SVG elements (<circle/>, <path/>, ...) are
neither closing-tag-shaped nor in the HTML void list, so
"<circle/>".repeat(40000) reported 0 — the same failure class as the
original <img> counterexample, and the exact shape of comp the
measured 1.8x regression case is made of. A 2500 ceiling cannot bound
an error with no bound of its own. Added a third alternative matching
any self-closing tag; verified it doesn't false-positive on the
adversarial minified-JS case (unspaced "<b/c>", which reads like a
tag open but never contains the literal two-char "/>" the alt requires).
2. HF_DE_SHORT_MAX_ELEMENTS=0 (the documented kill switch) still reported
deShortBand: "skipped_elements" for every in-band render instead of
undefined — attributing "comp too large" when the real cause was "band
disabled," which would have polluted the DiD control cohort with
kill-switched renders and made the post-flip read look like the
ceiling was too tight. Extracted the attribution logic into
resolveDeShortBand(), a pure function gated on bandEnabled
(deShortBandMaxElements > 0) as well as decisiveness — and made it
independently unit-testable, since the inline version could only be
exercised by a full render pipeline run.
Also from this review round: the inversion log line could report
"400 frames >= 900" for a band-routed inversion; it now names the floor
that actually decided the render. Tightened shortBand's type to match its
peer fields' unions (workerInversion, parallelRouter) instead of a bare
string. Clarified the tween-count merge docblock, which claimed workers
always agree (semantically true) while the code takes a defensive max
(in case one doesn't) — the two aren't in conflict, but the comment read
as if they were.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What
- Treat GIF as an alpha-capable output format and capture its frames as RGBA PNGs.
- Encode transparent GIFs with explicit FFmpeg palette semantics: `reserve_transparent=1` and `alpha_threshold=128`.
- Keep page-side shader compositing enabled for GIF while the resulting composite is captured through the RGBA disk-frame path.
- Extend the real render harness to verify decoded GIF alpha and compare a GIF shader-transition frame against the existing MP4 golden.
- Preserve the existing opaque encoder contract: `needsAlpha=false` continues to use JPEG frames without alpha-only palette filters.
## Why
Direct `--format gif` renders silently flattened transparent compositions when frames are captured as JPEG, because the palette encoder receives no alpha plane to preserve.
GIF also needs page-side shader compositing. A blanket `needsAlpha` exclusion disabled that path after enabling RGBA capture, while the layered compositor intentionally excludes GIF. That left shader GIFs on the DOM fallback and produced hard cuts instead of the authored WebGL blend.
## How
- Centralize output alpha detection in `outputNeedsAlpha`, shared by in-process and distributed planning.
- Select PNG or JPEG GIF frame input from the resolved alpha requirement.
- Make palette transparency flags explicit and conditional so the legacy opaque path retains its existing arguments.
- Add an explicit output-format capability for page-side shader compositing: MP4 keeps its opaque streaming path, GIF uses RGBA PNG disk frames, and WebM/MOV/PNG sequence retain their existing paths.
- Add `data-no-timeline` to the static transparency fixture so the artifact regression does not wait for a timeline it intentionally does not register.
## Test plan
- [x] RED on base: direct GIF decoded with an opaque corner instead of alpha 0.
- [x] RED on the previous PR head: the real GIF shader-transition frame scored 11.05 dB against the existing golden because neither shader compositor was active.
- [x] `bun test packages/producer/src/services/render/renderFormat.test.ts packages/producer/src/services/render/stages/encodeStage.test.ts packages/producer/src/services/render/capturePlan.test.ts` — 23 passed.
- [x] `bun run --filter @hyperframes/producer typecheck`
- [x] `bun run --filter @hyperframes/producer build`
- [x] `bun run --filter @hyperframes/producer test:transparency` — WebM, GIF, and PNG sequence alpha assertions passed; GIF shader control/transition frames scored 28.11/26.57 dB against the golden.
- [x] `bun run --cwd packages/producer tsx src/regression-harness.ts page-side-shader-compositor-render-compat --sequential` — all 100 visual checkpoints passed, stream parity passed, and audio correlation was 1.000.
- [x] Changed-file oxlint, oxfmt check, pre-commit checks, and `git diff --check`.
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>
Recut after pre-registering the read exposed two flaws in the first cut:
1. Attribution was wrong. de_short_band keyed on frame count + element
ceiling alone, so a webm render, a compile-gated comp, or a forced
screenshot at 400f reported "applied" while its routing was untouched —
poisoning the measurement cohort with unaffected renders and diluting
any effect toward zero. Now the predicate is evaluated twice (900 floor
vs band floor) and the band is DECISIVE only when the calls disagree:
every other eligibility condition passed and only the floor differed.
The cohort contains exactly the renders whose routing the band decides.
2. A same-release flip is unfalsifiable. composition_element_count ships
WITH the routing change, so the before-period cannot be filtered to the
same cohort as the after-period — the comparison would show a speedup
even if the change did nothing (the after-cohort excludes big comps by
construction; the before-cohort includes them). Routing is therefore
gated behind HF_DE_SHORT_BAND_ROUTE, default OFF: this release computes
and emits the full band decision on every render ("applied" is the
counterfactual "would have inverted"), a follow-up flips the default.
Identical cohort selector on both sides of the boundary, and the
skipped/oversize renders in the same frame band form a concurrent
control — a difference-in-differences that absorbs secular drift
(content mix, version-correlated populations, hardware), which a plain
before/after cannot.
Also: countElementTags now counts HTML void elements. Counting only
closers read an image gallery as a tiny comp and opened the band on
exactly the content most likely to lose it (images skew expensive to
paint). Opening tags stay uncounted — inline scripts' `a < b` would
false-positive. Counter semantics are frozen while the baseline is read:
the distribution the baseline release records must be measured by the
same counter that later gates.
Revert-rate baseline for the pre-registered read, measured over 14d
fleet-wide: the 900+ inversion runs 31,756 inverted / 1,705 reverted =
5.1%. At the benched 1.16-1.24x win and ~1.8x revert cost, expected net
for the band is ~12%. Kill criteria for the flip release: DiD <= 0,
in-band revert rate > 5.1% baseline, or band fallback rate > DE baseline.
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>