mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
a9ea07edde6648463e3970a572dc42f4007a3d7f
187
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a9ea07edde |
fix(cli): reject blank default composition entries (#3392)
* fix(cli): reject blank default composition entry * fix(cli): complete blank entry safeguards |
||
|
|
f822200fb8 |
feat(telemetry): measure which lint rules fire, cost, and fail to converge (#3367)
* feat(telemetry): measure which lint rules fire, cost, and fail to converge Lint rule changes are currently argued from anecdote. This adds the three measurements needed to argue them from data. `lint_report`, once per `hyperframes lint` or `hyperframes check`: - `code_counts` / `codes` — which rules actually fire, and how often - `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...) - `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>` - `rule_count` — how many rules this build ran `lint_rule_streak`, once per finding that survives an edit to its file: - `edits` — how many edits the finding survived - `cleared` — whether it eventually went away The streak event is the one that matters. A lint pass costs about 5ms, so per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot satisfy is, because every failed attempt costs a full edit-and-relint cycle. A single run cannot see that, so `lint_rule_streak` reconstructs it across runs: high `edits` with `cleared: false` is a rule nobody can fix, and the `cleared: true` distribution is the baseline to judge it against. An iteration is counted only when the file's content digest CHANGED and the finding is still there. Re-linting an untouched project is not an attempt, which is what stops `check` (which lints on every invocation) from inflating the numbers. Rule identity is the source module plus an index within it. Naming all 86 rules would make the timings prettier but it is a refactor this measurement does not need: the group locates the file, and the index locates the rule. Version, agent runtime, CI flag, and invocation id are already attached to every event by `trackEvent`, so lint pain can be split by CLI version and by which agent produced it without adding anything here. Privacy: only rule codes, counts, and timings are sent. Streak state lives in ~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf ~/.hyperframes` is still a full reset) and stores digests only — no file paths, no project names, no composition source. Nothing is written and nothing is emitted when telemetry is off. Entries expire after 14 days and are capped at 500 files. `EventProperties` gains string arrays and numeric maps. `codes` and `code_counts` are inherently a set and a histogram; flattening them into dynamic top-level keys would make them unqueryable. PostHog stores both natively. `trackLintRun` is the single call site shared by `lint` and `check`, and it swallows every error — telemetry must never turn a green lint red. * feat(telemetry): emit per-group rule counts so slowest_rule stays comparable Review catch on #3367: `slowest_rule` is the one positional key in either event. It is `<group>#<index>`, so adding or removing a rule renumbers every later slot in that group and the same string means different rules in two builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count` alone says only THAT the ruleset moved, not which groups. `rule_group_counts` carries the per-group sizes alongside it, so a consumer comparing two builds can tell which groups' indices still mean the same thing without anyone having to remember which release dropped rules. `codes`, `code_counts` and `rule_group_ms` are keyed by name and were never affected. Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as LINT_RULE_COUNT in the same file computes. |
||
|
|
83ceaeb902 |
refactor(lint): drop seven rules that fire on correct compositions (#3366)
Each rule below either reports a hazard the compiler or runtime already
prevents, duplicates another rule's invariant with a weaker detector, or
cannot be cleared by its own fixHint. Measured over the 643 shipped
registry HTML files, this cuts lint output from 1740 findings to 507
(-70.9%) and removes 40 errors, with no new codes introduced.
- scene_layer_missing_visibility_kill: regex heuristic keyed on `#sceneN`
ids. It only accepts the literal string `visibility: "hidden"`, so the
canonical GSAP hard kill (`tl.set(el, { autoAlpha: 0 })`, which sets
visibility hidden at runtime) never clears it — an unfixable error. It
also matched the `0` inside `opacity: 0.5` and treated `.from({opacity:
0})` entrances as exits. gsap_exit_missing_hard_kill owns this invariant
using parsed tween timing and real clip boundaries, and accepts every
hidden encoding.
- unscoped_gsap_selector: wrapScopedCompositionScript already rewrites
string GSAP targets to the composition root for every sub-composition
script (pinned by compositionScoping.test.ts "executes document and GSAP
selectors inside the composition root"). The rule also never fired on a
standalone sub-composition file or a <template> sub-comp.
- caption_transcript_parse_error: required the inline TRANSCRIPT array to
be strict JSON so Studio could read it, but Studio's parseTranscriptArray
already normalizes unquoted keys, single quotes, and trailing commas. It
errored on ten shipped caption components whose transcripts Studio parses.
- composition_self_attribute_selector: warned that
`[data-composition-id="x"] .y` leaks across instances, but
scopeCssToComposition rewrites that selector to each instance's runtime
scope. It was also the pattern the rest of the toolchain prescribes.
- timed_element_missing_visibility_hidden: strict subset of
timed_element_missing_clip_class, which reports the same condition as an
error, so it only ever added a second line saying the same thing.
- pointer_events_none: Studio selection ergonomics only, no render impact,
on 124 of 211 shipped blocks.
- google_fonts_import: the producer resolves Google Fonts during
compile/render, as the message itself said.
system_font_will_alias is narrowed to distributed/Lambda renders, where
system-font capture is off and the fallback is a real defect. Under a local
render the substitution is the renderer working as designed, so the info
tier is gone.
The three tests that used composition_self_attribute_selector as a probe
for "this style source was collected" now use scoped_css_missing_wrapper,
which still fires once per source.
|
||
|
|
d464f60b96 |
fix(cli): zip the publish archive to the same bytes every time (#3358)
adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP timestamp resolves to two seconds — so archiving identical content twice gave different bytes whenever the two runs landed either side of a boundary. The archive's digest was a function of the clock rather than of its contents, which is backwards for something `cloud render` uploads and addresses by content. It surfaced as a CI flake: publishProject.test.ts asserts two archives built back to back are byte-identical, and both sides are the same expression, so the only way it can fail is non-determinism. The window is narrow, which is why it survived since July and why re-running always cleared it. Entry times are now fixed. Built from local components deliberately: `fromDate2DOS` reads getFullYear/getMonth/getHours, so a fixed instant would still encode differently per timezone — verified identical bytes under UTC, America/Los_Angeles and Asia/Kolkata. The new test moves the clock across a boundary, which is what reproduces it; back-to-back builds land in the same bucket almost always, which is exactly how it hid. |
||
|
|
9da422fd7f |
feat(cli): run a managed background preview in every launch mode (#3310)
`--background` was rejected outside the embedded server. It now re-execs the CLI in foreground, which makes it mode-agnostic by construction: whichever server the child resolves to serves the config endpoint the readiness probe looks for. `--foreground` is its counterpart, for a non-interactive shell that wants to stay attached, and a bare launch keeps the same promise — attached in an interactive terminal, managed in an agent session. That generalization exposed an existing hole. Local-studio mode runs Vite with the studio package as its cwd and needs that package's own Vite config, which the published tarball does not carry, but resolving the package was treated as proof the mode was usable. An npm-installed studio therefore took a path that can never come up — previously a clear error, now a ten-second silent timeout. The predicate becomes "can this studio actually be served", so a published install falls back to embedded mode, which works. Over the 1k line budget at ~1.3k. The overage is one command file and its tests carrying one invariant, and the seam that would split it further is inside a single request-handling function — a split there would produce two PRs neither of which starts a preview on its own. |
||
|
|
74149e249a |
fix(cli): keep a live preview's ownership record and stop past a bad one (#3308)
* fix(cli): keep a live preview's ownership record and stop past a bad one A missed liveness probe is not proof the preview is gone — a server blocked on a Puppeteer capture answers nothing for a second or two — but any miss retired the session record, and the record carries the only PID-reuse guard `--stop` has. Reproduced by SIGSTOPping a managed preview and running `--status`: the record was deleted and never came back, leaving every later stop to fall through to an unauthenticated port scan with no ownership proof at all. Only a wrapper process that is provably gone now retires a record. That record gains a process-birth token so a recycled PID reads as a different process, and it is written through a temp file and renamed — every reader deletes it when it fails to parse, so a torn read would otherwise destroy a live server's proof of ownership. Two failure-propagation bugs in the stop path: `--kill-all` collected the first unprovable record's exception and abandoned every server after it, so they were left running AND unreported; and a replacement refused to launch when the server it was replacing had already exited on its own, which is the goal state rather than a failure. `--list` now shows managed sessions ahead of whatever else answers the scan. * fix(cli): keep a record whose identity lookup gave no answer, not a different one Review blocker. The keep-alive path this PR adds could still retire a LIVE record — through a different door than the one it closed. `processIdentity` catches every failure into `null`, and on two of three platforms that failure is a subprocess timeout on a live process: the win32 `Win32_Process` CIM query and the POSIX `ps -o lstart=` both run on a 2 s budget, under exactly the load that made the HTTP probe miss in the first place. A `null` compared unequal to the saved token, so the record was deleted and `wrapperIdentity` — the only PID-reuse guard `--stop` has — was gone for good. Only Linux, reading /proc directly, was reliable. No answer is now distinguished from a different answer: the PID is checked with `kill(pid, 0)` first, which asks the kernel without signalling and treats EPERM as alive. A PID nothing can signal is gone and retires the record with no subprocess at all; a signalable PID whose token cannot be read keeps it. Only a token that comes back and differs retires it. That ordering also answers the `--list` note: the identity subprocess no longer runs for the stale records that made it slow, so the N x 2 s worst case is gone along with the timeouts that fed the bug. Verified by mutation: restoring the old "no answer means gone" behaviour reds the new case. Also clean up the temp file when a rename fails, rather than orphaning it in the session directory. * test(cli): assert only what the birth-token lookup actually guarantees `captures a stable birth token for the current process` made two assertions that a lookup allowed to fail cannot support. `processIdentity` returns null whenever the lookup cannot be completed — not only when the process is absent — and on Windows and macOS it shells out to PowerShell or `ps` on a 2 s budget that a cold CI runner routinely outruns. Both failed on windows-latest, in sequence: first `.toMatch()` received null, and once that was guarded, `expect(second).toBe(first)` compared a null from the cold first spawn against a token from the warm second one. Two lookups can disagree for exactly one reason — one of them failed — so stability is only assertable across two successful ones. The token itself cannot change between calls; it is a birth timestamp and the process did not restart. `processIdentity(-1)` stays unconditional: the guard rejects it before any subprocess runs. The strict shape assertion moves to a Linux-only case, where /proc is read directly with no subprocess and null is genuinely not allowed — keeping the guarantee on the one platform that can honour it rather than dropping it everywhere. Callers already depend on this contract: `wrapperProcessIsAlive` treats null as "no answer" rather than "gone" precisely because it is reachable. |
||
|
|
c1c70f44bd |
fix(cli): signal only processes the OS says own the port (#3307)
* fix(cli): signal only processes the OS says own the port `/__hyperframes_config` is unauthenticated and the PID it reports is what `--stop` and `--kill-all` send signals to, so any local process answering on a scanned port could name an arbitrary PID and have the CLI kill it. Reproduced with a twenty-line HTTP server on a scanned port self-reporting an unrelated PID: before this, `--kill-all` killed that process; after it, the process survives and only the real listener is stopped. The listening PID now comes from the OS — `lsof`, and `netstat` on Windows, where the lookup was previously unavailable and the self-reported value was taken on trust. The response's own PID is used only where the OS lookup fails, which is also the only case where it is unfalsifiable. Orphan cleanup moves to the last step before a launch. It reaches outside the process and kills other people's PIDs, so it must not run for an invocation that turns out to be a validation error and never starts anything. * fix(cli): fail closed when the OS cannot confirm who owns a port Review follow-up. The two halves of this change picked opposite directions for the same condition. `isProcessDescendant` fails closed by design; `activeServerOnPort` fell back to the self-reported PID whenever the OS lookup came back empty — and that is not only "unsupported platform". `lsof` may be absent (the default on many slim images), may time out, or may not see a socket owned by another user. On such a machine every scanned port silently reverted to pre-change behaviour, with nothing said. Provenance is now part of the type rather than a convention: `ActiveServer` carries `pidSource`, so a caller cannot mistake a self-report for the kernel's answer. `--kill-all` requires `"os"` and skips the rest, naming the ports it left alone and why. That is the deliberate trade — a blind sweep of a port range has no evidence beyond an unauthenticated response, so an unconfirmed PID must not be signalled. Managed previews are unaffected: they stop through their session record, which proves ownership by process birth identity. The fallback branch — the one with the security consequence — now has the coverage it lacked, via an injected lookup matching the seam `testPortOnAllHosts` and `isProcessDescendant` already use, including a live process that survives because nothing confirmed it owns the socket. Also state that `killProcessTree` honours `signal` on POSIX only: Windows always passes `/F`, deliberately, since taskkill without it posts WM_CLOSE that a console process may ignore. The caller-side comment claiming Windows cleanup is a no-op described the code before this change and now says the opposite. |
||
|
|
56d8df65ca |
docs(skills): add /hyperframes-audio, and key the waveform cache by file (#3211)
* feat(studio): show every automated knob at the playhead, and carve as one module An automated parameter has two values: the number sitting in the chain, which is only the seed a lane replaced, and the number the envelope is on right now. The second is the true one, so the panel shows it — on the carve rack's readouts and on every effect's own fader and number field. A rack that showed the seed stood still while the carve was audibly working. Off the clip it keeps sampling rather than falling back to the stored number: a lane holds its first value backwards and its last forwards, so before the clip starts it already knows what it will open on, and the stored seed is a value nothing will ever play. Showing it made the fader jump the moment the clip came under the playhead. The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop deliberately keeps frames out of the store, so a panel watching only the store would sit still for a whole take. PropertyPanel had that subscription inline; it is now one shared hook with two callers. Readouts reserve the width their parameter can need rather than what its current value takes, because an updating value one character narrower shunted everything after it sideways 30 times a second. The carve's effects are presented as one module: an author switched on a carve, and the peaking filters plus the level stage are how it is built, not six things to remove one at a time. Opening it lists every member's settings as readouts, since strength is what sets them. No carve control is offered on a track another track already carves against — that track is the voice, not the bed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio-server): key the waveform cache on the file, not just its path Two takes written to the same path returned the first one's waveform, so a re-recorded track drew the shape of the audio it replaced. The key now carries size and mtime, which is enough to notice the bytes changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): render audio FX in an OfflineAudioContext Reads `data-fx-chain` off an audio element and runs the chain over the trimmed WAV before volume automation is baked in — effects should see the raw signal, and the envelope belongs on their output. The processing happens in an OfflineAudioContext inside the headless browser the engine already drives, running the same graph builders the studio previews with. That is the point of the approach: one implementation per effect, so the render agreeing with the preview is a property of the architecture rather than a tolerance to police. Reimplementing each effect as an FFmpeg filter would mean two implementations to keep in step, and for the dynamics processors and modulated delays there is no filter that behaves the same way. `build:audio-fx-runtime` bundles the graph builders into an injectable IIFE, following the same pattern as the existing runtime artifacts, so the browser runs exactly the code the studio does. The page loads from a file:// URL rather than about:blank because AudioWorklet is only exposed in a secure context — the compressor, limiter, gate and bitcrush processors would otherwise fail to register with an opaque error. file:// qualifies and needs no listening socket. The chain is serialised into the attribute the way colour grading carries its config, so there is no side-car file to resolve or lose. An FX failure is fatal for the whole mix rather than a per-track soft failure. Every other audio failure mode degrades gracefully — the track drops, siblings continue — but substituting the dry signal for a processed one ships a render that sounds plausible and is not what the author set up. Since the per-element work races under Promise.all, an internal AbortController chained off the caller's signal aborts in-flight siblings before workDir is removed. * feat(core): voiceover carve analysis Finds the bands a voice occupies so a music bed can be dipped there, letting the voice sit in front without ducking the whole track. Carve is a relationship between two tracks rather than an effect on one, so it stays out of the FX chain. What it emits is an ordinary chain of peaking filters, so a carve composes with whatever else is on the track and needs no separate rendering path. Selection is weighted toward intelligibility rather than raw voice energy. Ranking purely by power lands on the fundamental almost every time, because that is where a voice is loudest — but the masking that actually hurts a voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights toward 1-3 kHz. Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB across these bands — it falls off roughly 6 dB per octave above the fundamental — so a weighting has to be on that scale to move anything at all. A multiplicative weight of `1 - bias + bias * shaped` is bounded below by `1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7 default, 3 dB at 0.5. That is no influence against a real voice — every bias short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the outcome the bias exists to prevent, while looking decisive against a fixture whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth up to 30 dB at full strength, and relative cut depths come from a dB difference rather than a ratio of weighted linear powers. The bias reweights ranking without overriding the spectrum — a band the voice has no energy in is not worth carving, and scores -Infinity rather than competing — so a strongly low-pitched voice can still select low at full bias. What the tests hold is that biasing never selects lower than the unbiased ranking, that the DEFAULT bias reaches the presence region on a voice with a realistic tilt, and that bias 0 still follows raw power exactly. Includes a radix-2 FFT rather than a dependency; one Welch-style averaged spectrum over third-octave bands does not justify pulling in a DSP library. * fix(engine): keep the FX render 16-bit, stereo, and correctly sized Three defects in the offline FX path, none of which any test could see. **Float output silently disabled sample-accurate volume automation.** The writer emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope into the samples and accepts only 16-bit PCM, returning null otherwise. So enabling any effect downgraded that track to the ffmpeg expression path — capped at 32 straight segments, quantising a curved envelope, and on a dense one falling back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test asserts the baker accepts the writer's own output and actually fades it. **Everything was folded to mono.** `prepareAudioTrack` goes out of its way to emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo rematrix — and this folded it, then wrote one channel. So adding a single peaking EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed stereo. Channels now travel as one plane each, through an OfflineAudioContext of the same width, and come back interleaved. **Small results decoded the wrong length.** `new Float32Array(buf.buffer)` discards byteOffset and byteLength, and Node pools small allocations: a 400-byte payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples decoded as 2048 samples of unrelated memory — and the empty-result guard could not see it. The reader has the mirror-image fix: a float data chunk on an odd boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now copies instead of throwing RangeError on an unaligned view. The tail limitation is now stated rather than mis-stated: the context is exactly as long as the input, so a reverb or delay still ringing is cut there. The old comment claimed the opposite. How far a tail may run past a clip's end changes the clip's length in the mix, so it is a product decision, not one to make here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): report an FX render failure as an audio error `processCompositionAudio` reports per-track failures in its result, but an FX failure it cannot degrade past — a browser that will not launch, a chain that will not build — rejects instead. `runAudioStage` had no try, so that rejection escaped to the orchestrator as an unclassified pipeline exception, losing the stage/owner/retryable classification this stage exists to attach, and skipping its abort check on the way out. It now lands in `audioError` alongside every other cause, while an abort still keeps its own shape rather than being reported as an audio problem. Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh clone typechecks packages/engine without building first. The bundle is built from the stub, and the stub changes three times across this stack — so the artifact differs per branch and would conflict on every restack. Its model, position-edits-render-inline.ts, is committed only because it is stable. Building before testing is this monorepo's existing contract (studio's tests need core's dist too), so the gap is not specific to audio FX and is better closed by a build ordering gate than by committing a per-branch artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(engine): skip the browser FX render cases when there is no browser CI's `Test` job was red on this PR with four failures, all the same cause: Failed to launch the browser process: spawn /home/runner/.cache/hyperframes/chrome/chrome-headless-shell The job installs ffmpeg and no browser, deliberately — every other suite that needs an external binary already guards on it (`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming a Chrome, so they failed on an absent dependency rather than on anything about the code. Guards on `resolveHeadlessShellPath()` — the same resolver `acquireBrowser` launches through, so the check cannot drift from the thing it guards the way a hard-coded cache path would. A configured path that does not exist throws; that is caught and read as "cannot run here". Checked both directions rather than just the green one: with a browser all 11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a missing binary exactly 3 skip and the other 8 still run. A guard that silently skipped everything would have looked identical in CI. They keep their value where it exists — every developer machine, and any job that has run `hyperframes browser ensure`. Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five days and several force-pushes stale. None of the 17 open repo alerts are in files this PR changes; it re-runs on this push. * chore(engine): suppress the temp-file alert with the reason it is safe CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file (high) — the one new alert on #3021, and the reason its CodeQL check is red. It is a false positive, and the comment says why rather than just silencing it: `path` is always inside a directory made by `mkdtempSync`, never a name assembled directly under `tmpdir()`. Both callers are covered — the browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`, and the render output goes to the producer work dir, itself `mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the random suffix and creates the directory 0700 in one syscall, so the predictable filename inside it cannot be pre-created or symlinked by another user, which is the attack the rule is about. The analyzer sees the dataflow reach `tmpdir()` and not the mkdtemp in between. Suppressed inline rather than dismissed in the UI, so the justification lives next to the code and the rule stays live for anything added later in this file. Matches the repo's existing convention — `planV2.ts:222` carries an `lgtm[js/insecure-temporary-file]` for a different reason on the same rule. Correcting myself: I first reported this alert as not real, having intersected the PR's files against the default-branch alert list, which does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns it straight away. * test(engine): probe ffmpeg and Chrome instead of assuming them Two failures on #3021's Test job, both about the environment rather than the code under test. **Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to `execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()` resolves — every other ffmpeg-dependent suite in this package already goes through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)` so a contributor without ffmpeg skips rather than fails. **The browser guard trusted the wrong thing.** It asked `resolveHeadlessShellPath()` and treated a returned path as "a browser is here". CI's cache holds a chrome-headless-shell that resolves and then fails to spawn — a partial download is indistinguishable from a working one by `existsSync`, which is all that resolver checks. So the three browser cases ran anyway and failed on the launch. It now runs `--version` and requires exit 0, which is the same probe the ffmpeg suites use: ask the binary, do not infer from the filesystem. Checked both directions rather than just the green one. With a working browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed at a binary that exits non-zero — CI's exact situation — exactly 3 skip and the other 8 still run. A guard that quietly skipped everything would have looked identical on the CI summary. * feat(core): register the audio-fx-rack canary at 0% Lands the rollout switch dark, per the registry's own procedure: "Start at percentage: 0 and merge that — a canary at 0 is dead code you can land safely and ramp without a code review." Declared at the bottom of the stack so every branch above can read it. The gate itself goes in at wa-4-fx-panel, where the rack first appears. Scope is deliberate and stated in the description: it gates the AUTHORING surface only. A composition that already carries `data-fx-chain` still plays and renders it. A canary should stage who can REACH a feature, not make an attribute somebody already wrote silently inert — an agent that writes a chain through the skill would otherwise produce a file whose audio processing vanishes with no error. * feat(studio): audio FX panel generated from the registry Controls for the whole chain: add, remove, reorder, bypass, and every knob each effect declares. Nothing in the panel knows what a compressor is. The registry supplies each parameter's range, step, unit and scale and the panel renders what it finds, so adding an effect or a knob upstream needs no change here, and the panel cannot offer a value the renderer would reject — a typed-in figure is clamped into the declared range on the way through. Frequency and time controls span three or four decades, so those declare a log scale and the slider maps exponentially; a linear slider would spend most of its travel somewhere useless. Reorder is a first-class control because chain order changes the sound: a reverb before a compressor is not the same as after. Carve gets its own block rather than an entry in the add menu, with a picker for the voice track to listen to. It processes this track based on another one, which is how a sidechain control works — it lives on the track that changes, and names the source. * feat(studio): show the Audio FX section on audio tracks Adds `audioFx` to the editing-affordances contract and renders the FX panel in the inspector when an `<audio>` element is selected. The section is audio-only. A `<video>` carries its sound on a separate `<audio>` element, so an FX chain on the video would have nothing to process. Chain and carve settings are written straight back onto the element as serialised attributes, the way colour grading carries its config, so persistence is an ordinary attribute write and needs no new server route. A chain that cannot be parsed renders as empty rather than breaking the panel, and the attribute is left untouched until the user changes something. The collapsed group summarises what is on the track ("2 effects + carve") so the state is visible without expanding it. Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED defaults to true, so the flat inspector is what actually renders. * refactor(studio): lift audioFxSummary out of PropertyPanelFlat `PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap, so the required File size check is red — the sole reason this PR is blocked. The review says as much: "mechanical fix (~5 min), not a design problem. Code itself is LGTM." Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later branch creates for it. Deliberately the smallest cut that clears the cap rather than the whole `AudioFxGroup` extraction: every later commit in the stack edits AudioFxGroup, so moving it here would collide with each of them, while almost nothing touches this function. 595 lines. * feat(core,studio): hear the FX chain in preview, and run the carve analysis Splices an element's FX chain into the playback graph so preview stops being silent about effects, and wires the carve button that was previously inert. The chain goes between the decoded source and its gain stage: effects see the raw signal and volume automation rides on their output, matching the order the offline render uses. Since preview and render call the same graph builders, what is heard while scrubbing is what gets written. The splice lives in the transport rather than on the `<audio>` element. The transport plays each track from a decoded AudioBuffer and mutes the element to avoid doubling, so capturing the element with createMediaElementSource would have processed a stream nothing is listening to — it looked like it worked because the call succeeded, and the audio was unchanged. A chain that cannot be built plays dry rather than silencing the track, which is the right failure in preview: the author keeps working and hears the source. The render still refuses, because shipping the dry signal there would be wrong. Carve now analyses for real: it decodes the chosen voice track, ranks its bands and writes the resulting peaking filters onto this track. Generated nodes are tagged `fromCarve`, so re-running replaces the previous carve instead of stacking another set on top of hand-added effects. Known limitation: the graph is built when a source is scheduled, so a knob turned mid-playback takes effect on the next play or seek rather than immediately. Live re-parameterisation needs the transport to hold the handle and forward updates. * fix(studio,core): stop parameter drags from restarting playback Dragging a knob wrote the chain through the persisting attribute path on every input event. That path refreshes the preview, which reloads the composition and reschedules audio — so a single drag reloaded dozens of times and playback stuttered the whole way. Drags now go through `onSetAttributeLive`, the same path colour grading uses for scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens. The persisting write fires once, when the gesture ends — pointer-up or blur for a slider, Enter or blur for a typed value. A select commits immediately since there is no drag to wait for. While dragging, the control is driven from local state. Waiting for the value to round-trip through the element attribute made the knob lag behind the pointer. For the change to be audible without a reload, the graph now follows the attribute: the chain installed by the transport observes the element and re-parameterises itself in place, so a value change lands on the next 128-sample quantum. A shape change (effect added, bypassed, pole count) cannot be patched into a running graph, so it still waits for the next schedule rather than cutting the audio mid-play. The regression test drags a slider through several values and asserts the persisting handler is untouched until release. * feat(studio): put the audio FX rack behind its canary Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered at 0% — so the whole 47-PR stack can land without showing anyone a feature that has not been measured yet. The gate sits on the AUTHORING surface and nowhere else. The runtime and the render still honour a `data-fx-chain` already on an element, so a composition written through the skill or by `carve.mjs` keeps its processing rather than going silently dry for anyone outside the cohort. A canary should stage who can REACH a feature, not make an attribute somebody already wrote stop working with no error. Gated at the panel rather than in `resolveEditingSections`: the affordance resolver is a pure function in core describing what an element CAN support, and rollout state is not a property of an `<audio>` tag. Pinned the 0% with a test, and checked it fails at 25 — a ramp should have to break something that says "this ships dark" out loud. One gap, stated rather than papered over: the gate itself has no unit test. I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness never renders the Audio FX group for its audio fixture even with the gate removed — so the test passed for the wrong reason in the off case and could not pass at all in the on case. A test that cannot fail for the right reason is worse than none. Verifying the gate needs the panel harness to mount that section first, which is its own change. * fix(core): register FX worklets before building nodes that need them An AudioWorkletNode cannot be constructed before its processor is registered — it throws, and the surrounding chain is lost with it. `attachElementFxChain` built the chain first and only then called `ensureAudioFxWorklets`, so every worklet-backed effect (compressor, limiter, gate, bitcrush) threw on construction and the track fell back to dry. Instrumenting the preview showed `hf-compressor: InvalidStateError` with addModule never called at all. When the module has not landed yet the track now plays dry and the graph is swapped in once registration resolves, so the effect arrives a moment late instead of never. Registration is also tracked per context rather than in one module-level promise. A processor registered on one AudioContext does not exist on another, so the shared promise made every context after the first believe it was ready when it was not — the studio's transport owns its own context, which is exactly that case. With the worklets actually running, the compressor's per-sample log10 and pow became real audio-thread work. Samples below the knee have a gain of exactly unity and need neither, so the envelope is now compared in the linear domain and the transcendentals only run for samples that are actually being compressed. * refactor(studio): split the FX node row out of FxSection Clears the health findings the FX stack left behind: the chain-node render callback was a 70-line closure over half of FxSection's state, and the two reorder arrows were the same button written twice. Also drops two exports with no consumers, and registers the audio FX runtime stub as an entry point — it is bundled by file path, so nothing imports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): automation envelope model for audio tracks Adds the data model behind Ableton-style automation lanes: breakpoint envelopes over track volume or one knob of one effect in the track's FX chain, stored on the element as `data-automation`. Times are clip-local, so an envelope travels with the clip when it moves — the clip-envelope model rather than arrangement automation. `sampleAutomationLane` is the single interpolator. The lane drawing, the preview scheduler and the render bake all call it, so the picture and the sound cannot disagree about the curve. Log-scaled parameters interpolate in log space, matching what their own knob already promises. FX nodes gain a stable `id`, minted by count rather than randomly so the document is the same on every machine. Lanes address nodes by id, so reordering a chain never re-points a lane at a different effect, and a lane whose effect was deleted is dropped rather than left to reattach. Also warns when a track carries both a volume lane and a GSAP volume tween, since only the lane is heard and the tween silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(studio): lift the audio FX group out of PropertyPanelFlat `PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so the required File size check was red — the sole reason #3014 and #3022 are blocked. Both reviews say the same thing: "mechanical fix, not a design problem. Code itself is LGTM." Moves `AudioFxGroup` and `audioFxSummary` into `propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them anyway — done here so the file is under the cap from the point it first crosses it, rather than ten branches later. 533 lines now. The four audio imports it no longer needs go with it. Not fixed here: three `FxSection carve` tests fail on this branch with "Cannot read properties of undefined (reading 'toFixed')". Confirmed pre-existing by stashing this change and re-running — that is the separate `Test` failure the review also flags. * feat(core): expose the AudioParams behind automatable FX knobs Marks the knobs an automation lane can drive and has each graph builder hand back the AudioParam behind them, so a scheduler can write to a running effect without knowing what the effect is. A knob is not always one AudioParam. A wet/dry mix is two gains moving in opposition, and a knob in milliseconds drives a delay time in seconds, so each target carries the mapping out of the knob's own declared unit. What stays unautomatable is stated where it is decided: a WaveShaper curve, a convolution impulse and a one-pole filter's coefficients are all rebuilt wholesale rather than scheduled, and the four worklet effects take values by postMessage rather than through AudioParams. The registry flag is written by hand, so a test builds every effect and checks the exposure both ways — nothing flagged is missing, nothing exposed is unflagged. A flag that lied would offer a lane that silently did nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): play automation envelopes in preview Schedules each lane onto the AudioParams behind its knob using native ramps and value curves. Nothing evaluates the envelope per frame: it is handed to the audio thread once, so it stays sample-accurate however busy the main thread is, and the offline render will schedule it the same way. Timing comes from the transport, so an envelope survives seeking into the middle of a clip, a clip that has not started yet, and a playback rate that compresses clip seconds into context seconds. A straight line is only scheduled as a ramp when nothing bends it — no curvature, a linear parameter scale, and no unit mapping. Log-scaled parameters and mapped ones are sampled instead, since a delay knob in milliseconds and a wet/dry pair moving in opposition are not linear in the parameter they drive. Lanes with nowhere to write are skipped rather than reported: a one-pole filter exposes no frequency param, and the worklet effects expose none at all. Editing an envelope mid-playback re-aims it at the live playhead rather than restarting the track. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): make the volume lane audible in preview The envelope was scheduled onto the transport's gain AudioParam, but the runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked value — so it was erased within a frame. Volume automation was correct in the render and inaudible while previewing. The lane now feeds the per-tick path where the probed volume keyframes already sit, checked ahead of them so the two cannot fight, and the transport no longer schedules volume at all: one mechanism instead of two racing. The cost is honest — in preview the level steps per tick rather than per sample, exactly as the existing keyframe path does. The render still bakes it into the PCM sample-accurately, and FX parameters are still scheduled on their own AudioParams, since nothing rewrites those. Parsed lanes are cached by attribute text: the runtime asks once per tick per track, and parsing there would run the JSON parser 60 times a second for a value that only changes on an edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(engine): bake automation envelopes into the render The offline render schedules FX lanes with the same scheduler preview uses, inside the OfflineAudioContext that already runs the same graph builders. The input WAV is the clip's own audio from its first sample, so clip-local time is offline time and the envelope needs no offset. Volume lanes take the existing PCM bake rather than a second mechanism: the lane is converted to keyframes, so a straight fade stays two of them and only a bent segment is sampled — the baker interpolates linearly and would otherwise quietly straighten the curve. A volume lane supersedes keyframes probed from the timeline, which `lint` already warns about. A browser test sweeps a lowpass from below a 2 kHz tone to well above it and measures both ends. Parsing the envelope is not the same as scheduling it, and only running the real thing tells the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): apply chain edits to the running graph A structural edit — an effect added, removed, bypassed, or a filter's pole count switched — was dropped. `buildFxChain`'s update reports false when the change is not merely new values, and the attribute observer ignored that, so the edit only took hold when the persisting write reloaded the composition. That reload restarted every playing track, which is what was heard as the audio chopping. The graph is now swapped in place: the old effects are detached, the new ones built and connected between the same source and gain, and any lanes re-scheduled onto the new nodes. The source node is never touched, so playback does not restart. A track with no chain is watched too, rather than wired through and forgotten, so adding its first effect is heard the same way. That means the function always returns a disposer instead of null for the empty case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): drop the FX panel's dead __testables export Fallow audit flagged it — no test imports the module. * fix(core,studio): clear the remaining Fallow audit findings on the FX panel - Split FxSection's per-node row into FxNodeRow + FxNodeControls so the CRAP score (31.6, threshold 30) splits across two smaller units instead of moving wholesale with one extraction. - Dedupe the repeated "open the add menu, read its items" block in propertyPanelFxSection.test.tsx into openAddMenuItems(). - Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into one build-inline-artifact.ts, config-selected by CLI arg — the two scripts were a byte-for-byte clone save for names. - Exempt canary.test.ts's rawFnv (a deliberate independent reimplementation used to cross-check canaryBucket, per its own docstring) and the property-panel test files' shared renderInto/mount scaffolding (pre-existing across 9 files, 2 outside this stack) in .fallowrc.jsonc, consistent with this file's existing exemptions for the same class of intentional/pre-existing duplication. * fix(ci): allowlist the build-script consolidation in the no-main-deletions guard build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into build-inline-artifact.ts to kill a fallow duplication finding; the deletion guard flagged that as an accidental loss since main still has both originals. * fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo Both effect builders set wet.gain to the mix and dry.gain to its complement in identical two-line blocks; fallow kept re-flagging it as a 10-line clone on every unrelated change. Extracted setWetDryMix. * fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge An earlier merge with main brought this deleted file back (git's merge/delete handling on an unchanged-on-one-side file); package.json already points at build-inline-artifact.ts, so it sat unreachable and duplicating that file's config, both of which fallow flagged. * fix(studio): pull TimelineLanes under the 600-line cap TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer gestures (resize-start, pointer-down move-arm, click/razor-split) into createClipGestureHandlers — one factory call per rendered clip instead of ~120 lines of inline handler bodies in the render loop. 529 lines now. * fix(studio): split the extracted pointerdown handler under the CRAP threshold Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts concentrated it into two functions fallow flagged (onPointerDown at CRAP 63.6, onResizeStart at 31.6). Split the decision logic (which gesture a pointerdown implies) into a pure resolvePointerDownAction, then split its own intent-blocking check into isIntentBlocked. onResizeStart's guard moved into canStartResize. Every function now scores under 30. * fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the stack removed the last use of the type here without removing the import. * fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a preview-only commit that useDomEditAttributeCommits.ts never grew — backported that option support from its own later commit so the two sides of the API agree. The paste path and its tests were missing the box selection's v0/v1 bounds a sibling commit added to AutomationSelection. The FX panel's carve controls still edited the six mechanism numbers (maxCutDb, bands, intelligibilityBias) after carveProfile() collapsed authoring to one Strength knob, so those fields no longer existed on HfCarveSettings; UI now edits strength, and analyseCarveBands is called with carveProfile(strength). Also closes fallow's complexity, dead-code and duplication findings on this PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math) and useAutomationRangeDrag.ts (the marquee-select gesture) out of useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a resolver into named functions, dropped an export nothing outside its file used, and shared a step-simplifier between audioCarve's two envelope builders. The edge-stretch vs. box-select priority test in TimelineAutomationLane.test was still pinning the pre-box-select rule (edge wins over a point sitting on it) that a sibling commit deliberately reversed — a point inside the box is now selected content, so grabbing it drags the group instead. Updated the test to the shipped rule instead of the old one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): cap the via conic's weight so an edge-clamped via point can't NaN A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to (0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0. viaConic divided by that zero to get an infinite weight, and shapeVia turned Infinity into NaN a few steps later (Infinity - Infinity in the quadratic coefficient). NaN reaching setValueCurveAtTime silences the automated parameter for the rest of the render. Capped the weight at 1e6 instead of leaving it unbounded — past that point the arc already reads as touching the via point, so nothing visible is lost. Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`, since NaN fails the original comparison and fell through it. Review by Miga (PR #3208). * fix(studio-server): fingerprint the proactive waveform cache key too The route already keys the waveform cache on the asset's size and mtime as well as its path, so a rebuilt-in-place file gets fresh peaks instead of stale ones. generateWaveformCache — the proactive path that runs on upload — still called buildWaveformCacheKey with the path alone, so it wrote to a different key than the route reads from (making the pre-generated cache never found) and kept the exact collision bug this fingerprint exists to fix on its own path. Review by Miga (PR #3211). * style(docs): run oxfmt on the /hyperframes-audio skill docs Table column widths had drifted out of alignment with oxfmt's own rules, failing format:check and blocking the Preflight gate every downstream branch inherits. Whitespace only, no content change. * fix(studio): widen PropertyPanel's resetModules render timeout again The 20s margin (already once widened for the same reason) is timing out in CI's full-monorepo Test run — the resetModules()+fresh-import render this test needs is uncached and competes with every other package's test suite for the same worker pool, and the same test passes in well under 2s standalone. Went to 45s rather than re-tuning to whatever number happens to clear the current CI load, since that number moves every time CI gains a package. * feat(core): carve against every voice over a bed, always (#3212) * feat(core): carve against every voice over a bed, always dynamically A bed usually runs under a whole sequence — a narrator, an interview answer, a second presenter — and carving against one of them left the others fighting it. `source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's clock before anything is measured. That is what keeps one analysis sufficient: the chain is fixed, so there is no per-voice filter to switch between, and bands drawn from all the speech there is with envelopes that rise wherever any of it happens answer the actual question — where and when is speech masking this bed. Summed rather than averaged: two people talking at once mask more than either alone. Audio before the bed starts is dropped rather than folded in at zero, since it plays over nothing and shifting it would put a cut where there is no voice. `dynamic` is gone. A fixed depth thins the bed through every pause, and once both have been heard there is no reason to want it, so every carve follows the speech. Two helpers the panel and the headless script now share instead of each carrying a copy — two definitions of "what does this name suggest" drift, and then the two disagree about which track is the voice: - `classifyAudioName` reads a track's kind from its id and filename together. `unknown` is deliberately common: treating an unrecognised name as "not a voice" would hide the one track somebody needs to pick. - `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten duration counts as unbounded, not zero — refusing a clip whose length the composition leaves to the media would drop the commonest case there is. Files written before this still load: a single `source` reads as a one-voice list, a stored `dynamic` is ignored, and an absent attribute means the defaults whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration \b treats `_` as a word character, so \bbed\b never matched bed_01, music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap — an underscore-separated bed classified as "unknown" and could end up offered as its own carve source. Replaced the short hints with a boundary that actually excludes letters and digits on both sides. clipsOverlap computed end = start + duration without guarding sign, so a negative duration put end before start — an interval that does not describe anything, and one specific case showed it silently dropping a real overlap (a shorter, earlier broken end rejected a clip that genuinely contained the point). Duration clamps to zero instead: a clip cannot un-play time, and a zero-length clip at its start is the sane reading of "duration nobody wrote down as positive." Review by Miga (PR #3212). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(studio): port the carve UI off the removed source/dynamic fields #3212 (accidentally squash-merged into this branch instead of main) changed HfCarveSettings from a single `source` + `dynamic` toggle to a `sources` list with dynamic mode removed outright — the multi-voice UI consumer that goes with that shape lands in the very next PR, so this branch was left with a type that no longer matched its own code. Minimal port, not the multi-voice redesign that PR does properly: the "Listen to" picker and analyse() treat sources[0] as the one voice this UI still understands, and every dynamic-mode branch (the automated envelope lanes, the toggle, the checkbox) is gone along with the field — a carve is now always the static value the analysis computes, matching what the type change made permanent. Test suite trimmed the same way: the automation-lane and toggle tests covered behavior that no longer exists. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
08934bfd55 |
revert(cli): keep HeyGen API traffic on stable prod (#3202)
* Revert "fix(cli): route HeyGen API calls through canary (#3201)"
This reverts commit
|
||
|
|
5545521556 | fix(cli): route HeyGen API calls through canary (#3201) | ||
|
|
db3de4c1bf |
fix(cli): follow every redirect a host may answer with (#3148)
* fix(cli): follow every redirect a host may answer with downloadFile handled 301 and 302 and passed the Location header straight back as a request target. Hosts answer with relative locations far more often than that assumed, and 303, 307 and 308 are all reachable, so a CDN handoff failed on a URL that was never a URL. Locations now resolve against the URL that sent them, the code set covers all five, and a hop cap ends a redirect loop rather than recursing. Keeps mains idle-response test, which asserts the request timeout fires and clears the partial file. An earlier version of this branch replaced the file wholesale and lost it, leaving downloadFile with no test that calls it at all. * fix(cli): bound and isolate model downloads |
||
|
|
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. |
||
|
|
ed3ff98ce0 |
fix(cli): stop skills update deleting skills the manifest never covered (#3118)
`hyperframes skills update` deleted skills that the same command had just installed, from every agent directory on the machine, and reported them as "no longer published". `skills add --skill '*'` installs every skill in the repo — including the repo-native ones under `.claude/skills/` and `.agents/skills/` — and the upstream lock attributes all of them to `heygen-com/hyperframes`. The published manifest is generated from `<repoRoot>/skills` only (gen-skills-manifest.ts), so it never lists those. detectRemoved read that silence as "removed upstream" and pruned them, so `check || update` could not converge: `add` reinstalled them and the next `update` deleted them again. Scope removed-detection to skills the manifest is actually authoritative for, using the lock's `skillPath` — the only field that separates a skill installed from `skills/` from one installed out of the same repo's other skill roots (`source` is identical for both). An entry with no `skillPath` is treated as not covered: this is a delete path, so unknown provenance fails safe. Also resolve the prune's manifest canonically. Its notion of "still published" could otherwise come from any `skills-manifest.json` within 16 parent directories of cwd, which — since HyperFrames' own manifest declares `source: heygen-com/hyperframes` — matches lock attribution and drives deletion. The install-side check already did this (#2176); the deleting path did not, and the comment claiming that was deliberate and "tested separately" had no such test. An explicit `--source` still wins. Verified end to end against the real CLI in a sandboxed HOME. Before: `add` installed 25 skills, `update` printed "Removing 6 skill(s) no longer published: captions-overlay, changelog-video, cut-the-curve, motion-doctrine, oversized-cursor, seam-craft" and deleted all six (27 dirs -> 21). After: no removal line, 27 -> 27. Both new regression tests fail on the pre-fix source. Fixes #3111 |
||
|
|
dd629697d3 | fix(cli): harden publish retry behavior | ||
|
|
f2d6ce3245 | fix(cli): recover transient publish failures | ||
|
|
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> |
||
|
|
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. |
||
|
|
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 |
||
|
|
fdc5932897 |
fix(cli): honor check navigation timeout (#2860)
* fix(cli): honor check navigation timeout * test(cli): clarify diagnostic timeout precedence |
||
|
|
3a7950fd63 |
feat(check): add data-layout-allow-caption-zone waiver (#2853)
* feat(check): add data-layout-allow-caption-zone waiver Opt intentional lower-third copy out of caption_zone_collision. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(check): address caption-zone waiver review nits Co-authored-by: Cursor <cursoragent@cursor.com> * docs(skills): document caption-zone waiver on CLI agent path Co-authored-by: Cursor <cursoragent@cursor.com> * docs(cli): document caption-zone waiver under check, not inspect Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
880021411d |
fix(cli): make telemetry opt-out durable (#2852)
* fix(cli): make telemetry opt-out durable * fix(cli): make telemetry status trustworthy |
||
|
|
d287e5244c |
fix(cli): persist authoring skill in hyperframes.json for durable render attribution (#2762)
* fix(cli): persist authoring skill in hyperframes.json for durable render attribution authoring_skill was stamped only on the first render through a workflow passing --skill, so re-renders, `npm run render`, --batch, existing-project renders, and general-video lost it — leaving 77-96% of real-human render volume un-attributed and the skills-penetration metric misleadingly low. Persist the owning skill in hyperframes.json: `init --skill` stamps it at creation, `render` resolves the flag then falls back to the stored value, and an explicit --skill seeds it (seed-once, never overwriting the creating workflow's identity). Activate all render-producing creation workflows to declare their skill at init. Forward-only: does not rewrite historical telemetry. * fix(cli): patch hyperframes.json in place when seeding the authoring skill seedProjectAuthoringSkill is the only writer that touches an already existing hyperframes.json — every other writeProjectConfig call site is guarded to write only when the file is absent, which made the whole-file overwrite safe by construction. Round-tripping the seed through normalizeConfig broke that: it rebuilds the object from a field whitelist with no rest-spread, so any key outside the schema was silently dropped, a media block was materialized in projects that never had one, and key order was rewritten. hyperframes.json is normally committed, so a render introduced a diff the user never asked for, and any field added to the schema later would be deleted by a render on an older CLI. Parse the raw JSON, set authoringSkill, write it back, reusing the file's own indentation. Unknown keys and formatting survive; the only delta is the key being added. A corrupt config is now left untouched instead of clobbered. Seed-once semantics are unchanged, still normalized so a hand-edited garbage slug neither reaches telemetry nor wedges the seed. Reported independently by both reviewers on #2762. * fix(cli): create the docker build context with mkdtempSync The `--docker` build context was created at a guessable path derived from `Date.now()` in the world-writable OS temp dir. Another local user can pre-create or symlink that path and have the build read a Dockerfile they control. mkdtempSync gets a random suffix and 0o700 from the kernel, and it creates the directory itself, so the separate mkdirSync goes away. Pre-existing on main (alert #432, 2026-06-04, packages/cli/src/commands/render.ts), surfaced against this branch only because the seed commit shifted line numbers in the same file. Fixed here to unblock the CodeQL gate on #2762 rather than left for a follow-up; the remaining 10 js/insecure-temporary-file alerts elsewhere in the repo are untouched and still want their own pass. * fix(cli): drop the check-then-use race when seeding the authoring skill The seed tested for the config with existsSync and then wrote, which is a check-then-use race: the file can be created or swapped between the check and the write (CodeQL js/file-system-race). Read once and branch on the failure reason instead. Only ENOENT creates a config from scratch; any other read failure (permissions, I/O) now leaves an existing file alone rather than overwriting it with a default, so this is also strictly safer than the version it replaces. Also replaces the `as Record<string, unknown>` assertion with an isJsonObject type guard, per the repo's no-assertion convention. Behaviour unchanged: all 4 seed regression tests still pass, and the create/preserve/seed-once/corrupt-untouched paths were re-verified end to end. |
||
|
|
209e6e0148 |
feat(check): opt-in --layout proseCoverageFloor (#2834)
* feat(check): opt-in --layout proseCoverageFloor for text_occluded Keep the default prose coverage floor at 0.15 for all callers, and allow stricter agents (e.g. Zephyr) to lower it via --layout "proseCoverageFloor=0.05" without changing other layout gates. Co-authored-by: Cursor <cursoragent@cursor.com> * style(check): collapse --layout comments and docs to one line Co-authored-by: Cursor <cursoragent@cursor.com> * fix(check): strict proseCoverageFloor parse + pin 0.07 floor tests Reject trailing-garbage fractions that Number.parseFloat would accept, and pin the existing ~0.07 coverage fixture for default vs floor=0.05 (atomic labels unchanged) plus a collectLayout forwarding assertion. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(check): share parseNumberStrict across layout and frame-check Sweep the sibling --frame-check tol parser (and caption fractions) onto the same strict Number() helper so trailing garbage cannot prefix-parse. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
0bf33cb117 | fix(cli): await stalled download cleanup | ||
|
|
75ed99e1d4 | fix(check): elongated pivot drift + counterfactual connector_detached (#2819) | ||
|
|
72e2f08f15 |
feat(lint): dense motion re-sampling for content_overlap (#2746)
* feat(lint): dense motion re-sampling for content_overlap Transient text-on-text collisions during continuous motion (e.g. an orbiting label card crossing the center card) overlap for a fraction of a second that the sparse 9-point layout grid seeks straight past. The content_overlap detector is correct; it just never gets a sample at the crossing moment. Rerun ONLY content_overlap on an 8fps grid (text-only, cheap) when the composition animates; findings feed the existing persistence tiering unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lint): unconditional dense content_overlap pass + honor 500ms floor Round-1 blocker: the dense motion-overlap re-pass was gated on sparse-grid geometry fingerprints changing, so an animation aliased to the sparse grid (identical fingerprints, yet colliding between samples) bypassed the pass — exactly the transient false-negative it was built to catch. Remove the gate: the dense pass now runs unconditionally (bounded, text-only), driven by the composition timeline rather than a fingerprint heuristic. Round-2 follow-ups: - Persistence-tier drift: at 8fps, occurrences>=2 spans only ~125ms, not the ~500ms the design intends, and it short-circuited before the ms floor. content_overlap promotion now requires BOTH occurrences>=2 AND a literal firstSeen..lastSeen span >= 500ms, so the wall-clock floor is honored at any sampling density. Comment block updated to match. - Sample cap scales to hold a true 8fps grid up to ~75s (raised 120 -> 600) with an explicit note that longer comps degrade below 8fps to stay bounded. Tests: - Replaced the trivial "warning at every sample" test with a real between-grid regression: a collision living only inside (3.5,4.5) — a gap the sparse grid seeks past — is detected and, held ~750ms, promoted to error. - Replaced the now-invalid "skips when static" test with one asserting the dense pass runs even when sparse fingerprints are identical (aliased motion). - Added a tiering regression: two dense occurrences spanning ~125ms stay a warning (not error). Both new guards verified red before the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(lint): settle-free geometry seek for dense content_overlap pass The dense overlap re-pass did up to OVERLAP_MAX_SAMPLES full-settle seeks (120ms paint settle each, ~72s of pure sleep at the ceiling) even though collectOverlap only reads getBoundingClientRect geometry, valid synchronously after the timeline setTime. Add a settle-free DENSE_GEOMETRY_SEEK_OPTIONS + driver.seekGeometry used only by the dense loop; the base grid keeps full-settle driver.seek. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(lint): document + cover content_overlap 500ms boundary for sparse callers The occurrences>=2 AND heldMs>=500 promotion rule is a semantics change for sparse callers (--samples 20, --at, short comps) whose two samples can land <500ms apart. Document the change in the tiering comment and add boundary tests: 499ms span stays warning, 500ms span promotes to error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(lint): make dense content_overlap seek genuinely geometry-only Per review: DENSE_GEOMETRY_SEEK_OPTIONS only overrode settleMs, still inheriting animationFrameSettle:double + waitForFontsMs:500 → ~3 frame waits + font wait per seek → ~30s at the 600-sample cap. Geometry (getBoundingClientRect) is valid synchronously post-setTime, so drop all post-seek waits (animationFrameSettle:none, waitForFontsMs:0, settleMs:0). Add options-level regression locking the geometry-only contract. Also fix a stale comment name (detectMotionTextOverlap → collectMotionOverlapSamples). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: collapse multi-line comments to single lines Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e710a1686f |
feat(lint): add off_pivot_rotation hub-referenced layout check (#2744)
## What it catches A gauge needle / clock hand / dial pointer / radar sweep that rotates about the **wrong pivot** — the recovered center-of-rotation sits far from the dial hub (e.g. `transform-origin` at the needle base or SVG element edge instead of the dial center). Visually the needle "wobbles" or orbits off-axis instead of sweeping cleanly about the hub. This is a genuine gap in the current checks: `rotation_pivot_drift` (#2741) provably **cannot** catch it — a correct sweeping needle's bbox-center orbits identically to a broken one, so only a **dial-hub reference** distinguishes them. This is the separate hub-referenced check that analysis called for. ## How it works - Sampler maps 2 material endpoints per frame via `getScreenCTM` (honors the actual rendered transform, independent of `svgOrigin`). - Resolves the dial hub = shared center of the modal set of static concentric circles, or the arc-center of the largest static near-circular path (Kasa circle fit). - Fits a circle to the endpoint trajectory to recover the true center-of-rotation; flags drift `> 0.35 * pointer_length`. One warning per hub. - Never fires without a resolvable hub. Walks the rotation reference to the composition root (not the `<svg>`) so a pointer rotated by a `div` ancestor is measured correctly. - Multi-body guard: `>= 2` bodies at distinct angular positions on one hub = orbit/atom system, not a dial → suppressed. ## Corpus evidence (autonomous geometry-fuzz run, 81 fuzzed diagrams) - **7 / 7 true positives, 0 false positives across all 81 samples.** - Assigned TPs: fuzz005, fuzz017, fuzz032. Bonus TPs: fuzz044, fuzz056, fuzz068, fuzz080. - **The Gemini-3.6 video-judge itself MISSED all 4 bonus TPs** (`vlm_has_defects: false`) — the deterministic hub-reference check beats the VLM on this defect class. - FPs driven to 0 by the two principled guards above: fuzz016 (planet arc rotated by a `div` ancestor) cleared by root-walk; fuzz055 (atom) cleared by the multi-body guard. - fuzz080 reads as a false positive to the connector check but is a true positive here — confirms the architectural boundary between the two checks is drawn correctly. ## Validation - Autonomous Gemini-3.6 **video**-judge fuzz run to surface candidate defects, then a **deterministic FP sweep** across all 81 rendered compositions (not VLM-gated — code inspection is the arbiter, since the VLM both over- and under-calls this class). - 9 unit tests (`checkPipeline.offPivotRotation.test.ts`) + full check suite pass; `bun run build` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
222aec45ab |
feat(lint): add rotation_pivot_drift layout check (#2741)
## What New cross-sample layout check `rotation_pivot_drift` — flags a rotating element that should spin **in place** but pivots about the **wrong point** (e.g. a wheel whose spokes use a hardcoded px `transformOrigin` instead of `svgOrigin`/`%`, so they swing off-center while every existing check still passes). Motivating prod case: a portrait ad's spoked-wheel whose `#spokes` rotated about `transformOrigin:"250px 250px"` in a resized 460px container — spokes detached from the hub, shipped clean because no rule inspects rotation. ## How - `layout-audit.browser.js`: `window.__hyperframesRotationSample()` reports each visible transformed element's bbox center + decoded rotation angle per layout sample. Skips `[data-layout-allow-orbit]`. - `checkPipeline.ts`: accumulates samples across the seek grid; `detectRotationPivotDrift()` (modeled on `detectSweepStatic`) flags an element that (a) actually spins (angle spread > 20° over ≥3 samples), (b) is size-stable (bbox width ratio ≤ 1.6), and (c) whose bbox **center** drifts > `max(10% of its size, 2% of min viewport dim)`. Emits `warning`; not persistence-tiered (not demoted to info). ## FP guards Real rotation required, ≥3 samples, size stability, `data-layout-allow-orbit` exemption, min area ~2500px². Center-drift (not bbox size) is the discriminator, so a correctly-centered spinner reads drift ≈ 0. ## Validation (`check --json`) | Fixture | Expected | Result | |---|---|---| | off-transformOrigin spoked wheel | fire | **fired — 109px drift on `#spokes`** | | non-spinning comps (node diagram, device tree) | clean | clean, no FP | | correctly-centered spinner (`svgOrigin`) | clean | clean (spins 162°, drift 0) | | `data-layout-allow-orbit` off-origin spinner | clean | clean (exempt) | | off-`svgOrigin` control, no opt-out | fire | fired — 251px drift | No false positives. `tsc --noEmit` clean, `oxlint` clean, `check.test.ts` + `layout-audit.browser.test.ts` = 112/112 pass. ## Note `ROTATION_MAX_SIZE_RATIO` is 1.6 (not 1.3): a rotating anisotropic shape's axis-aligned bbox inherently oscillates (8-spoke star ~1.32×, square 1.41×), so a tighter ratio rejects legitimate targets. Center-drift stays the real discriminator; thin swinging bars are excluded. Follow-up: a `detectRotationPivotDrift` unit test via the fake driver's `collectRotationSample` (mirroring the sweep_static tests). 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
73d3b4e1f4 | fix(cli): report command failures once | ||
|
|
e0bda7a171 | fix(cli): keep post-render exit reset root-owned | ||
|
|
3aa2404747 | refactor(cli): centralize process lifecycle | ||
|
|
e64a22893a |
fix(studio): preserve alpha proxy playback (#2625)
* fix(studio): preserve alpha proxy playback * test(cli): pin VP8 alpha proxy pre-resolution * fix(studio): emit textarea field-sizing CSS * test(studio): verify textarea CSS generation |
||
|
|
e73304fb0e | feat(cli): make cloud archives size-aware | ||
|
|
65f2e2927c | refactor(producer): unify render requests | ||
|
|
8eccc6e9a3 |
Merge pull request #2599 from heygen-com/via/composition-structure-mandate
feat(skills): add COMPOSITION_STRUCTURE to feedback repro packet + soft-warn lint |
||
|
|
e8371a7acc |
feat(media): alpha-capable authoring proxies (#2598)
* feat(media): alpha-capable authoring proxies Alpha sources were refused a proxy before the codec map ever asked whether the browser could decode them, so a ProRes 4444 alpha file (which no browser previews at all) rendered black forever, while an alpha WebM (which previews fine) was already covered by the browser-safe check on the next line. The alpha veto earned nothing and cost the one case that needed help. Alpha is now a target-codec choice rather than a veto: alpha sources transcode to VP9 + yuva420p in WebM, everything else keeps the existing H.264/MP4 path byte for byte. Only files no browser can preview are proxied, which is the rule the runtime already followed everywhere else. WebM cannot carry AAC, so the VP9 path uses Opus and drops the MP4-only faststart flag. PROXY_PARAMS_VERSION moves to v3 so clients stop serving the previously cached proxies. Safari does not decode VP9 alpha and still shows black for alpha sources, as it does today: this is better on Chromium and Firefox and no worse anywhere. * fix(media): infer proxy variant for rescue * fix(media): preserve alpha proxy hardening after restack |
||
|
|
35eff5038b |
feat(cli): bake proxies into published archives (#2595)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly * feat(cli): let projects opt out of automatic proxying Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and forwards the resolved value into the studio and preview servers and the vite adapter. Lands before the runtime slice that turns auto-proxying on, so the switch exists before there is any behavior to switch off. * fix(cli): align media config schema * feat(core): swap undecodable video to its proxy at runtime Adds the browser-side half: before first load the runtime consults the injected codec map and swaps a hostile source to its proxy, and if a video still reports zero decodable width it rescues it reactively. An HEVC file carrying AAC fires no error event, so zero videoWidth, not the error event, is the reliable signal. Audio elements and alpha sources are never proxied, render mode never proxies, and each swap evicts the element's stale sync state and reports once. This completes the loop: auto-proxying is live for preview and studio from here. The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice. * feat(cli): serve proxies from play, present, and the static project server Adds proxy negotiation to the CLI-side servers and gives play byte-range serving it never had, so a swapped video can seek. The static project server behind check, snapshot, compare and friends injects the codec map once, so all of its callers inherit the behavior; snapshot forwards its own proxy flag. * fix(cli): serve proxies for camera formats * feat(cli): resolve proxies before check's timed browser phase check pre-resolves hostile assets so a cold transcode cannot exhaust the render-ready budget, and surfaces the runtime's proxy diagnostics as findings so a swap is visible rather than silent. * feat(cli): bake proxies into published archives Published pages are static, so there is no server to negotiate with: publish transcodes proxies for hostile assets into the archive and rewrites the video sources that point at them. Audio elements keep their originals, since audio decodes independently of the video codec. Splits the archive build from the zip step so publish can transform between them. cloud render keeps calling the unchanged composition and still uploads originals, which its regression test pins. * fix(cli): harden proxy pre-resolution * fix(cli): surface publish proxy outcomes * test(cli): remove ffmpeg from archive guard * test(cli): normalize publish fixture path |
||
|
|
490642b78a |
fix(feedback-lint): tighten census value scoping and defect-keyword matching
Addresses C1-C7 from Rames's adversarial review + N2/N3 nits:
- C1 (positionFixed): inline probe now value-scopes to 'fixed' — previously
fired on any position value (absolute, relative, sticky), producing a
false-positive anatomy that would mislead maintainers pattern-matching
the 'sub-comp + position:fixed capture' bug family.
- C2 (overflowHidden): symmetric fix — inline path now catches
style='overflow: hidden' via the value-scoped probe, matching the
<style>-tag branch. Also handles overflow-x/overflow-y variants.
- C3 (VISUAL_DEFECT_KEYWORDS): drop 'render' — CLI's primary command is
'hyperframes render', so build/perf/hang reports were triggering an
inappropriate COMPOSITION_STRUCTURE: nudge on the most common failure
mode. Rely on the more specific tokens (black, blank, flicker, corrupt,
wrong frame) to identify actual visual defects.
- C4 (mentionsVisualDefect): compile keywords into a word-bounded regex.
'blackboard', 'blanket', 'visualize', 'corruptible' no longer false-
positive. Accepted tradeoff: plural forms ('flickers') don't match.
- C5 (marker case-normalization): REPRO COMMAND: / COMPOSITION_STRUCTURE:
checks now case-insensitive, matching mentionsVisualDefect's
normalization. Reporters using 'Repro command:' or lowercase
'composition_structure:' get credit for compliance.
- C6 (background/mask shorthand): inline branch previously required the
longhand 'background-image:' / 'mask-image:' — style='background:
url(bg.png)' silently returned false. Now checks both longhand AND
shorthand-with-url() inline forms.
- C7 (usesGsap docstring): trim promise of data-gsap-* attribute scanning
that detectGsap never implemented — attribute scan lives outside the
<script>-only detection path.
- N2 (EMPTY_VALUES): include 'inherit', 'revert', 'revert-layer' — a
style='position: inherit' is authored intent to defer, not authored
intent to place.
- N3 (input size cap): early-exit to a zero census on HTML > 20 MB
rather than feeding linkedom a hostile input. Not expected in normal
usage; guard for future callers that might pass raw user uploads.
Extends the test locks: 6 new census tests (value-scoping, size cap) and
5 new lint tests (word-boundary rejections, render-noise rejections,
lowercase-marker acceptance). All existing tests unchanged in intent —
only the 'flickers' plural in one test updated to 'flicker' to reflect
the new word-boundary rule.
No behavior change to the wire path: lint is still soft-warn, census is
still never called from feedback.ts, no new dependencies.
|
||
|
|
0561adc11c |
feat(cli): resolve proxies before check's timed browser phase (#2594)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly * feat(cli): let projects opt out of automatic proxying Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and forwards the resolved value into the studio and preview servers and the vite adapter. Lands before the runtime slice that turns auto-proxying on, so the switch exists before there is any behavior to switch off. * fix(cli): align media config schema * feat(core): swap undecodable video to its proxy at runtime Adds the browser-side half: before first load the runtime consults the injected codec map and swaps a hostile source to its proxy, and if a video still reports zero decodable width it rescues it reactively. An HEVC file carrying AAC fires no error event, so zero videoWidth, not the error event, is the reliable signal. Audio elements and alpha sources are never proxied, render mode never proxies, and each swap evicts the element's stale sync state and reports once. This completes the loop: auto-proxying is live for preview and studio from here. The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice. * feat(cli): serve proxies from play, present, and the static project server Adds proxy negotiation to the CLI-side servers and gives play byte-range serving it never had, so a swapped video can seek. The static project server behind check, snapshot, compare and friends injects the codec map once, so all of its callers inherit the behavior; snapshot forwards its own proxy flag. * fix(cli): serve proxies for camera formats * feat(cli): resolve proxies before check's timed browser phase check pre-resolves hostile assets so a cold transcode cannot exhaust the render-ready budget, and surfaces the runtime's proxy diagnostics as findings so a swap is visible rather than silent. * fix(cli): harden proxy pre-resolution |
||
|
|
74b4f1e8c3 |
feat(cli): serve proxies from play and the static project server (#2593)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly * feat(cli): let projects opt out of automatic proxying Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and forwards the resolved value into the studio and preview servers and the vite adapter. Lands before the runtime slice that turns auto-proxying on, so the switch exists before there is any behavior to switch off. * fix(cli): align media config schema * feat(core): swap undecodable video to its proxy at runtime Adds the browser-side half: before first load the runtime consults the injected codec map and swaps a hostile source to its proxy, and if a video still reports zero decodable width it rescues it reactively. An HEVC file carrying AAC fires no error event, so zero videoWidth, not the error event, is the reliable signal. Audio elements and alpha sources are never proxied, render mode never proxies, and each swap evicts the element's stale sync state and reports once. This completes the loop: auto-proxying is live for preview and studio from here. The opt-out (media.autoProxy, --no-proxy) shipped in the previous slice. * feat(cli): serve proxies from play, present, and the static project server Adds proxy negotiation to the CLI-side servers and gives play byte-range serving it never had, so a swapped video can seek. The static project server behind check, snapshot, compare and friends injects the codec map once, so all of its callers inherit the behavior; snapshot forwards its own proxy flag. * fix(cli): serve proxies for camera formats |
||
|
|
6458807066 |
feat(cli): let projects opt out of automatic proxying (#2591)
* feat(studio-server): serve H.264 proxies from the preview route Wires the codec manifest and the transcoder into the preview surface: the route negotiates a proxy via a query param and serves it through the existing range and ETag machinery, composition HTML carries a codec map for the runtime, and hostile assets pre-warm so a first play does not wait on a cold transcode. Exposes the three subpath exports the CLI surfaces consume upstack. Drops the TEMP fallow entry added with the transcoder: it has real importers now. * fix(studio-server): publish media proxy exports * fix(parsers): scan HTML comments linearly * feat(cli): let projects opt out of automatic proxying Adds media.autoProxy to hyperframes.json plus --proxy/--no-proxy flags, and forwards the resolved value into the studio and preview servers and the vite adapter. Lands before the runtime slice that turns auto-proxying on, so the switch exists before there is any behavior to switch off. * fix(cli): align media config schema |
||
|
|
0aaac7aa30 |
feat(skills): add composition-structure block + soft-warn feedback lint
Extend the CLI feedback reproduction packet (#2498) with a fifth mandated field, `COMPOSITION_STRUCTURE:`, and enforce presence of `REPRO COMMAND:` / `COMPOSITION_STRUCTURE:` at feedback-submit time. - Skill + reference now specify `COMPOSITION_STRUCTURE:` — a privacy-preserving structural anatomy (element census + attribute presence + timeline shape + delta + defect location) — required for any rating <=7 that describes a visual defect. - `buildCompositionCensus()` + `renderCompositionCensusBlock()` auto-fill the block from composition HTML so agents don't ask the human user to hand-count `<video>` / `<img>` / sub-comp mounts. Counts + presence flags only — no file paths, no src URLs, no user text. - `hyperframes feedback` soft-warns (never blocks) when a non-10 `--comment` is missing `REPRO COMMAND:`, and when a rating-<=7 visual-defect comment is missing `COMPOSITION_STRUCTURE:`. The warning points at the auto-census helper so agents remediate themselves. - `coreSkillContent.test.ts` locks the new literal in both the skill and the reference file, following #2498's pattern. Extends #2498. Follow-up: no change to `doctorSummary` generation, no change to the feedback-submission API endpoint, no refactor of #2498's doc-content Jest test. Signed-off-by: Via |
||
|
|
75eedf5cc1 | fix(cli): time out stalled model downloads (#2415) | ||
|
|
ff26e5f2c8 |
Merge pull request #2529 from heygen-com/via/resolution-portrait-fix
fix(cli): accept portrait aspects for --resolution alias flag |
||
|
|
2d398ed274 |
fix(cli): wire aspect-agnostic resolution through cloudrun/lambda/batch + preflight recompute
Addresses R2 CHANGES_REQUESTED from Miga + Rames on PR #2529: 1. Sibling-surface gap (blocker): `hyperframes cloudrun render{,-batch}`, `hyperframes lambda render{,-batch}` all advertised the same tier-only aliases (`1080p` / `hd` / `4k` / `uhd`) but normalized them to `landscape` and never set `outputResolutionAspectAgnostic`. The distributed plumbing PR #2529 added received `undefined` from those callers, so portrait `1080p` still hit the original aspect-mismatch on Cloud Run / Lambda. Fix: introduce `resolveResolutionFlagPair` in `@hyperframes/parsers` (the single source of truth for the two-step normalize + aspect-agnostic detect) and route every distributed entrypoint through a shared `parseOutputResolutionFlag` CLI util so the alias signal now reaches `SerializableDistributedRenderConfig`. Studio Server keeps its canonical-only HTTP contract; that intent is now pinned in tests. 2. Preflight recompute (hardening): the earlier "downgrade aspect-mismatch" preflight cleared un-remapped mismatches, so IG 4:5 (non-preset aspect, no sibling) and portrait-4K comp + `--resolution 1080p` (remap + downsample) both slipped through to fail late in `resolveDeviceScaleFactor`. Now `checkRenderResolutionPreflight` computes the effective preset via `suggestMatchingPreset` (mirroring the compile stage's `adaptAspectAgnosticResolution`) and re-checks against that — only genuinely-fixable mismatches clear early. New tests pin both regressed input classes. 3. Docker forwarding boundary test (Miga's important #2): pinned `1080p` survives verbatim as `--resolution 1080p` in the Docker args so the in-container CLI can re-run `isAspectAgnosticResolutionAlias`. 4. Doc-nit (Miga): parsers/src/types.ts no longer references the nonexistent `resolveResolutionForComposition` — points at the actual remap helpers. Fallow: cloudrun.ts / lambda.ts share 390 lines of pre-existing structural symmetry (parallel AWS + GCP dispatchers), and lambda/render.ts + render-batch.ts declare parallel RenderArgs interfaces. Both re-flagged after threading the aspect-agnostic field through each surface; ignored with justification in .fallowrc.jsonc. lambda.ts's `run` and lambda/render.ts's `waitForCompletion` are pre-existing CRAP-score hotspots untouched by this PR — added under health.ignore. Co-Authored-By: Claude <noreply@anthropic.com> — Via |
||
|
|
25a04d7fc8 |
fix(cli): don't override exit code after artifact validated
The render command's post-artifact-validated cleanup (telemetry flush,
feedback prompt, worker/browser teardown, stray promise rejections) can
throw AFTER the producer has committed a valid MP4 to disk. Field signal
ts=1784169760, ts=1784171150, ts=1784172467 (all win32/x64, CLI 0.7.58,
ffmpeg=no, 1080x1920): ffprobe + visual QA confirmed the outputs are
valid, but the CLI exited 1 after the terminal "artifact validated" log
with no final error message.
Introduce a `renderSucceeded` sentinel that flips after `executeRenderJob`
(or the Docker child render) resolves cleanly. From that point on:
- Post-render steps in the render command (trackRenderMetrics,
printRenderComplete, warnIfWebmAlphaDropped, maybePromptRenderFeedback)
run through `runPostRenderStep`/`runPostRenderStepAsync` guards that
swallow throws, log a compact warning to stderr, and sanitize a stray
`process.exitCode` back to 0.
- The CLI's top-level `uncaughtException` handler logs the throw for
diagnosis but exits 0 instead of 1 when the render already succeeded.
- The CLI's `unhandledRejection` handler stops flipping `commandFailed`
(which drove the success:false telemetry field) when the render
already succeeded.
Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
— Via
|
||
|
|
7d21cc9b8a |
fix(skills,cli): close four reproduced contract gaps from the CLI feedback digest (#2476)
* fix(cli): invalidate the skills nudge cache after a successful install/update/check The passive "N skills out of date or missing" nudge reads a 24h config cache that only the background check (on non-skills commands) ever wrote. The skills commands themselves are excluded from the nudge pipeline, so a successful `skills update`/install/check never refreshed or dropped the cached verdict — the pre-install count kept printing on every other command for up to 24h. Reconcile commands now drop the cached verdict (counts + timestamp) so the next command's background check re-runs for real. The offline presence-only path deliberately keeps the cache: that run learned nothing about freshness. * fix(skills): win32-safe npx spawns in media-use + accurate whisper wording The Whisper transcribe fallback and the Kokoro local-TTS delegation both spawned a bare "npx" via execFileSync — on Windows npx is npx.cmd, which spawn cannot exec, so both paths died with `spawnSync npx ENOENT`. Route them through the skill's existing resolveSpawnCommand (node + npx-cli.js on win32, no shell:true), same as the audio engine's TTS spawns. Also corrects the "bundled with the hyperframes CLI" claim about whisper.cpp: it is resolved from PATH / installed via Homebrew / built from source with git+cmake on first use, and models download from HuggingFace — nothing whisper is shipped in the package. * feat(skills): canonical fully-silent marker + auth status exit-code docs product-launch's Step 3.1 gate said "or the project is marked silent" but nothing defined how to mark one, and audio.mjs unconditionally retrieved BGM. Define the canonical marker — `music: none` in the storyboard's top YAML block, plus no SCRIPT.md — and honor it: audio generate produces nothing (removing stale audio_meta.json, since absence is what assemble treats as silent), and `music: none` with narration keeps TTS while turning BGM off. Also documents the `auth status` exit-code contract (exit 1 while signed out is the normal offline state, not a failure) in the product-launch Step 0 note and the CLI skill's cloud reference. * fix(skills): transient-init retry for standalone animation-map and contrast-report The standalone helpers called initializeSession exactly once, so a valid modular project — whose sub-composition timelines register asynchronously — could hit the readiness deadline and die with the transient "zero duration / Runtime ready: false" diagnostic the render pipeline retries (probeStage). Add initializeSessionWithRetry to the shared package-loader (both byte-identical copies): close the crashed session and retry once with a fresh browser, gated by the engine's canonical isTransientBrowserError — now re-exported from @hyperframes/producer, with a frozen fallback pattern list for older published packages. The "Runtime ready: true" fast-fail (a genuine authoring bug) still fails without a retry. * feat(skills): extend the fully-silent marker to faceless-explainer and pr-to-video Both workflows reuse product-launch's audio model — their Step 3.1 gates carried the same undefined "marked silent" phrase, and their (intentionally identical) audio.mjs copies had the same unconditional BGM retrieve. Port the `music: none` marker handling into both copies, define the marker in their SKILL.md Step 3.1 and story-design references, and turn the copies' "intentionally identical" header claim into a byte-identity pin test so the next fix can't silently miss one of them. * test(cli): reset the prune mock explicitly instead of relying on restoreAllMocks The converge test's toHaveBeenCalledTimes(1) held only because vitest 3's vi.restoreAllMocks() clears vi.fn() call state; vitest 4 restores spies only, so the count would accumulate across tests and fail. Reset pruneOrphanedLockEntries in beforeEach like the other manifest mocks — passes under both vitest 3.2.4 (pinned) and vitest 4. * test(skills): close review findings — package-loader pin, whisper win32 parity, quoted-none Review follow-ups on #2476: - package-loader.mjs byte-identity pin (the elevated concern): the two copies now carry initializeSessionWithRetry + FALLBACK_TRANSIENT_PATTERNS, exactly the shared-logic shape a future fix could land in one copy and miss in the other — same enforcement as the audio.mjs pin. - whisper win32 call-site parity: runWhisper's npx resolution lifted into lib/npx-sync.mjs (resolveNpxInvocation, injectable params matching the localTtsGenerate idiom) with the same three-branch coverage as the Kokoro site — plus the hard-fail contract (throws actionably, since the whisper fallback has no next provider to fall through to). - quoted music: "none" pin: the vendored storyboard parser strips matching quotes at parse time (stripQuotes), so the silent marker already accepts the quoted spelling — pinned so that stays true. |
||
|
|
7382fabab9 |
fix(core): consolidate external asset and dependency preservation (#2410)
## Summary - preserve external SVG fragment references during bundling - preserve external module scripts and serve `.mjs` with a JavaScript MIME type - retain template-head stylesheets when mounting sub-compositions - add regression coverage across compiler runtime and file-server paths Consolidates and replaces #2390, #2297, and #2375. ## Verification - core compiler/runtime tests: 89 passed - producer file-server tests: 48 passed - core, producer, engine, and CLI typechecks passed - `git diff --check` |
||
|
|
9bcd279b29 | feat(cli): surface stale project pin to non-TTY agents, throttled | ||
|
|
6fec8f39ae | feat(cli): add project-pin rewrite helper |