mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
68205dbbc159412209663ede7732629b4f50bcf1
556
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
68205dbbc1 |
feat(cli): search the catalog by meaning, on this machine (#3089)
* feat(cli): search the catalog by meaning, in three named tiers Browsing the registry means matching names and tags, which fails whenever the author's wording differs from yours. "make the pace feel faster" finds nothing when the move is described as "velocity-driven blur". This ranks by meaning instead. Three tiers, and the command always says which one answered: words shared vocabulary, free, offline, no account on-device bge-small, free, offline, one opt-in download hosted Gemini, free for signed-in HeyGen users The tier is stated because a quietly worse answer looks exactly like a good one. --json carries it as a token alongside dropped, shown, total and top_score, so an agent reads provenance as data rather than matching English that is written to be reworded. Two consents, asked once each, and never conflated. Sending a query is a privacy question, so the prompt says the query is sent. Downloading a model is a disk and bandwidth question, so that prompt talks about size. Neither fires without a terminal: an unattended run sends nothing and downloads nothing unless a flag records that a person agreed. The catalog is derived from registry-item.json rather than from a separate document, so the set that is ranked and the set that can be installed are the same object by construction. Only the on-device vectors are committed; the hosted vectors are nine megabytes and belong on the server. top_score is reported and never acted on. A "nothing matched" threshold looked clean on long briefs and collapsed on the short queries people type: "a logo appears" scores 0.6181 and keyboard mash scores 0.6417, so any cut that catches the noise rejects the real query. The measurement is in the evals directory rather than in this branch. Not covered here. The published recall figures were measured against a separate hand-written document, not against registry text, so they should not be quoted for this catalog until re-measured. The offline tier needs a normal install: a single-file build cannot load the native ONNX runtime, which the command now reports instead of silently degrading. And the drop-detection path has never been observed firing outside its author's tests. * fix(cli): make this branch pass the repo's own gates Three things `bun run lint` and `fallow audit --base origin/main` rejected. CI runs both, so none of this branch would have gone green. Found by running them, not by reading the diff. process.exit in catalog.ts, twice: an invalid --type and a cancelled picker. check:cli-process-ownership reserves that for cli.ts, and the rule is not cosmetic — process.exit tears the process down where it stands, so anything cli.ts has queued to run on the way out is dropped. finishCommand throws a CliResultSignal that cli.ts turns into the exit code, which is what init.ts already does for a cancelled prompt. Three exports with no consumers. normalize keeps its body and loses its export; localEmbedder is the only caller. modelsDirectory goes entirely, having no caller inside its file or out. The WordPieceConfig re-export goes, and with it the import it existed to forward: the type is exported from wordpiece.ts, where its consumers already take it from. Complexity. prepareOnDeviceTier is lifted out of run(), which took run from 64 cyclomatic and CRAP 948 to 54 and 684. That block is one decision — can the offline tier run, and if not, why not — and its only product is a list of warnings, so it reads and tests as a unit, which it could not do inline. The rest is suppressed rather than refactored, each with its reason on the line above. Finishing run() means extracting its three output paths, and that is a refactor of a command this branch already changes for other reasons: a separate initiative, not something to absorb here. Every suppression says what shape the function has and why; a bare marker on a function nobody can justify is how a threshold stops meaning anything. Verified: `bun run lint` exits 0, fallow reports no issues across 27 changed files, and 2540 CLI tests pass. * feat(cli): ship the local search tiers only, drop the hosted one Search now has two tiers, both local: shared-vocabulary word matching, and the opt-in on-device model. The hosted tier, which sent the query to a HeyGen endpoint and ranked it with a hosted model, is removed. This is a scope decision, not a defect. The endpoint works and its own change is reviewed and green; it is simply not what we want to ship first. Landing local only means the feature has no backend dependency, no auth requirement, and nothing leaves the machine unless someone opts into downloading a model. Gone: registry/smartSearch.ts and its test, the --smart and --no-smart flags, the outcome plumbing through the command, the remote branch of applySearch, the remote tier, and the hosted-only JSON fields (ranking, catalog_version, top_score). Also the smartSearchEnabled consent field in telemetry config, which was the persisted storage behind the hosted consent and would otherwise have been left as dead configuration surface. Kept exactly as they were: both local tiers, the --on-device and --yes flags, the download consent prompt, and the runtime check that happens before the download rather than after it. The --json envelope still reports query, tier, tier_detail, shown, total, dropped, warnings and results, so an agent can still tell which tier answered and why. tierToken now distinguishes on-device from words. Verified: lint exits 0, fallow reports no issues, 2522 CLI tests pass, and the command was exercised directly. A query answers on the on-device tier where the model is installed and falls back to word matching where it is not, reporting that fallback in warnings rather than silently. An unknown --type still exits 1 with a readable message, and --smart is now rejected as an unknown flag. * fix(cli): count only moves this registry cannot install as dropped The dropped count was computed against the list left after the user's own --type and --tag filters, so every move the user excluded was reported as one the registry is missing. Filtering made the number go up: the same query reported 277 unfiltered and 302 with --type block. The count exists so a caller can tell "nothing matched your words" apart from "the ranker suggested things this project cannot install". Conflating it with user filtering destroys exactly that signal, and worse, genuine index skew and a self-inflicted filter printed a byte-identical line with opposite remedies -- one means refresh the shelf, the other means drop a flag, and refreshing does nothing. Now counted against the registry rather than the filtered view. The manifest is already fetched whole and narrowed in memory, so keeping the unnarrowed name set costs no extra request, and item loading still runs only on the filtered subset. Verified against ground truth rather than by eye: the vector artifact holds 411 names, the registry holds 168 installable items, and 134 of those names exist in both, so 277 are genuinely uninstallable. The count now reads 277 unfiltered, 277 under --type block, 277 under --type component and 277 under --tag, and the skew it reports is real -- the artifact predates dropping the UI primitives and still ranks moves that are no longer on the shelf. Reported by Vance Ingalls, who also noted this closes an item the status doc listed as unverified. Two earlier sweeps could not make the count fire because neither combined a filter with a query. Tests pin the three cases: a genuinely absent name counts, a filter-excluded name does not, and a fully installable ranking reports zero. * fix(cli): tell the user when meaning search cannot see the catalog The on-device index was fetched once and never revalidated: the only freshness check was two existsSync calls. A move added after that fetch was invisible to meaning search permanently, not down-ranked but absent from the candidate set. The registry manifest on the same command carries a 24h TTL, so the two halves of one feature disagreed about staleness. The dropped count reported over-coverage only, names the index has that the registry lacks. Under-coverage was never computed, so the harmless direction was instrumented and the costly one was silent. Reproduced with an index truncated to 120 of 168 moves: dropped read 0, perfect health, while 48 moves were unreachable. Counts under-coverage from the name list the artifact already carries, so no extra request. Warns only when non-zero, and names the remedy. The remedy had to be made true: --on-device could not refresh a stale index because hasLocalVectors short-circuited the fetch. That flag now refetches when the index is absent or no longer covering. Two defects the reproduction surfaced. A failed refresh reported the tier unavailable while the old vectors were still on disk and still ranking. And the fetch wrote its two files one at a time, so failing between them paired a new name list with an old matrix, a hard load error rather than stale data. It now writes both or neither, which matters more once refresh runs on staleness. top_score returns, scoped to the on-device tier and set to the score of the best result actually shown rather than the ranking head, which can describe a row the caller never received. Also: scripts/ is now typechecked. It never was, which is how a build script that crashes after the paid embedding call, and two scripts whose imports do not resolve at all, went unnoticed. 43 errors fixed, no suppressions. And the docs stop describing a --smart hosted tier that was deleted, an item that does not exist, and a registry refresh that cannot fix a stale vector index. * ci: fail when the search index stops covering the registry The catalog vector artifact is regenerated by hand. Nothing in CI, in package.json or in a hook rebuilds it, because embedding needs the 32 MB model. So adding a registry item silently makes it invisible to meaning search until someone remembers to regenerate. The failure is asymmetric, which is what makes it easy to miss. Removing an item is self-healing: the ranker still scores the dead vector, then filters the name before display, so a user is never offered something they cannot install. Adding one is not: the item is absent from the candidate set entirely, not ranked low. Comparing the two name lists needs neither the model nor a network call, so the gate runs in seconds. CI checks rather than fixes, for the same reason it cannot regenerate. Scoped to blocks and components. Examples are starter projects a user scaffolds, never something catalog ranks, and the artifact carries no vector for them, so demanding one would keep this gate permanently red and it would be ignored within a week. Verified in both directions rather than assumed: adding an unindexed item exits 1 and names it, restoring the registry exits 0. * fix(catalog): rebuild the search index from the registry build-local-vectors.ts read registry/catalog-artifact/catalog.json, a file no script in this repo writes and which is not committed, so the documented regeneration command failed on a missing path. That is why the index could drift from the registry with nothing to run to fix it. It now reads registry/blocks/* and registry/components/* through catalogFromRegistry, the existing helper that already produced the right shape but had no caller. Rebuilding reproduces the shipped 168 rows byte for byte. A lefthook catalog-index command regenerates and re-stages both artifact files whenever a staged registry-item.json changes, mirroring the skills-manifest pattern, so adding or removing an item keeps the index in sync without anyone remembering to. Verified end to end: staging a new item took the artifact 168 to 169 rows and staged it in 0.80s. * fix(cli): refuse a half-downloaded vector cache The two artifact files have to agree on how many rows there are, and until now nothing checked that before writing them. A truncated or wrong-model response landed in the cache and only failed at load, on every later search, until someone cleared it by hand. The pair is now checked first and refused as a unit, and the cache is created 0o700 with 0o600 files rather than inheriting the umask of a directory the caller may have pointed anywhere. Also lifts the capture setup the two preview generators had drifted into sharing into scripts/preview-capture.ts, and splits the vector builders batching and packing out of main. Both were findings the audit attributed to this branch. * fix(cli): keep the catalog vitest run with the tests it runs Restacking took the base package.json wholesale, which dropped the vitest dependency and the scripts/catalog run this PR adds. Both belong here rather than under it. * fix(cli): stop the declined model download from happening anyway Answering no to the on-device download offer recorded no and warned, then carried on. The guard below it is localModelConsent() !== false, which the decline had just made false, so it was skipped rather than taken: control reached recordLocalModelConsent(true), overwrote the answer with yes, and fetched the 32 MB model the user had refused. Next run it never asked again. No test could catch it. The stub pinned localModelStatus to ready, so the prompt never fired, and recordLocalModelConsent was a no-op that recorded nothing. Two tests now cover the offer, and they need three things the old stubs did not model: the run has to look like a terminal, because off one the command treats --on-device as the consent and never asks; the ONNX probe has to answer true, or an accepted offer returns at the runtime guard before it can download; and the status has to follow the recorded answer, or the second offer later in the run fires as well. Removing the return makes the decline test fail. * fix(catalog): let someone without the model still add a component The pre-commit hook rebuilds the search index, and rebuilding needs the 32 MB embedding model. An outside contributor adding a registry item does not have it, so their commit died inside the ONNX loader on an ENOENT naming a path they never set, and the CI gate then told them to run the command that had just crashed. The model is an opt-in for search, not a build dependency, so nobody is charged for it to contribute. The builder checks first and explains itself, exiting 3 for cannot as distinct from 1 for failed. The hook treats 3 as skip and lets the commit through. The gate now names both paths: regenerate if you have the model, leave it if you do not and a maintainer will. Verified both ways: with no model the builder explains and the hook exits 0; with the model it still regenerates byte-identically. * docs: say that anyone can add a registry item, and stop hand-editing a generated file Two defects, one of them the reason 64 stale entries survived in registry.json. The checklist told contributors to add their item to registry/registry.json. That file is generated from the item directories, so an entry added by hand survives until the next regeneration and then vanishes, and one left behind for a directory that no longer exists is worse: hyperframes add resolves the name and then fails on missing files. Both CONTRIBUTING.md and the agent-facing skill reference now run the generator instead. Nothing said contribution was maintainer-only, but nothing said it was not either, and two steps do need assets an outside contributor has no reason to install. Those are now named in a table with what happens if you do not have them, matching how the preview image was already handled. The search index is the new one: the model behind it is a 32 MB opt-in for search, not a build dependency. * fix(cli): harden on-device catalog search * fix(cli): refresh stale catalog vectors * test: create catalog vector temp dirs securely |
||
|
|
b4bd670402 |
fix(core): retry bpm-detective import after transient failure (#2736)
loadBpmDetective cached the promise returned by dynamic import even when that import rejected. A transient failure (network hiccup, bundler issue, missing module at first access) was therefore cached as null for the rest of the session, silently disabling BPM detection. - Reset the cached promise on import failure so the next call retries. - Only cache the default production import; custom loaders bypass the cache. - Make loadBpmDetective testable by accepting an optional importFn. - Add regression tests for failure/retry and module/default resolution. |
||
|
|
19defeabfe |
feat(core,cli): ship the DE parallel router fleet-wide — remove the canary gate
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard in render.ts together, leaving the producer's default-ON in place. Net effect for users: the parallel drawElement router is on for everyone again. ## Why, and why not a ramp Gating at 5% was itself the regression. Measured 2026-08-08, the day after v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are explicitly disarmed and #2840 deleted the everyone-armed trial in the same change. 2,537 installs lost a feature they already had. Severity is speed only, never output, and nothing is persisted to disk. PR #2840's body claimed "the canary does not make exposure smaller; it makes it chosen and revertible." That was true of the end state and false of the first step. This lands the end state. Entry and guard go together deliberately: at >=100 the evaluator short-circuits ahead of the CI/seedless exclusions, so removing only the entry would have flipped whatever still resolved false at deletion time, unstaged. ## Both stated blockers are void - **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0 of 4,281 across every CPU tier, software GL gates it out — and the router requires it. No percentage could ever expose Docker, so no ramp closes that gap. ≤4 CPUs yields ~42 drawElement candidates in three days. - **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93, 0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore this router. It is real, still live on 0.7.101, and belongs to the screenshot/beginframe path. 11 reproduction runs across four configurations on the enriched profile (darwin/arm64 25.5.0) came back clean. ## Safety unchanged The per-install circuit breaker and the per-render self-verify are untouched; `HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) — consistent with the 2.75-3.16% baseline. Revert path is now a code revert rather than a registry edit. That is the trade this shape accepts in exchange for one release instead of two. ## Corrects two claims that shipped wrong `~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and `~11% of installs already route` was an OUTCOME (the share clearing eligibility and the old 25-render cap), not an exposure setting — read as a rollout knob it inverts the arithmetic, which is how gating at 5% came to cut exposure rather than ramp it. Both are recorded in render.ts so they are not reintroduced. ## Tests Removed the core wiring assertion and the two CLI canary-gating tests, which pinned a gate that no longer exists. Added the inverse guarantee in its place: an ordinary install must come out of the breaker with the var UNSET so the producer default applies — writing "false" there is precisely what disarmed the fleet at 5%. core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area). oxlint and oxfmt clean. Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router` and `canary_reason_de_parallel_router` are emitted from the registry, so the `Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go blank once this ships. Watch drawElement engagement on 1807532 instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b6ff3ab745 |
fix: preserve the composition query and serve the runtime before author scripts (#3114)
* fix(player): stop re-encoding the composition query Every src the player sets goes through withShaderQueryParams, which parsed the author's whole query with URLSearchParams and re-serialised it with toString(). That is a form encoder: it writes a space as +, while callers percent-encode and read back with decodeURIComponent. Those two codecs are not inverses, so any space in any query value arrived corrupted. It ran even when there was nothing to inject. With no shader attributes both params are deleted, so the round-trip was pure loss, on every src, for every consumer. Append the two params to the raw query instead of re-serialising it. The player now hands a composition its query back byte-identical. Empirically space was the only casualty: plus, ampersand, equals, hash, percent, question mark, quotes and non-ASCII all survived a URLSearchParams round-trip. That is narrow, but a space in a headline or in SVG path data is the common case, and invalid path data renders nothing at all. Latent until now: no shipped consumer depended on query preservation, so this surfaced only once compositions began carrying variable payloads. * fix(cli): serve the runtime ahead of every author script injectRuntime appended its script before </body>, so it landed after any inline script the composition carried. At the moment a composition's own script ran, window.__hyperframes was undefined and getVariables() was unreachable: our documented API did not exist at the point authors are told to call it. Served order was gsap at line 6, the composition's init script at 20, the runtime at 37. A probe inside the composition's IIFE recorded hfTypeAtInit undefined with no variable keys, and the element rendered its hardcoded fallback rather than the declared value. The runtime is designed to load early. Its entry assigns __timelines, installs the authored-opacity capture (whose own comment says it must run while the document is still parsing), and exposes __hyperframes synchronously, deferring real work to DOMContentLoaded. End-of-body injection defeated all three, and nothing in it needs a parsed DOM, so no defer is wanted. Injects at head start instead, reusing the placement cascade injectScriptsAtHeadStart already implemented rather than adding a fourth copy of it. Head start rather than the closing tag so the runtime also precedes author scripts inside head. injectRuntime has exactly one consumer, the play server's composition route. Every other surface reaches the runtime through the bundler, which already injects into head, or deliberately serves raw. Two registry blocks had independently worked around this by parsing the authored attribute themselves. Those stay, but the workaround is no longer the only way to read a variable at init. |
||
|
|
b08cefea63 |
Merge pull request #3105 from heygen-com/fix/caption-declared-color-states
fix(core): let a composition declare its caption colour states |
||
|
|
cef0dde5f2 |
fix(core): draw the dim baseline only from tweens the guess still applies to
Review catch. The baseline was taken from the first colour tween unconditionally, so a tween declared "active" at index 0 set the reference its undeclared siblings were compared against -- and the genuinely dim tween beside it was classified active and given the wrong override. A partial migration could therefore end up worse off than a composition that declared nothing. The reference is now a declared "dim" tween if one exists, else the first undeclared one: the heuristic stops drawing its inputs from records the declaration has already spoken to. Also pins the fallback for a malformed declaration -- a typo, a number, a null, or a non-object `data` -- so a future tightening of the accepted union cannot quietly turn an unrecognised value into a broken composition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
867eeabc0f |
Merge pull request #2840 from heygen-com/07-27-feat_producer_enable_parallel-de_router_by_default
feat(cli,core,producer): ramp the parallel-DE router through the canary at 5% |
||
|
|
1e7799bfbb |
fix(core): let a composition declare its caption colour states
Classification by colour equality has to guess: it takes the first colour
tween's value as the dim baseline and calls everything else active. A
composition whose two states share a colour therefore has every tween
classified dim, and the caller's activeColor is silently dropped -- a real
failure, now covered by a test that fails without this change.
A tween may declare its state as data: { captionState: "dim" | "active" }.
GSAP passes unknown vars through untouched, so declaring costs nothing at
runtime, and resolution is per tween -- a composition can declare some and
leave the rest to the fallback, which is unchanged for anything undeclared.
This is the composition telling us what it built rather than us inferring it
from what it happens to look like. The data-driven caption templates already
author their state tweens from resolved values and never guess; this closes
part of that capability gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
57ec008cb2 |
refactor(core): settle the four remaining preview-vs-render divergences (#3097)
## Why #3094 fixed one way the mount and render paths disagreed, and added the gate that catches disagreement. It deliberately left the rest. Four divergences are still live. Each one means a composition assembles differently depending on whether it is being previewed or rendered — the same class of defect that shipped three catalog components unstyled, just with smaller blast radii. ## How Both paths now derive root discovery, scope identity, asset sources and order, hoisted links, variable carriers and nested-host enumeration from the shared module #3094 introduced. Each keeps its own I/O, which is where they genuinely differ. The compiler's local depth cap and root lookup and the runtime's three pre-filtered head parameters are gone; the runtime hands over the head node and lets the module decide what comes out of it. Four behaviour changes, each stated by what actually differs rather than by the edit: **Inline `<head>` scripts.** The compiler looped head scripts with a `src` branch and no `else`, so an inline one was silently discarded on render while the runtime ran it. That is losing code, not holding a convention — the runtime's answer wins. Head and content scripts now share one loop, head first, order preserved. A non-templated sub-composition with an inline head script went from **0 collected scripts to 1**, wrapped, body intact. **`<link>` hoisting.** Conditional on render, unconditional on mount, so a templated sub-composition's webfont link was dropped in video and kept in preview. Hoisting is the superset and matches what the author declared. A templated composition with a stylesheet link went from **no external links to that link**. The parity fixture that previously recorded this shape as a known exclusion now gates it. **Anonymous hosts.** With a host naming no id, the compiler fell back to the first declared composition and scoped to it; the mount left the content unflattened and injected its stylesheet into the host `<head>` **unscoped**, so a composition's CSS leaked into whatever mounted it. The compiler's answer wins. The injected rule went from a bare `.label { … }` to `[data-composition-id="scoped-text"] .label { … }`. **Scope ids.** The compiler splits the CSS scope id from the script composition id; they differ only when a host names an id the content does not declare, and there the scripts follow the declared id so their self-referencing queries resolve. The runtime used one for both. The split wins: a host naming `captions-comp` over content declaring `captions` now emits scripts bound to `captions` while its CSS still scopes to `captions-comp`. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) Core 1694 passing, producer 574 passing, the parity contract now gates the two divergences it can observe (the other two carry no contract field, so they are gated by unit tests naming the exact before/after). Lint 0, `typecheck:runtime` and the runtime preview guards clean, package cycles unchanged. Characterization-first: both suites were run and recorded green before any decision moved, so a behavioural drift would surface as a red test rather than a silent difference. **One assertion changed, deliberately.** A runtime test asserted that an anonymous host's composition is *not* flattened, and documented that as intentional. That premise is now false. What the test actually cared about — the root and its content present under the host — still holds and is still asserted; the "not flattened" claim flipped, and the test now also asserts the scoping that was missing. ## Not covered The variable-carrier divergence and its `TODO(template-var-carriers)` are untouched by design, as is recursion on the mount path — a sub-composition containing its own `data-composition-src` is still silently dropped in live preview. Both are behaviour changes with their own units, and both are now one-line-ish changes because the shared module already reports what they need. `runtimeScopeCompositionId` no longer falls back to the authored scope id. This is a functional change beyond the four above, surfaced in review: for an anonymous host with authored variable defaults, the runtime previously stashed them under the declared id, and now does not. It removes a runtime-vs-compiler divergence in the correct direction — the runtime was doing work the compiler never did, and the compiler is authoritative for a shipped composition — but a caller relying on runtime-only variable exposure loses it. The three copies each of the flattened-root helper and the id assignment are left alone: they look mergeable and are not cheaply, and they touch the instancing contract the pixel harness guards. ## Worth knowing The parity test's compiler arms import core's **built dist** while the mount arm imports source, so core must be rebuilt before that lane means anything after a compiler change. Skipping it produces a phantom divergence that looks exactly like a real one. |
||
|
|
4a2514232b |
feat(cli,core): ramp the default-on router through the canary
Rebased onto main (was 308 behind) and gated the new default-on behaviour on the de-parallel-router canary, at 5%. Default-ON without a ramp is a ~17x exposure jump: from ~6% of eligible renders today to all of them, landing on profiles the opt-in trial never covered (<=4 CPUs and Docker, ~12% of eligible renders between them). 0.7.60-0.7.64 is why that matters — every unclamped render reverted for five consecutive releases and nobody noticed. The gate reuses the breaker's own disarm: non-enrolled installs get an explicit HF_DE_PARALLEL_ROUTER=false, because with default-ON polarity deleting the var means ON. Setting the registry percentage to 0 is therefore a full fleet-wide revert with no release. Today's ~11% of installs routing is emergent — the product of eligibility rules and a capped trial — so it drifts with fleet composition and cannot be turned off without shipping. The point of the canary is that the number becomes chosen and revertible, not that it is smaller. Also replaces the registry test that pinned the percentage to 0. Its intent was 'ramp only alongside the circuit breaker', but pinning 0 blocks the ramp forever and never checks the wiring it names. It now asserts the wiring directly, and fails if either the canary gate or the breaker consult is removed. Hold at 5% until PRINFRA-372 resolves: --workers auto crashes every worker on macOS arm64 while --workers 1 is clean, and the router forces 3 workers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8d9db3df73 |
fix(core): stop dropping a mounted composition styles, and gate the divergence (#3094)
## Why A composition mounted as a sub-composition lost its entire stylesheet and scripts whenever they were authored as siblings of the composition root inside its `<template>`. That shape is legal and common, so three catalog components — `oversized-cursor`, `device-frame-stage`, `touch-indicator` — rendered **completely unstyled** in the live preview. `oversized-cursor` drew its pointer at 1280px against an authored `7cqw` (~134px at 1920), because `width: 7cqw` was never declared at all. Confirmed in the mounted document, where only the host's own `<style>` was present. The rendered video was correct the entire time. This was a preview-versus-render divergence, and it survived a fully green test suite. ## How **The fix.** `mountCompositionContent` collected assets from the composition root element, so sibling nodes were invisible to it. It now collects from the source node — a superset of the root, and the single point every mount path routes through (external fetch, inline template, nested). It also strips the mounted *clone* rather than the source: the previous code removed extracted nodes from the node it was handed, which on the inline-template path is a live `<template>` still in the document, so a remount would have found it emptied. **Why nothing caught it.** Every CLI gate — `check`, `lint`, `validate` — reaches the compiler path through `bundleToSingleHtml`, and the compiler always collected from the whole template. Nothing in the CLI exercises the mount path, which is reachable only through the player and Studio. The repo's own parity test assembled a fixture two ways and deep-equalled a contract across them, but **both arms were static-compiler paths** — which is exactly why the runtime could drift unnoticed. **The gate.** A third arm mounts the same fixture through `loadExternalCompositions` and extracts the same contract. Three fixtures run through all three arms, one authoring its assets as root siblings — the shape that broke. `authoredStyleSignatures` was already in the contract and is exactly the signal that was missing, so no contract field was added. **The owner.** Both paths answer the same questions — which nodes are a composition's assets, in what order its scripts run, how its CSS is scoped, which head elements hoist, how nested hosts are discovered, which element carries variable defaults. They now have one module to answer them from. It holds decisions only, never I/O: the two paths differ at their boundary in ways that are essential (Node + linkedom + synchronous + strings; browser + fetch + live DOM + script *execution*), and the runtime ships as a bundle to a CDN, so anything it can reach is weight and risk. Hence zero imports, a structural input type rather than `Document`, and a test asserting the import surface stays empty. Routing both paths through that module is deliberately **not** in this PR — it changes behaviour in four places (below) and belongs where each can be judged and reverted on its own. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) Every claim here was verified in both directions rather than assumed. The fix's regression test fails on pre-fix code and passes after — run both ways. The parity arm was proven able to fail: with the fix reverted, the sibling fixture fails and names the composition's own scoped selector against an empty list, while the other two fixtures stay green, so the arm is targeted rather than blanket-red. Restored, 7/7 pass. `bun run lint` exits 0. Core: 1690 tests passing, plus `typecheck:runtime` and `lint:runtime-preview-guards` clean. Producer: 571 tests passing. The shared module's own defect was reproduced by mutation — collecting from the composition root instead of the whole template fails three of its 17 tests, including the sibling case. ## Found while doing this, not fixed here Deriving the shared decisions surfaced **four more live divergences**, none of them the reported bug, each a behaviour change to decide deliberately: - The compiler silently drops inline `<head>` scripts — it handles the `src` case and has no `else` — while the runtime executes them. - `<link>` hoisting is conditional on render and unconditional on mount, so a templated sub-composition's webfont link is dropped in video and kept in preview. This one reproduces under the new parity arm and is explicitly excluded from its contract, with the reason recorded in the file. - For a host naming no id, the compiler falls back to the first declared composition and scopes to it; the runtime mounts the content whole, unflattened and unscoped. - The compiler keeps two scope ids, CSS and scripts, so a script's self-referencing query resolves when a host names an id the content does not declare; the runtime keeps one. Separately: the mount path **does not recurse at all**, so a sub-composition containing its own `data-composition-src` is silently dropped in live preview. The compiler has a dedicated recursive-discovery suite; the runtime has no nesting, circularity or depth coverage. Each is recorded with its evidence in the commit messages here, and sequenced so the behaviour-changing ones land separately, after this gate exists to catch a mistake in them. ## Not covered This does not heal the published docs by itself. Previews load `@hyperframes/player` unpinned, but the player bakes a version-pinned core runtime URL at build time, and core and player publish in lockstep — so the live catalog only recovers after both ship. There is no hotfix path short of a release. |
||
|
|
076657a639 |
feat(core,cli): emit the canary decision reason alongside the assignment
The calibration contract deferred this until the stability check came back dirty. It did: the first fleet read found 304 installs (1.08%) reporting both values for a canary whose percentage never moved, and the genuinely anomalous ones could not be separated from a developer toggling HF_CANARY_*, because the assignment alone is identical in both cases. resolveCanary has always computed the reason and canaryEventProperties dropped it. Now every canary emits canary_reason_<name> beside its assignment. Deliberately outside the $feature/ namespace: PostHog treats those as flag values, and a non-boolean there would corrupt the flag's own breakdowns. Two of the six wire values are immediately useful beyond override attribution. 'excluded' identifies CI installs, which today have to be dropped by joining on is_ci — conflating them with out_of_cohort is what made the first accuracy read look like a significant failure (9.22% against a 10% target) when it was not. 'no_unit_id' surfaces the fails-closed corner. The reason is optional on the core helper so existing callers are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d373c3f4a0 |
fix(catalog): explicit section ownership, no body-sniffing heuristic
carriedSectionsFrom() decided whether a ## Usage section was generated by matching its first line against a list of historical opener phrases — so a hand-written Usage section that happened to open that way was classified as generated and silently deleted on regeneration. Ownership is now purely set membership: a section is generated iff its heading is one the template emits, and ambiguous 'usage' is no longer in that set (the template never emits it), so any ## Usage is carried. Exported carriedSectionsFrom behind an entrypoint guard and added two executable preservation fixtures. Flagged by Magi (#5). |
||
|
|
6ff6601dd0 |
fix(catalog): restore the required Related topics section on generated pages
My generator rebuild replaced the '## Related topics' section (still required by docs/AGENTS.md) with the provenance footer, dropping it from all 168 generated Catalog pages. Emit it again as the final section so pages end with it, and stop carriedSectionsFrom() breaking at the footer marker so a human section appended below the generated tail survives a regeneration. Adds a per-page regeneration assertion so a future drop fails CI. Flagged by Magi (P1) and Rames. |
||
|
|
1664fe6ad7 |
fix(core,producer,skills): unicode paths, non-Error rejections, shell callers
Three R3 findings.
The redactor's segment classes were ASCII `\w`, so `/数据/客户/秘密视频.mp4` and
`/data/客户/secret.mp4` went out verbatim — and the generic redactor also feeds
CLI telemetry and producer observation messages, where no known-path list
compensates. Segments are now defined by their delimiters instead of an
alphabet, which is correct for every script by construction rather than
requiring Unicode classes to be kept correct. The bare-relative lookbehind had
the same ASCII assumption and let a match start mid-token, redacting
`客户/秘密/视频.mp4` to `客户[path]`; it is now a token boundary, and
bare-relative runs before absolute so it claims the whole token.
sanitizeProbeFailure cast the rejection reason to Error and read `.message`.
An injected probe can reject with anything, so `Promise.reject("failed")` gave
`undefined` and threw inside the redactor — converting a returned failure
result into a rejected promise. Normalized at the boundary, and
redactKnownPaths no longer throws on a non-string.
The contract only admitted .ts/.js/.mjs/.cjs, so it missed shipped shell and
Python callers. frame_strip.sh passed a user-controlled path as ffprobe's last
positional with no terminator; render-and-composite.sh had four more. Both
fixed, and the sweep now covers .py/.sh. Python list argvs are bracket
literals so they get the same position check; shell command lines get a
separate presence check, because checking position there needs a shell parser
— stated as the weaker guarantee it is rather than implied to be equal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e79ab3ab31 |
fix(core,producer): redact bare relative paths and the known input path
The generic scrub still missed a relative path with no `./` prefix: `customer/acme-secret/video.mp4` and `assets/bgm.mp3` reached telemetry completely unredacted, because the absolute rule needs a leading slash and the `./` rule needs the dot. Adds a rule for them that still leaves `N/A`, `24/1` and `48000/1001` alone. Shape matching is a net with holes by construction, so audioPadTrim now also redacts the exact path it put in the argv, plus its basename, before the generic scrub runs. It built the argv, so it does not have to guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d04569e37f |
fix(core): redact any path in telemetry, not an allowlist of roots
redactTelemetryString enumerated roots — /Users, /home, /opt, /tmp and a handful more — so a project on /data, /Volumes, an NFS mount or any root a user invented reached telemetry verbatim. Relative paths and bare basenames were never redacted at all, and audioPadTrim routes raw ffprobe stderr through this on every probe failure. Now redacts by shape: absolute paths under any root (two or more segments, so N/A and a 24/1 frame rate are not mistaken for one), relative paths including dash-prefixed ones, and bare basenames with an asset extension. URLs still keep their host and drop only the query. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
47564ab94c |
fix(cli,core,lint,producer): terminate ffprobe options at every call site
#2740 added `--` to one of nine independent ffprobe invocations, so the bug class it closed stayed open everywhere else while CI reported it fixed — the regression test asserts the argv of that single site. Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine through extractMediaMetadata but fails with "Missing argument for option 'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper duration probing and webmAlphaCheck. hevcPreviewLint catches and returns false, so a dash-prefixed HEVC preview silently passes the lint rule. Terminated at all of them: producer/services/render/audioPadTrim.ts (x2) producer/plan-parity-analysis.ts cli/commands/init.ts cli/utils/webmAlphaCheck.ts cli/whisper/transcribe.ts (x2) core/mediaGradeAnalyzer.ts lint/hevcPreviewLint.ts audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's runFfprobe and structurally cannot add the terminator itself, because callers bake the input path into `args`. It now asserts the terminator is present rather than letting a dash-prefixed path through, takes the same stdio ["ignore", ...] as the engine helper, and redacts its stderr — it was throwing raw ffprobe output, which echoes the input path, into logs and telemetry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
71fd96bbf1 |
Merge pull request #2854 from heygen-com/feat/canary-rollouts
feat(core): percentage-based canary rollouts + calibration experiment |
||
|
|
4e7fcf7f2a |
fix(core): resolve sub-composition sibling asset paths everywhere (#2994)
Extends the studio-preview fix to the render path and the asset-discovery utilities, which share the same resolver and had the same defect. `rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the composition's own directory when that sibling exists on disk; project-root refs with no sibling (the registry's `assets/logo.png` convention) stay as authored. Callers that can see the filesystem supply the probe, so the module stays free of node:fs. Also fixes a second defect in the inliner: `<head>` <link> hrefs and external script srcs are hoisted into the root document but never went through the rewrite at all, so even the documented `../` form escaped the project and 404'd at render time. Wired into the preview bundler, the producer compiler, the studio preview builder, the HEVC preview lint, the project lint's asset scans, publish proxy baking, and media-treatment source resolution. |
||
|
|
3f8dca165d |
fix(cli,core): refresh telemetry posture at the render boundary
R6/R7 blockers. An already-open Studio kept emitting server-side render telemetry after another process disabled CLI telemetry. refreshTelemetryPosture() only ran while serving a fresh SPA document and on /api/telemetry-identity, which Studio has no consumer for, so the render POST and its async outcome used the posture cached when the preview server booted. It now refreshes at the render boundary and again immediately before the completion/error event, so an opt-out during a long render is honoured. The identity tests were passing vacuously: their mocks omitted readConfigFresh and resetTelemetryPostureCache, and the resulting missing-export error was swallowed by the refresh's own catch. Mocked properly, plus the enabled -> external disable -> next response transition and the suppression path at the layer that drops the event. A full reset also did not persist its new lineage in a long-lived process: syncInstallState returned early on a process-lifetime memo even after ~/.hyperframes was deleted, so install-state was never recreated and the next config-only re-mint rolled a third seed instead of inheriting the second. The memo is now revalidated against the file. Also drops a stale reference to assertNoOverdueCanaries and stops the workflow and docs claiming the sunset job routes anything to the owner — it names them in the run log and notifies nobody. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6f0df2640b |
fix(cli,studio,core): close five R5 telemetry and canary findings
- A long-lived preview cached its telemetry posture in two places (readConfig and shouldTrack). Running `telemetry disable` in another terminal left it resolving canaries and injecting the CLI id for hours. Both caches are now dropped together at a request boundary. - Studio minted and shipped a telemetry id for every render regardless of the browser profile's opt-out, and the server emitted the outcome under CLI policy, which cannot see localStorage or DNT. The browser now sends an explicit telemetryOptOut, distinct from an old client's omission. - Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard, so even a loopback bind accepted a hostile Host. The guard now holds for loopback binds and, on a LAN bind, admits only names this machine answers on. - sunsetAfter had no reader of the current date. A scheduled workflow runs scripts/check-canary-sunset.ts weekly, so a failure lands on the rollout's owner rather than on an unrelated PR author. - The install-state seed memo outlived `rm -rf ~/.hyperframes`, resurrecting a cleared cohort. Removed; it only saved a read on a readConfig cache miss. Docs updated for the Host rule and the 100% exclusion carve-out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f69a2c635 |
fix(cli,core,studio): close 15 review findings + 2 R5 blockers
R5 blockers
- Negative install-state latch was cached for the process lifetime, but
only `true` is monotonic across processes. A long-lived preview server
held a stale `false` and could re-enrol after another process tripped
the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
{ok:true, mirrored:false} to success, so a run that mirrored nothing
reported done with the latch only on the erasable store. It consumes
writeConfigWithResult and retries until both stores carry it.
Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
"anonymous": computed against the shipped hash, 100% of them were
enrolled in calibration-50 rather than 50%, and they merged into one
PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
write-once authoritative, but readConfig took config.json's blindly, so
the stores could hold different seeds until a re-mint flipped every
cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
re-rolling the seed on every command, and the "cohorts will not be
stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
forever — poisoning the exact metric this work exists to produce.
Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
disable` during a running preview server was ignored for hours while
the server kept serving pre-opt-out decisions. The memo is keyed on the
telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
argument, minting and PERSISTING a tracking id for an opted-out profile
— a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
for installs that opted out of telemetry entirely.
Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
stitching and split one user across two PostHog persons. Identity is
now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
non-browser client sets Host freely. It is a browser DNS-rebinding
mitigation, not access control, and now says so.
Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
the fast path, so the registry's "delete the entry at 100" step was an
unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
core suite would go red on 2026-09-15 for every unrelated PR; and `>`
against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
enough to fail ~1 run in 200. Seeded.
Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.
Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3a6b7f0612 |
fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior * fix: address WebGPU capture review feedback * fix: retain overlapping GPU seek work * fix: satisfy runtime seek completion types * fix: drain concurrent GPU seek work * fix: prevent WebGPU capture barrier starvation * fix: keep WebGPU presentation active during render seeks |
||
|
|
31361b8e5b |
fix(cli,core): close the remaining canary review findings
Six findings from review, none behaviour-critical on their own but three of them quietly corrupt the data the rollout is judged by. Endpoint no longer serves bucketSeed (studioServer.ts). Studio gets its canary answers from the injected decisions map now, so nothing needed the seed over HTTP — and an unauthenticated local endpoint is a strictly worse place for it than a script scoped to Studio's own document. The endpoint itself predates this PR and still serves distinctId, so it also gains a Host guard: a remote page can rebind its hostname to 127.0.0.1 and read the response as same-origin, but the request still carries THAT hostname, which is what makes it refusable. predecessorFound no longer reports corruption as a fresh install. It returned null for both "file absent" and "file unreadable", so a partial disk write looked like a new machine — understating recoverable churn, the one thing the field measures. Now distinguishes absent from corrupt and emits install_state_file_corrupt alongside. A mangled markerAt no longer discards a salvageable bucketSeed. markerAt is only a timestamp and can be restamped; the seed cannot be recovered, and losing it silently re-rolls the install's cohort. The seed backfill no longer ignores its write result. An unwritable ~/.hyperframes meant a different seed every invocation with no diagnostic, and made the field's own "backfilled once" docstring false. Warns once per process with the underlying error. FNV-1a's ASCII constraint is now explicit rather than incidental. It hashes UTF-16 code units while reference FNV-1a is byte-oriented, so the two agree only on ASCII; the registry's kebab-case assertion is what makes non-ASCII unreachable, and both ends now say so. Not a live bug — names are kebab-case and units are UUIDs. de-parallel-router is pinned at 0%. The registry is data, so a ramp is a one-line edit with no review surface, and its own description says to ramp only alongside the circuit breaker. Tests: 8 new (corruption vs absence, seed salvage, backfill write failure, 17 host-guard cases, registry pin). One existing test asserted predecessorFound: false on corruption — that was the bug, updated with a note. Fault injection: restoring the old corrupt handling fails 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4f464dc424 |
feat(cli,studio): telemetry opt-out is canary opt-out
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> |
||
|
|
6f6f01c8ea |
docs(canary): correct the loose-toggle count in the registry header
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> |
||
|
|
a7bb061afe |
feat(core): inert calibration canaries to validate the mechanism in the wild
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> |
||
|
|
a1682e1228 |
feat(core): emit canary assignments as PostHog flag properties
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>
|
||
|
|
df1521a0b6 |
feat(cli): attach canary cohort to telemetry, harden canary tests, document
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> |
||
|
|
3aea786687 |
feat(core): percentage-based canary rollouts
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>
|
||
|
|
04e0ccce42 |
fix: preserve plateaus in sampled audio automation (#2863)
* fix: preserve audio automation plateaus * fix(audio): align automation probe windows |
||
|
|
9bbb6d50a0 |
fix: offset nested template video timing (#2859)
* fix: offset nested template video timing * test(producer): cover nested sequential video render * fix: share canonical nested media timing |
||
|
|
477defc7e7 | fix(core): avoid live volume probe during render | ||
|
|
20f4bde46a | test(cli): harden media treatment parity | ||
|
|
c1dde28980 | fix(cli): address media treatment review findings | ||
|
|
e446de6023 | fix(core): align secondary mask contracts | ||
|
|
9a515858c9 | fix(core): preserve curve compiler subpath exports | ||
|
|
1ffef9a262 | fix(core): address grading review findings | ||
|
|
f99fc4e568 | feat(core): add professional color grading controls | ||
|
|
5acbf240cb | feat(core): add deterministic keyframe ease runtime | ||
|
|
4582881d00 | feat(cli): add agent-first media treatment tools | ||
|
|
944640c328 | feat(runtime): render media treatments deterministically | ||
|
|
70213c5a85 | feat(core): define media treatment capabilities | ||
|
|
1e2c7d673f |
fix(core): preserve nested-rule selectors in composition CSS scoping (#2721) (#2733)
Chrome 112+ / Firefox 117+ / Safari 16.5+ support native CSS Nesting.
A nested rule like '.title { … }' inside '[data-composition-id="intro"]
{ … }' resolves at match time to '<parent> .title' via the implicit
'&' prefix.
'scopeCssToComposition' walks every rule via 'root.walkRules' and re-
scopes selectors, but it did so for nested rules too — producing
'[…scope…] .title' inside '[…scope…] { … }', which nesting then
prepends AGAIN to '[…scope…] […scope…] .title'. Since the composition
root only appears once in the DOM, the doubly-scoped selector never
matches — the nested rule appears 'just ignored' as the reporter
described (#2721).
Reproduced on 0.7.66 with the reporter's exact composition. Fix: add
'isNestedInsideAnotherRule' predicate — mirrors the existing
'isInsideGlobalAtRule' — and skip nested rules in the walkRules
callback. Top-level rules still get scoped; their nested descendants
inherit scope naturally via CSS Nesting at match time.
Added two focused tests:
- 'preserves nested-rule selectors so CSS Nesting inheritance works
(#2721)' — asserts nested '.title' and 'h2' selectors stay verbatim
while top-level rules keep scoping.
- 'preserves deeply-nested CSS Nesting rules (#2721)' — same rule at
depth 3.
All 37 existing scopeCssToComposition tests still pass.
Fixes #2721.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
69446e7726 |
Revert "feat(producer): renderStretch to re-time short compositions across longer scenes (#2676)" (#2730)
This reverts commit
|
||
|
|
63539a0cde | fix(core): apply position edits to SVG elements, not just HTML (#2724) | ||
|
|
e786b78b33 |
feat(producer): renderStretch to re-time short compositions across longer scenes (#2676)
Linear: VA-1859 ## Problem For a `fit_to_scene` B-roll where the composition's intrinsic timeline (e.g. `data-duration=1.0s` → 30 frames) is shorter than the scene it fills (e.g. 4.8s narration), the producer renders only the intrinsic 30 frames and the downstream compositor frame-holds/PTS-stretches that fixed clip to the scene length. Spreading 30 unique frames over 4.8s starves motion to ~6 effective fps → a visibly choppy result. Root cause: the producer welds one `composition.duration` to both the frame count and the 1:1 seek mapping, with no notion of a target output length. ## Fix Add optional `renderStretch: number` (default `1.0` = no-op), `renderStretch = intrinsic / target`: - **Frame count** comes from the target: `outputDuration = intrinsic / renderStretch`, `totalFrames = outputDuration × fps` (`probeStage.ts`). `composition.duration` stays intrinsic (drives video/audio windows). - **Per-frame seek** is scaled: `time = (frameIndex / fps) × renderStretch`, so the N output frames map across `[0, intrinsic]` — a fresh frame per output frame. All seek sites go through a single shared `outputFrameToTimelineSeconds(frameIndex, fps, renderStretch)` helper (`core.types.ts`), consumed by every capture path so none can silently diverge: - parallel (`parallelCoordinator.ts`), `sdr_streaming` (`captureStreamingStage.ts` ×3), `sdr_disk` (`captureStage.ts`), HDR loops. - DrawElement + static self-verify (`frameCapture.ts`) — ground-truth seek uses the same mapping, so PSNR compares like-for-like (no spurious verification failure on stretched comps). - Distributed path: `renderStretch` threaded through `DistributedRenderConfig` → chunk workers, and **folded into the plan hash only when `!= 1`** so a pre-stretch cached plan is never reused. With `renderStretch = 1` (or omitted → `?? 1`): every seek is `×1.0` (IEEE-754 identity), frame counts unchanged, and the plan hash is byte-identical — a provable no-op. `player.ts` absolute-seek is untouched. ## Verify - typecheck (core + engine + producer): pass. lint/format/fallow/commitlint: pass. `planHash` + `renderRequest` unit suites: pass. - Adversarial self-review found + fixed three capture-path gaps (streaming, self-verify, distributed) before this revision. - **Not yet runtime-verified** on a real render — needs a fit_to_scene render at `renderStretch < 1` confirming N distinct frames over the target length (draft until then). Paired with experiment-framework#42766, which computes and forwards `renderStretch = hf intrinsic / scene duration`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
ac9f463108 |
fix(lint): recognize compiler-derived data-end as legitimate
`bundleToSingleHtml` compiles `data-duration` into `data-end` (in `compileTimingAttrs`), then calls `validateHyperframeHtmlContract` against the compiled HTML. The linter's `deprecated_data_end` rule fired on the compiler's own consistent output — `<audio data-start="0" data-duration="18" data-end="18">` — because `diagnoseDerivedEnd` unconditionally emitted a `deprecated-end` diagnostic whenever both attributes were present, ignoring whether the derived value matched. Reporters routed this as "raw-source lint passes with 0 errors and 0 warnings, but `check --strict` still logs StaticGuard noise about data-end without data-duration." Field cluster: cli-feedback crons 61-68, n=25+ across darwin/arm64, darwin/x64, linux/x64, win32/x64, and versions 0.7.56 through 0.7.64. L3 reporter cite (ts=1784519869): "bundleToSingleHtml compiles data-duration into data-end, then validates the compiled HTML and reports its own generated data-end as deprecated." Fix: `diagnoseDerivedEnd` now stays silent when the paired `data-end` matches `data-start + data-duration` (within a 1ns epsilon to absorb IEEE-754 residuals like `0.1 + 0.2 = 0.30000000000000004`). Truly-legacy authoring shapes — `data-end` alone with no `data-duration`, or a `data-end` that disagrees with `data-duration` — still fire `deprecated_data_end`, with a refined message that names the drift on the conflicting variant. Facets covered: (a) validator treats compiler-derived `data-end` as legal when paired with `data-duration`, and (e) recognizes the compile-time rewrite site (`bundleToSingleHtml` → `compileHtml` → `compileTimingAttrs`). Facets (b) stderr gating, (c) terminal JSON verdict, and (d) audio-not- dropped are unblocked transitively — StaticGuard's `console.warn` is already gated on `!isValid` and never drops audio; the check command already emits JSON on every terminal path; so once the false-positive diagnostic stops firing on compiler output, the noisy stderr line and the misleading "check appears to fail" reporter framing both go away without further wiring. Co-Authored-By: Via <noreply@heygen.com> |
||
|
|
d21883fe05 |
fix: recursive sub-composition inlining for depth-3+ nesting (#2660)
* fix(core): discover sub-composition hosts to a fixed point during inlining
inlineSubCompositions collected [data-composition-src] hosts from the
document once, before inlining began, then processed that fixed list in
a single flat loop. A host's inlined content could introduce new hosts
of its own (a sub-composition nesting another sub-composition), and
those were never discovered: two-level nesting worked because the
second level's host already existed in the root document, but any
third level's host only appeared inside content the single pass had
already finished walking, so its content silently never rendered.
Host discovery now runs as a work queue: after a host is inlined, the
newly inserted subtree is re-scanned for further hosts, which are
enqueued with their ancestry chain. A host whose source already appears
in its own ancestry is a circular reference and is reported through the
existing onMissingComposition channel instead of being inlined; a depth
ceiling backstops any gap in that check. Existing single- and two-level
fixtures are unaffected.
* fix(producer): assign runtime composition ids to hosts discovered mid-inline
The producer's per-instance runtime id assignment (assignBundledRuntimeCompositionIds)
ran once over the root document's initial hosts, before inlining. With
host discovery now iterating to a fixed point (previous commit), hosts
revealed inside an already-inlined sub-composition were never in that
pre-pass, so the identity map returned undefined for them: two sibling
instances of the same sub-composition, discovered mid-traversal, both
fell back to their shared authored id and clobbered each other's
variablesByComp entry.
hostIdentityMap is now a lazy map: a pre-pass cache hit returns
unchanged, a miss reads the host's authored data-composition-id,
allocates a collision-checked runtime id by scanning the live document,
writes it back, and caches it. No change to the shared inliner's call
signature. Existing single-instance and root-level reusable-template
cases (#2064) are unaffected.
* test(producer): add depth-3 nested sub-composition regression fixture
Adds a minimal three-level chain (root -> level-2 -> level-3) where the
deepest level renders a distinguishing full-frame marker, following the
existing sub-comp-* fixture pattern. Confirmed against the pre-fix
commit (
|