Sync fork with upstream heygen-com/hyperframes (main → v0.7.109+) - #1
Open
TrevorLowing wants to merge 1783 commits into
Open
Sync fork with upstream heygen-com/hyperframes (main → v0.7.109+)#1TrevorLowing wants to merge 1783 commits into
TrevorLowing wants to merge 1783 commits into
Conversation
Adds a `host` block (platform, arch, cpuCount, totalMemMb, nodeVersion, gpuDisabled) to RenderPerfSummary so fleet-wide telemetry can correlate render performance with the machine it ran on — chiefly cpuCount vs the existing `workers` field (core over/under-subscription) and totalMemMb vs lowMemoryMode / single-worker collapse. Capture mode + GPU mode already surface via `observability`; this fills in the missing host facts. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1876) Drag start pauses every window.__timelines entry and records the list in data-hf-drag-paused-timelines; resumeGsapTimelines then removed the attribute and only re-seeked the player, never unpausing anything. The main timeline survives (seek-driven every frame) but play-state-driven sub-composition timelines froze permanently after any element drag, and deselecting could not recover them. Now unpauses exactly the recorded ids (never touching timelines the drag did not pause) before the player re-seek. Verified live: after a real drag on an animated element all scene timelines stay unpaused. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ExtractionPhaseBreakdown gained cachePublishFailures/cacheGcEvictions/ cacheGcBytesFreed/cacheAgedPartialsCleared; the studioRenderTelemetry test fixture was never updated, breaking Typecheck on main and every PR based on it. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…dless-shell launch crashes (#2481) * fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes Field feedback (#hyperframes-cli-feedback ts=1784116246, win32/x64, CLI 0.7.58) hit `Failed to launch the browser process ... Code: 3221225595` with no stderr. Exit code 3221225595 = 0xC0000409 = STATUS_STACK_BUFFER_OVERRUN, a Windows stack- corruption fatal from the pinned chrome-headless-shell binary. The reporter recovered by pointing HYPERFRAMES_BROWSER_PATH at system Chrome; render then used the screenshot fallback and produced the MP4 cleanly. The generic "Try --docker" hint the CLI already emits didn't name that env var, so the workaround was undiscoverable. Add a Windows-scoped launch-crash remediation sibling to `chromeLaunchRemediation` (Linux, `linuxDeps.ts`) and `wrapDownloadFailureWithBrowserPathHint` (download-time, `manager.ts` — #2443). Fresh concrete case for the #2078 lineage (closed with explicit invite to resubmit on a concrete case). - New `packages/cli/src/browser/windowsCrash.ts` — `isWindowsChromeCrashError` gates on Puppeteer's `Failed to launch the browser process` wrapper AND the specific crash code (decimal `3221225595`, hex `0xC0000409`, or symbol `STATUS_STACK_BUFFER_OVERRUN`), so unrelated Windows launch failures don't mis-fire this hint. `windowsChromeCrashRemediation` returns the actionable block scoped to win32. - `render.ts` `handleRenderError` calls it after the existing `chromeLaunchRemediation` (Linux) check; both fall through to the generic errorBox if neither matches. - Tests: 9 vitest cases covering positive matches on all three code forms, negative on Linux-shared-lib launch failures, negative on the code alone without the launch wrapper, and off-platform / non-launch short-circuits. — Via * fix(cli): fail the Windows crash branch through failCommand, not process.exit `scripts/check-cli-process-ownership.mjs` AST-walks every non-test file under `packages/cli/src` (bar `cli.ts`) and forbids direct process termination — only the CLI entrypoint owns exit. The new Windows chrome-headless-shell arm called `process.exit(1)` while both sibling arms (Linux shared-lib, macOS) and the generic fallback call `failCommand()`, so the required Lint job failed on that line and preview-regression failed downstream of its preflight. `failCommand()` carries the central failure-hook wiring, so this is the behaviour the branch already wanted.
…2759) * fix(producer): decode percent-encoded video src in HDR pre-extract (PRINFRA-349) * fix(producer): decode percent-encoded src in HDR image probe The HDR image probe still hand-rolled the path join the video probe had already delegated to resolveProjectRelativeSrc, so a percent-encoded non-ASCII `<img src>` (`图1.png` -> `%E5%9B%BE1.png`) never resolved: the image never entered nativeHdrImageIds, resolveEffectiveHdrMode saw no HDR sources, and the composition rendered through the SDR fallback with wrong color -- silently, unlike the video path which errored at ffmpeg. Both probes now call resolveProjectRelativeSrc directly, with no isAbsolute() pre-check. The resolver already returns an absolute path that exists and otherwise treats a leading slash as a browser origin-root URL, so a pre-check would hand back `/assets/%E5%9B%BE1.png` undecoded and re-open the same bug for root-relative srcs. This matches planHdrResources, so the two halves of the fix can no longer disagree. Widening resolution also makes previously-unresolvable files reachable for the first time, including truncated or 0-byte assets on which ffprobe exits non-zero. These probes run inside a bare Promise.all, so an unguarded throw aborted the whole render over one unreadable image; probeColorSpaceSafely now logs and treats such a source as SDR. Tests cover percent-encoded CJK, origin-root percent-encoded CJK, compiledDir-over-projectDir precedence, and existing-absolute passthrough, with distinct projectDir/compiledDir so the precedence is actually pinned. Fault-injection verified: reintroducing the isAbsolute short-circuit fails the origin-root test. Refs PRINFRA-349 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(producer): restore the vitest runner import in extractVideosStage tests The rebase merged the new `node:fs` / `node:os` / `node:path` imports into line 1 and took the incoming side, so `import { describe, expect, it } from "vitest"` was replaced rather than kept alongside. The file still uses all three, and `bun run test:classification` regex-matches `/\bfrom\s+["']vitest["']/` to route each test file to a runner — so the file matched neither and hard-failed the gate, taking Producer unit + integration and the required Test check with it. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ttribution (STUDIO-5433) (#3033) * fix(producer): attach src URL to ffprobe failures for compile-phase attribution (STUDIO-5433) Wrap the video-branch `extractMediaMetadata` and `probeMediaProfile` calls in `resolveMediaDuration` (`packages/producer/src/services/htmlCompiler.ts`) with a `withSrcContext` helper that re-throws with the remote `src` appended as `[src=<url>]`. The URL is passed through `redactTelemetryString` first so pre-signed URL signatures never reach telemetry. STUDIO-5433 — enterprise customer `mdave@manh.com` was blocked from generating AI Studio videos, surfacing in Datadog as `[FFmpeg] ffprobe exit with code 1: [mov,mp4,m4a,3gp,3g2,mj2 @ 0x...] moov atom not found\n[input]: Invalid data found when processing input`. `runFfprobe` at `engine/utils/ffprobe.ts:74-79` intentionally redacts the local `filePath` from the error (see `redactFfprobeInput` — same file, lines 13-35), so the failure carries no attribution and identifying the offending source requires dumping the Temporal activity history for the workflow. That dump is expensive-per-occurrence and blocks debugging on operator availability. The demuxer signature (`mov,mp4,m4a,3gp,3g2,mj2`) tells us the file is MOV/MP4-family, and the workflow_id tells us which HyperFrames composition element failed — but the *actual URL* that ffprobe was handed is lost. This change surfaces the URL so the next occurrence is diagnosable directly from the render error in Datadog, without a Temporal history dump. Preserves fail-fast semantics: the video branch still throws (aborts the compile), unlike the audio branch's deliberate graceful-degrade to `duration=0`. Only the error *message* is enriched; the control flow is unchanged. 1. `packages/producer/src/services/htmlCompiler.ts` - New `withSrcContext(error)` helper inside `resolveMediaDuration` that wraps `error.message` with `[src=<redactTelemetryString(src)>]` and preserves the original stack. - Video-branch `probeMediaProfile` catch re-throws via `withSrcContext` (was: bare `throw error`). - Video-branch `extractMediaMetadata` newly wrapped in try/catch that re-throws via `withSrcContext` (was: uncaught, so the caller saw the bare `[input]`-redacted ffprobe message). - Adds `redactTelemetryString` import from `@hyperframes/core` (already re-exported at `packages/core/src/index.ts:255`). 2. `packages/producer/src/services/htmlCompiler.test.ts` - New `describe("STUDIO-5433 — ffprobe failure includes src URL for attribution")` block with a `compileForRender` integration test: writes a 0-byte `assets/clip.mp4`, references it from an `<video src>` tag, asserts the thrown error message contains `[src=assets/clip.mp4]` AND still carries the original ffprobe diagnostic so downstream failure classifiers continue to match. - [x] Repro locally: 0-byte mp4 → `compileForRender` → error message contains `[src=assets/clip.mp4]` (test above). - [x] Preserves fail-fast semantics — video branch still throws (assertion on thrown error). - [ ] Focused CI must pass; hosted CI to follow. - [ ] Follow-up (separate PR pending URL recovery): identify the writer that produces the actual failing derivative and add `_probe_section_integrity` fail-closed at the write site (the durable fix — this PR is diagnosability defense-in-depth). <!-- pr-check:enterprise-ff:start --> - [x] This change is not behind a feature flag (small diagnostic improvement on an existing error path; preserves failure semantics unchanged). - [ ] This change is behind a feature flag <!-- pr-check:enterprise-ff:end --> <!-- pr-check:ui-impact --> - [x] <!-- pr-opt:no-ui-impact --> No UI impact — enriches a producer-worker error message read only in Datadog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(producer): pass typed routing errors through the src-context wrapper `withSrcContext` rebuilt every error as a bare `new Error(...)`, which dropped `NotMediaPayloadError`'s `.code = "NOT_MEDIA_PAYLOAD"`, `.owner = "user"`, `.retryable = false` and `.elementFingerprints`. `SAFE_RENDER_ERROR_CODES` and the distributed retry set both key on those, so a `<video>` src pointing at an HTML payload — the STUDIO-5433 root case — flipped from NOT_MEDIA_PAYLOAD/user/no-retry to generic/system/retryable: it paged ops and re-ran the render on a user-input bug. The existing sniff regression ("aborts with NotMediaPayloadError before ffprobe…") is the pin; it fails on the removal of this one line. The PR's own new test also asserted `[src=assets/clip.mp4]`, but a bare relative path matches `telemetryRedaction`'s BARE_RELATIVE_PATH shape and redacts to `[path]`. Assert what the redactor actually produces for a local src, and pin the case the ticket is about — a remote URL, where host and path survive and only the pre-signed query is dropped — directly on the redactor. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Picks up crbug 522872457's fix (CL 8032671), which landed after the 152.0.7935.0 canary cut and so was absent from the old 152.0.7928.2 pin. Re-probed every 3D signal the compile gate matches, drawElementImage vs a CDP screenshot of the identical state, on the shipping headless-shell binary. PSNR, old pin -> new pin: backface-visibility:hidden 1.4 dB -> 14.8 dB still DAMAGED preserve-3d (no backface) 46.7 dB -> 46.7 dB clean perspective() 45.2 dB -> 45.2 dB clean matrix3d() 45.2 dB -> 45.2 dB clean rotate3d() 45.3 dB -> 45.3 dB clean translateZ under perspective 29.9 dB -> 29.9 dB marginal The upstream fix repaired the collateral damage only: dropped sibling content and lost backgrounds now render, but a culled backface is still painted. So the 3D gate stays. Beta rather than Canary because 153.0.8000.0 measured identical on every variant. Follow-up filed as PRINFRA-486: four of the five signals the gate matches were never broken on any build tested, so it may be able to narrow to backface-visibility alone. Needs a corpus eval first — this probe covers static angles only, and animated 3D subtrees take a different path.
Presets button becomes the stacked primary control (bold, filled outline);
Add-effect demoted to a small trailing link ("+ effect"). Button onClick
bodies and audition-revert logic are unchanged.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…3275) * feat(studio): make presets the primary path into the FX rack Presets button becomes the stacked primary control (bold, filled outline); Add-effect demoted to a small trailing link ("+ effect"). Button onClick bodies and audition-revert logic are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(core,studio): silence hidden audio in preview, and call it mute Preview scheduled every audio[data-start] regardless of data-hidden, so a hidden audio track was silent in the export but audible in preview — render was already correct, this was a preview-only parity bug. Web Audio scheduling now skips (and re-syncs on toggle) any audio clip under a data-hidden ancestor; the HTMLMedia per-tick volume path folds the same check into effectiveVolume without touching el.muted (transport-owned). Ships unflagged since it's a bugfix restoring parity. Also relabels the eye as Mute/Muted on audio-only track rows (icon, strikethrough label, undo-history copy), gated behind the new audio-track-mute canary — the relabel is a copy/UX change, kept separate from the behavior fix above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(core): assert hidden-audio exclusion on the scheduling entry point, not the decode fallback CI was red on `Test`, `Test: runtime contract` and `Tests on windows-latest` — all three on the same two tests, both reporting `decodeAudioElement` called 0 times. Not a bug in this branch. The tests pass on the branch tip and fail on the MERGE with main, which is what CI actually builds. Main had moved 66 commits ahead, and #3322 ("make creator media edits render-safe") added `WebAudioTransport.scheduleMediaElementPlayback`: media-element clips now route straight through the Web Audio graph instead of being decoded into an AudioBuffer. `decodeAudioElement` survives only as the fallback for the rate-shifted case (`Math.abs(effectiveRate - 1) > 1e-9`), so on the ordinary path it is correctly never called: void webAudio.scheduleMediaElementPlayback(...).then((scheduled) => { if (scheduled || !clock.isPlaying()) return; // <- returns here now ... void webAudio.decodeAudioElement(rawEl) // <- fallback only Both tests used `decodeAudioElement` as a proxy for "this clip reached Web Audio scheduling", which was accurate before #3322 and is not any more. Retargeted to `scheduleMediaElementPlayback`, which is that signal now and takes the element as its first argument, so the assertions keep their exact shape and meaning. Confirmed by instrumenting the run rather than inferring: on the merged tree the scheduler is called exactly once, with the audible element — the feature under test works, only the probe was pointed at the wrong method. Still non-vacuous: deleting the `rawEl.closest("[data-hidden]")` guard from `scheduleWebAudioForActiveClips` fails the first test with "expected 1 times, but got 2 times", so it genuinely catches a hidden clip being scheduled. `init.test.ts` 77/77, and 1259 passed across packages/core `src/runtime` + `src/audio` on the merged tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…3276) Adds hf-pitchshift alongside the four existing dynamics worklets: a dual-tap granular delay line, 100 ms grain, taps 180° apart so one is always crossfading in as the other resets — hides the splice each tap makes on wrap. Read-tap speed relative to the write head tracks the semitone ratio, so pitch shifts without changing duration. Registered through the same workletBuilder/dispose-message path the other four use (so shapeOf never rebuilds on a param tweak, and a chain drop retires it), wired into the registry with a plain-language copy entry and a ~0.2s chain tail (two grains). One implementation, shared by preview (Web Audio in the page) and render (the same worklet run inside an OfflineAudioContext in the headless browser) — confirmed by a browser-render test that measures the actual output frequency, not just that it differs from input. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(cli): reject blank default composition entry * fix(cli): complete blank entry safeguards
ffmpeg and ffprobe are console-subsystem binaries and Node defaults windowsHide to false, so every spawn opened a visible console window on Windows. A render shells out dozens of times across parallel workers, which flashed a burst of windows across the user's desktop. Applied at every production spawn site rather than only the two named in the report, since they all share the cause: runFfmpeg, both gpuEncoder probes, ffprobe, streamingEncoder, audioExtractor and the distributed version check. windowsHide is a no-op on macOS and Linux. The dev-only parity and regression harnesses are left alone; they never run on a user's desktop. Closes #3379
…Windows (#3394) Windows users see a console window per chrome-headless-shell worker during a render. Those windows come from Puppeteer's own launcher, not from any spawn in this repo, so the windowsHide work on our ffmpeg spawns could not reach them. @puppeteer/browsers added windowsHide: true to its spawn in 3.2.1. It is absent in 3.1.0 and 3.2.0. puppeteer-core pins that dependency exactly, and 25.8.0 is the first release pinning 3.2.1 (25.5.0 -> 3.1.0, 25.6.0 and 25.7.0 -> 3.2.0), so 25.8.0 is the minimum that carries the fix rather than a preference for the latest. Verified after install that exactly one copy resolves, at 3.2.1, and that its launcher carries the flag. A draft render still completes. Refs #3379
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with weight and a compressor to hold the extra low end together, Monster pitches down further with saturation growl and a close, tight reverb. Every param verified against the live effect registry rather than sketched — the compressor/reverb/saturate/shelf keys all match exactly. Each gets its own title treatment (font, size, tracking, hue) so the FX rack's per-preset styling coverage and hue-distance/background-uniqueness tests extend cleanly to the three new entries, and complaint-line copy in the non-voice vocabulary the audit test enforces (no speech words — "Giant" over CapCut's "Deep Voice", as the design doc records). Updates plans/audio-fx-presets.md's two limits paragraphs to record that pitch shift landed and this half of the character list now ships; Robot and Alien stay out of scope (ring modulation, still unbuilt). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(core,studio): the character presets pitch shift unlocks Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with weight and a compressor to hold the extra low end together, Monster pitches down further with saturation growl and a close, tight reverb. Every param verified against the live effect registry rather than sketched — the compressor/reverb/saturate/shelf keys all match exactly. Each gets its own title treatment (font, size, tracking, hue) so the FX rack's per-preset styling coverage and hue-distance/background-uniqueness tests extend cleanly to the three new entries, and complaint-line copy in the non-voice vocabulary the audit test enforces (no speech words — "Giant" over CapCut's "Deep Voice", as the design doc records). Updates plans/audio-fx-presets.md's two limits paragraphs to record that pitch shift landed and this half of the character list now ships; Robot and Alien stay out of scope (ring modulation, still unbuilt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(core): the audio group model — element, membership, helpers Introduces <hf-audio-group> and data-audio-group as the group model B2–B7 and C1 build on: a non-rendering group element carries a label and (later) an FX chain, membership lives on the member's own data-audio-group attribute rather than DOM nesting, so a track removed from the document simply drops out of the group on the next resolve — nothing dangles. Groups do not nest: data-audio-group on the group element itself is ignored. A group with members but no <hf-audio-group> element still resolves, label falling back to the id, so hand-authored HTML degrades gracefully. Audio only in v1 — video members are ignored. Parse-only: nothing routes or sums audio yet (B3/B4). Adds the audio-groups canary at percentage: 0 gating the future Studio UI; the element and attribute parse and play regardless of enrollment. Verified rather than assumed per this plan's standing rule: the timeline's clip-collection selector ([data-start], [data-track-index], [data-composition-id], video, audio, img) already excludes the group element with zero changes, and no lint rule flags unknown elements or data-* attributes, so neither needed touching — confirmed by grep and by running `hyperframes lint` against a fixture containing the element (0 findings referencing it). The step doc's suggested display:none injection point (an existing base stylesheet in the runtime) does not exist in this codebase; skipped rather than inventing new infrastructure, since an empty, childless custom element already renders as a zero-size inline box with no visible output — the same reasoning the lint check above confirms empirically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(core,studio): the character presets pitch shift unlocks
Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet
P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with
weight and a compressor to hold the extra low end together, Monster pitches
down further with saturation growl and a close, tight reverb. Every param
verified against the live effect registry rather than sketched — the
compressor/reverb/saturate/shelf keys all match exactly.
Each gets its own title treatment (font, size, tracking, hue) so the FX
rack's per-preset styling coverage and hue-distance/background-uniqueness
tests extend cleanly to the three new entries, and complaint-line copy in
the non-voice vocabulary the audit test enforces (no speech words — "Giant"
over CapCut's "Deep Voice", as the design doc records).
Updates plans/audio-fx-presets.md's two limits paragraphs to record that
pitch shift landed and this half of the character list now ships; Robot and
Alien stay out of scope (ring modulation, still unbuilt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(core): the audio group model — element, membership, helpers
Introduces <hf-audio-group> and data-audio-group as the group model B2–B7
and C1 build on: a non-rendering group element carries a label and (later)
an FX chain, membership lives on the member's own data-audio-group
attribute rather than DOM nesting, so a track removed from the document
simply drops out of the group on the next resolve — nothing dangles.
Groups do not nest: data-audio-group on the group element itself is
ignored. A group with members but no <hf-audio-group> element still
resolves, label falling back to the id, so hand-authored HTML degrades
gracefully. Audio only in v1 — video members are ignored.
Parse-only: nothing routes or sums audio yet (B3/B4). Adds the
audio-groups canary at percentage: 0 gating the future Studio UI; the
element and attribute parse and play regardless of enrollment.
Verified rather than assumed per this plan's standing rule: the timeline's
clip-collection selector ([data-start], [data-track-index],
[data-composition-id], video, audio, img) already excludes the group
element with zero changes, and no lint rule flags unknown elements or
data-* attributes, so neither needed touching — confirmed by grep and by
running `hyperframes lint` against a fixture containing the element (0
findings referencing it). The step doc's suggested display:none injection
point (an existing base stylesheet in the runtime) does not exist in this
codebase; skipped rather than inventing new infrastructure, since an empty,
childless custom element already renders as a zero-size inline box with no
visible output — the same reasoning the lint check above confirms
empirically.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(studio): group rows in the timeline, and a split disclosure
A group renders as its own row with member rows beneath it, and disclosure
splits into two independent controls: caret shows/hides a group's member
rows (structural), `∿` shows/hides any row's automation-lane rows. Plain
tracks lose their caret (nothing to disclose structurally) and keep only
`∿`. `expandedClipIds` keeps its existing keyframe-lane-state job;
`expandedGroupIds`/`expandedLaneOwnerIds` are new, independent sets.
Groups get a real position in the row/geometry pipeline rather than a
visual-only overlay: `useTimelineTrackDerivations` re-emits a group's member
tracks contiguously under a synthetic fractional anchor key
(firstMember - 0.5, the same fractional-key convention sub-composition
expansion already uses), so `rowGeometry`/keyboard-nav/virtualization treat
a group row as a first-class row without widening their key type away from
number. `TimelineLogicalRow.level` widens `1 | 2` to `1 | 2 | 3` (group /
member-under-group / lane), lanes always `owner.level + 1`.
All of it — grouped row emission, the header, the new expansion state — is
gated behind `isCanaryEnabled("audio-groups")`; disabled, `groups` resolves
empty and every new code path no-ops. `TimelineElement.audioGroup` (+
`audioGroupLabel`, resolved once per document via `resolveAudioGroups` from
B1) is parsed unconditionally, mirroring how `hidden`/`fxChain` already
flow DOM → manifest → TimelineElement — inert without the canary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ockParamsPanel (#1965) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audio element carrying `data-audio-group` no longer lands its gain on the master bus directly — it feeds a per-group `GainNode` (built lazily on first use, one per group id) which itself feeds master, so members of the same group sum before the ear, ready for a group-level FX chain and volume/mute in later steps. An id with no matching `<hf-audio-group>` element still gets a plain, unprocessed bus rather than losing the track. The group's own chain and volume lane are wired through the same `attachElementFxChain`/`scheduleVolumeLane` every element already uses, against the group's clock — composition time (design doc §1.3), since a group has no `data-start` and a missing one parses as 0. The bus persists across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a group does not rebuild its chain; only `destroy()` disposes it. Render is untouched — stays flat until B4; `audio-groups` is still a 0% canary so nothing ships this to a real composition without hand-authoring `data-audio-group`. Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/ `getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles in this suite, including this file's own `mockEl`. Made it tolerant, same style as `readChain`'s existing guard in `runtime/audioFx.ts`. `schedulePlayback` was already 110 lines pre-existing before this diff; extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92, then suppressed the remainder (inherently sequential graph wiring, not a decision tree) per the same precedent B2 used on `TimelineLogicalRow`. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…al (#3288) * feat(core): route grouped audio through a group bus in preview An audio element carrying `data-audio-group` no longer lands its gain on the master bus directly — it feeds a per-group `GainNode` (built lazily on first use, one per group id) which itself feeds master, so members of the same group sum before the ear, ready for a group-level FX chain and volume/mute in later steps. An id with no matching `<hf-audio-group>` element still gets a plain, unprocessed bus rather than losing the track. The group's own chain and volume lane are wired through the same `attachElementFxChain`/`scheduleVolumeLane` every element already uses, against the group's clock — composition time (design doc §1.3), since a group has no `data-start` and a missing one parses as 0. The bus persists across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a group does not rebuild its chain; only `destroy()` disposes it. Render is untouched — stays flat until B4; `audio-groups` is still a 0% canary so nothing ships this to a real composition without hand-authoring `data-audio-group`. Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/ `getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles in this suite, including this file's own `mockEl`. Made it tolerant, same style as `readChain`'s existing guard in `runtime/audioFx.ts`. `schedulePlayback` was already 110 lines pre-existing before this diff; extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92, then suppressed the remainder (inherently sequential graph wiring, not a decision tree) per the same precedent B2 used on `TimelineLogicalRow`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(studio,lint): carve targets voiceover groups — always, when plural Plural voiceover carve now targets a group instead of naming each clip: `resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its current members at analysis time, so a clip added to the group later is covered without touching `sources`. The picker (`useFxCarve.ts`) offers a grouped voice as one option instead of one row per member, tests overlap as a union of member spans (a group overlaps the bed if ANY member does), and prefers a qualifying group over its individual members in `autoSourceIds`. Picking two or more ungrouped voice clips in the carve flow now mints a group behind them (`mintGroupId`, de-duped against every id in the document) and writes `data-audio-group` on each picked clip atomically, one undo entry — `createAudioGroupAndAssignMembers` in `timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target write shape. The DSP is untouched: `mixCarveSources` already sums multiple sources correctly (verified in the design doc's own investigation) — this only fixes the picker. New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/ media.ts`, alongside `audio_volume_double_automation`) warns when a `data-fx-carve`'s `sources` names two or more plain clip ids instead of a group — the shape that silently rots when a clip is added. `/hyperframes- audio` states the same rule as an invariant, not a tip, with the grouped- narration HTML example from the design doc. The group-matching and auto-group logic (`withAutoGroupedSources`, `collectCarveCandidates`) is split into `useFxCarveGrouping.ts` — `useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings` is deliberately NOT an `async function`: wrapping it in one would force a microtask on every call, including the synchronous branch — the exact bug `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid, and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures) before fixing it back to a plain function the caller conditionally awaits. Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and `useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600 lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker IIFE to clear fallow's complexity gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#3289) * feat(core): route grouped audio through a group bus in preview An audio element carrying `data-audio-group` no longer lands its gain on the master bus directly — it feeds a per-group `GainNode` (built lazily on first use, one per group id) which itself feeds master, so members of the same group sum before the ear, ready for a group-level FX chain and volume/mute in later steps. An id with no matching `<hf-audio-group>` element still gets a plain, unprocessed bus rather than losing the track. The group's own chain and volume lane are wired through the same `attachElementFxChain`/`scheduleVolumeLane` every element already uses, against the group's clock — composition time (design doc §1.3), since a group has no `data-start` and a missing one parses as 0. The bus persists across `stopAll()` (mirroring `_masterGain`'s own lifecycle) so replaying a group does not rebuild its chain; only `destroy()` disposes it. Render is untouched — stays flat until B4; `audio-groups` is still a 0% canary so nothing ships this to a real composition without hand-authoring `data-audio-group`. Also: `audioGroupOf` (B1) crashed on any element lacking a real `tagName`/ `getAttribute` — exactly the shape of most `HTMLMediaElement` test doubles in this suite, including this file's own `mockEl`. Made it tolerant, same style as `readChain`'s existing guard in `runtime/audioFx.ts`. `schedulePlayback` was already 110 lines pre-existing before this diff; extracted `resolveDestination` and `handleSourceEnded` to shrink it to 92, then suppressed the remainder (inherently sequential graph wiring, not a decision tree) per the same precedent B2 used on `TimelineLogicalRow`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(studio,lint): carve targets voiceover groups — always, when plural Plural voiceover carve now targets a group instead of naming each clip: `resolveCarveSourceIds` (core `audioGroups.ts`) expands a group id to its current members at analysis time, so a clip added to the group later is covered without touching `sources`. The picker (`useFxCarve.ts`) offers a grouped voice as one option instead of one row per member, tests overlap as a union of member spans (a group overlaps the bed if ANY member does), and prefers a qualifying group over its individual members in `autoSourceIds`. Picking two or more ungrouped voice clips in the carve flow now mints a group behind them (`mintGroupId`, de-duped against every id in the document) and writes `data-audio-group` on each picked clip atomically, one undo entry — `createAudioGroupAndAssignMembers` in `timelineTrackVisibility.ts` copies `setElementsHidden`'s multi-target write shape. The DSP is untouched: `mixCarveSources` already sums multiple sources correctly (verified in the design doc's own investigation) — this only fixes the picker. New lint rule `audio_carve_ungrouped_sources` (`packages/lint/src/rules/ media.ts`, alongside `audio_volume_double_automation`) warns when a `data-fx-carve`'s `sources` names two or more plain clip ids instead of a group — the shape that silently rots when a clip is added. `/hyperframes- audio` states the same rule as an invariant, not a tip, with the grouped- narration HTML example from the design doc. The group-matching and auto-group logic (`withAutoGroupedSources`, `collectCarveCandidates`) is split into `useFxCarveGrouping.ts` — `useFxCarve.ts` was pushing past the 600-line cap. `resolveNextCarveSettings` is deliberately NOT an `async function`: wrapping it in one would force a microtask on every call, including the synchronous branch — the exact bug `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid, and one caught via `propertyPanelAudioFxGroup.test.tsx` (10 failures) before fixing it back to a plain function the caller conditionally awaits. Also extracted `useEffectiveTimelineDuration` out of `App.tsx` and `useRemoveBackground` out of `StudioRightPanel.tsx` (both pushed past 600 lines from an added prop wire), and decomposed `useFxCarve.ts`'s picker IIFE to clear fallow's complexity gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(engine): render grouped audio through a summed, FX-processed bus Renders what B3 already routes in preview: a group's members sub-mix into one PCM WAV at full composition length (adelay already places each member at its composition position, so the group WAV's t=0 IS composition time), run through the group's own FX chain and automation via the same applyAudioFxChain/envelope-bake path a member uses, then fold into the flat track list as one processed AudioTrack — the final mixAudioTracks call never has to know groups exist. Gain law verified against plans/spikes/amix-nesting-spike.sh (brought over from the plans branch, along with audioMixer.grouping.test.ts, since both were committed there and never merged to origin/main — every step branch in this stack descends from origin/main): the sub-mix's own amix prefers normalize=0 (nulls exactly against a flat mix), falling back to per-node compensation by the group's OWN member count only when this ffmpeg build's amix rejects the option. Carrying any other count into a nested amix node is the exact +2.499 dB silent failure the spike measured — confirmed by a manual mutation check (wrong-count compensation landed 3.5 dB hot, exactly 20*log10(3/2) for a 2-member group compensated as 3; reverted after confirming the level test catches it). A group element carrying data-hidden drops every member before the sub-mix ever runs (RULES: mute-by-drop, never mute-by-volume-0) — parseAudioElements now resolves groups once per parse and skips hidden-group members the same way it already skips data-hidden ancestors. HfAudioGroup (packages/core/src/audioGroups.ts, from B1) gains fxChain, automation, volume and hidden, read off the group element the same way resolveAudioGroups already reads data-label — audioGroups.test.ts updated for the wider shape plus new coverage for the added reads. it.todo("mixes a grouped composition at the same level as the ungrouped one") is now a real, passing test; two more added per the step doc (FX routing isolation, member-level envelope survives grouping). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(studio,core): a volume and a living meter on the group row
B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.
Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.
Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.
Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared
Two CI gates, both from this branch's own additions.
`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.
`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.
Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
… trap, undo (#1966) Left-sidebar and slideshow-panel fixes from the studio UX review. Four criticals: an unconfirmed permanent asset delete, a Rename menu item that did nothing, a search box that unmounted itself while its filter stayed applied, and slideshow edits whose persist failures were swallowed. Asset context menu: Delete shows an inline DeleteConfirm before calling the API; the dead Rename item is a working inline rename (validates `/`, `\`, `..`, preserves directory + extension); role="menu"/"menuitem", Escape, arrow-key nav, focus-into-menu, viewport clamping. Assets tab: header controls gate on the UNFILTERED asset count, so a no-match query shows "No assets match" + Clear search instead of unmounting its own input; cards and font rows are keyboard-operable; a copy chip surfaces clipboard failure; the import button owns its pending state; a broken thumbnail names the file type. Slideshow panel: persist failures raise a "Changes not saved — Retry" banner (role="alert") with a working retry; in-panel undo stack (50 snapshots, scoped ⌘Z); branch delete confirms inline; reorder buttons disable at boundaries; HotspotTool explains its prerequisites. Blocks / compositions tabs: "Added!"/"Copied!" are promise-truthful; hover-only overlays reveal on focus; PromptPreviewModal gets the dialog contract + dirty-draft guard; lint dot → labeled count badge; the render button explains "A render is already in progress"; sidebar tabs are a real APG tablist; AudioRow coordinates a single preview at a time. Restacked onto main now that PRs 1962/1963/1964 have squash-merged, so this carries only its own changes. Reconciled against six weeks of main: main's newer interaction model wins (rows drag to the timeline, click reveals the clip or opens the preview, copy is a context-menu action), and this PR's a11y and error surfacing is ported on top of it. The card components main extracted to AssetCard.tsx receive the keyboard activation, focus cues and copy-outcome chip; the "Add at playhead" item main added joins the rewritten menu's arrow-key order; the Catalog tab is unconditional since the blocks-panel flag was removed. The row copy chip is feedback-only — an idle "Copy path" label would describe something the row no longer does. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-delete gesture (#1967) Player and timeline fixes from the studio UX review, reconciled against six weeks of main. Honest media states: - AudioWaveform no longer falls back to synthesised sine-wave peaks when a decode fails. The failure propagates and the clip renders a dashed flat line + "waveform unavailable" instead of a plausible waveform an author would trim and beat-align against. Main's thumbnail scheduler already caches the failure with a TTL, so this neither refetch-loops nor pins the degraded state past a transient error. - VideoThumbnail renders a static "no preview" placeholder on a failed decode rather than resolving to an empty box. Keyframe context menu, restored: - "Edit Ease…" (showing the current ease) and "Copy Properties" (async, "Copied!"/"Copy failed") were plumbed but never rendered. Edit Ease routes to the same focused-ease-segment path a segment click takes, so the menu advertises the editor that exists instead of growing a second one; it is offered only for a keyframe that names a tween to focus. Copy Properties matches the keyframe cache on clip-% with the same tolerance main's move-to-playhead uses. Every row is a role="menuitem" with arrow-key navigation and focus handling via the new useMenuKeyboardNav helper, and a separator now isolates "Delete All Keyframes" from the single delete. Error prevention: - Beat dots: hit target 12→24px (WCAG 2.5.8), and delete moves off double-click to ⌥-click — a stuttered drag reads as a double-click and would destroy the beat. ⌥ starts no drag, so a slipped ⌥-drag abandons instead of deleting. - ShortcutsPanel moves focus into the panel on open and returns it to the trigger on close; SpeedMenu's trigger is labelled and reports its popup. Superseded by main, deliberately dropped: the seek-slider keyboard and aria-valuenow fixes (the transport no longer owns a seek bar), the Player load-error inline retry (main's reports the actual message and retries with a cache-busting src), TimelineClip keyboard selection (main renders a native button, and this PR's onKeyDown would have preventDefault'ed the synthesized click), the keyframe-diamond keyboard guard and label (both already on main, with a richer label), and the waveform's own cache/failure maps (main's scheduler owns that). TimelineOverlays.tsx is a main-side file edited to thread the two restored menu actions; BeatStrip.test.tsx tracks the new gesture and hit target. Restacked onto main now that PRs 1962-1966 have squash-merged, so this carries only its own changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the export (#3291) B5: mute and solo, on groups and tracks (track mute already shipped by A2 — nothing to build there). Group mute — persisted as data-hidden on the <hf-audio-group> element itself (never written onto members, per design doc §2.1's state-restoration warning). Studio action reuses B7's generic setAudioGroupAttribute (setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's shape — same one-atomic-patch/one-undo-entry contract, already built for exactly this purpose. Render: B4 already drops every member of a data-hidden group (confirmed by a new audioMixer.test.ts case — no production change needed there). Preview: a dedicated muteGain node (groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle never fights scheduleVolumeLane's ramps on the same param — the same hazard B7's volume fader was split out to avoid. Mid-playback toggles sync via a new syncAudioGroupMute pass in init.ts (a group carries no data-start, so it's invisible to the existing visibility-node query). Members of a muted group render the strikethrough label treatment (TimelineTrackPlainHeader's isGroupMuted, sourced from TimelineElement.audioGroupHidden) — display only, no attribute touched. Solo — "Hear only this": a new session-only store slice (audioSoloSlice, soloed: ReadonlySet<string> of clip/group ids, never track numbers, never serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts so both the store and the preview transport share one definition): an element is audible while any solo is active only if it or its own group is soloed. "Siblings, never ancestors" lives in the graph, not the predicate — solo gain is a per-element stage only; group buses are never attenuated by solo, so a soloed member's path through its group stays open by construction. Preview: a dedicated per-element soloGain in webAudioTransport.ts (parallel to the mute mechanics), pushed via window.__hf.setAudioSolo — a direct call, not an attribute write, so it can't ride the visibility-diff path mute uses. media.ts's HTMLMedia fallback folds the same predicate into its per-tick volume computation (the same seam A2 used for data-hidden). Half-lit group indicator (isGroupHalfLitUnderSolo) for "not soloed itself, but a member is". Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton (⌗) beside mute on both track and group headers. Transport-bar banner ("Hearing only <label> — your export is not affected", Clear button) added in PlayerControls.tsx, reading labels straight off the live preview DOM. Export-safety, the most important property here: toggling/adding/clearing solo never calls setAttribute/removeAttribute on any element and never invokes the project save path (both asserted directly via spies in audioSoloSlice.test.ts) — solo cannot reach an export by construction, not by convention. Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a pre-existing, unrelated effect) to stay under the 600-line filesize cap after wiring useAudioSoloBridge in; and fixed a circular dependency the solo-banner wiring introduced (useAudioSoloBridge.ts now imports usePlayerStore from its concrete module instead of the player/ barrel, which re-exports PlayerControls.tsx — the barrel path is what closed the cycle). Gates: bun run build clean; packages/core full suite 2379/2379; packages/ studio full suite 4276/4294 (18 pre-existing todo); packages/engine audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched files; fallow clean (0 new circular deps, 0 new filesize/complexity findings). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…t gating (#1968) Caption-editing fixes from the studio UX review. This surface held five of the thirteen criticals; the theme is that the editing UI shipped ahead of its apply/persist pipeline, so several controls mutated an in-memory model with no downstream effect, and the mode itself could never be exited. Mode trap: caption edit mode auto-activated on detection and had no exit — `setEditMode(false)` and `reset()` had zero call sites, so the caption overlay replaced normal element editing for the rest of the session, even after switching compositions. The store now resets on composition change (flushing the last debounced edit first), an "Editing captions · Exit" pill sits on the preview, and a re-enter button appears once dismissed. Honest gating of dead surfaces: the Animation tab (31 presets × duration/ease/stagger/intensity) edited state that was never applied to playback nor serialized — wiring it needs a CaptionOverride schema extension in packages/core plus a runtime engine, so the tab is now visibly disabled with an amber "isn't applied to playback or saved yet" notice instead of silently discarding work. Timing edge-drags moved a block that never changed playback and never saved; the handles are gone and the blocks remain as select/seek targets. Double-click split desynced the overlay↔DOM index mapping, so split is out until regeneration exists. Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target) across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is active and reapplied to the live iframe. Previously ⌘Z reverted an unrelated file edit while the bad caption drag persisted. Autosave: save failures, including non-2xx, raise a persistent "not saved — Retry" banner; the code's own comment called this a data-loss path and it was telemetry-only. Debounced saves flush on unmount instead of being discarded, `beforeunload` flushes and warns while pending, and corrupt overrides JSON is distinguished from a missing file. Input safety and a11y: arrow-key nudge no longer hijacks arrows inside form inputs; numeric fields commit finite values only (typing "-" used to inject NaN into gsap and persist null); "Mixed" shows on multi-select divergence; Escape cancels an in-flight drag and restores the pre-drag transform; ⌘A selects all; caption blocks are keyboard-selectable with a playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed but nothing passed it); 24px hit areas around the 8px handles; a hint when no boxes are visible; visible input focus styles; tablist semantics. Perf: the 66ms getBoundingClientRect polling loop is replaced with event-driven updates (player-store subscription, preview messages, ResizeObserver, rAF-coalesced); the interval now runs only during playback. Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio revamp (#2291), so the mode pill, the sync-error banner and the re-enter button move to its successor, nle/PreviewOverlays.tsx, and the caption track's onSeek is wired in EditorShell. The per-keyframe onChangeKeyframeEase change that also lived in that file is dropped: main removed the prop, and #1967 now routes the diamond menu's ease action to the focused-ease-segment editor instead. Restacked onto main now that PRs 1962-1967 have squash-merged, so this carries only its own changes. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update skills.mdx * docs: drop --full-depth from skills install commands
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…3398) audioMixer.grouping.test.ts spawns real ffmpeg per assertion and ran on vitest's 5s default; on slow Windows runners the FX-chain and envelope cases land right at the line and fail runs that touch nothing in the engine. The other ffmpeg-bound engine suites (videoFrameExtractor) already carry a per-test 30_000 timeout; this brings the grouping suite in line.
* fix(engine): create WAV staging files exclusively * fix(engine): isolate WAV staging in a private directory
…3712) * fix(studio): match style attributes with explicit quote boundaries * fix(studio): apply quote boundaries to active source writers
* fix(studio-server): bound preview variable insertion scans * docs(studio-server): update preview variables helper reference
* fix(core): bound inert region scans in timing compiler * fix(core): use literal search for comment terminators * fix(core): recognize end-bang HTML comment boundaries
* fix(core): bound timing compiler opening tag scans * fix(core): bound ID-targeted duration tag scans
* fix(cli): create capture metadata exclusively * fix(cli): publish capture metadata without following links
* fix(cli): preserve concurrent package metadata during init * fix(cli): preserve concurrent project config creation
…ors (#3725) * fix(engine): read served files through checked descriptors * fix(producer): retain checked files through streamed responses
#3726) A capture is one session with two halves: Chrome navigates the page with a browser User-Agent, then Node fetches the assets that page referenced. Those halves sent three different identities — "HyperFrames/1.0" from the asset and media downloaders, a bare "Mozilla/5.0" from the stylesheet inliner, and the real Chrome UA from the navigation itself. An origin is free to answer those differently, and anti-bot edges do. Capturing one large site, GET /favicon.svg answers 403 text/html to "HyperFrames/1.0" and 200 image/svg+xml to the UA the very same capture had just navigated with. The favicon ranker had already picked that SVG as the best declared icon; the 403 discarded it and the downloader fell through to the next candidate, so the icon written to assets/ was chosen by the CDN's bot rules rather than by the ranker. The capture reported it as one "unavailable" drop and carried on. Hoist the navigation UA into CAPTURE_USER_AGENT and use it for every out-of-band fetch the capture makes: favicons, images, og:image, fonts, stylesheets, Lottie JSON and videos. One constant is what stops the two halves drifting apart again. Verified end to end against that site: before, assets/favicon.png (the apple-touch icon) plus one unavailable drop; after, assets/favicon.svg, byte identical to the file the site itself serves.
* fix(cli): read Studio bundle files through checked descriptors * fix(cli): pin Studio signature and runtime artifact reads
…#3727) A brand band shows logo artwork on a transparency checker, where a badge (a mark knocked out of a full-bleed disc or square) reads as a solid blob. The capture kept only the first icon that downloaded, so which of a site's icons survived was decided by ranking alone, and nothing recorded what the survivor actually looked like. Keep them all. Each declared icon is written as assets/icon-<rel>-<sizes>.<ext> and classified as bare-mark, badge, or unknown, then the best one is copied onto the historical assets/favicon.<ext> stem so a stem match still finds it. Classification is deterministic and cheap. SVG is read from markup, not rendered: these files routinely fill via CSS custom properties behind a prefers-color-scheme query, and librsvg resolves neither, rasterising one real favicon to a fully transparent image while Chrome draws it correctly. Raster icons are sampled at the midpoint of each edge rather than the corners, because a full-bleed disc has four transparent corners and would otherwise read as a bare mark. The headline prefers a bare mark, then falls back to the existing ranking. Preference is binary on purpose: an "unknown" is not promoted above a badge, or an undecodable .ico would outrank a good SVG purely for being unexaminable. extracted/icons-manifest.json records every icon, its shape and the evidence for that shape, plus which file became the headline, its rank, and why. A choice whose losers are invisible is indistinguishable from having had no choice, which is how a substituted icon went unnoticed in the first place. Worth noting for consumers: sites often declare no bare mark at all. An apple-touch-icon is composited onto an opaque tile per Apple's spec, so it is a badge, not a mark. The manifest says so rather than implying the preference was satisfied.
* fix(cli): clarify publish visibility and claim links * style(cli): apply oxfmt to publish visibility test * chore(skills): regenerate skills manifest for updated references * fix(cli): stop an in-place re-publish claiming it made the project private * test(cli): pin in-place visibility copy to the plain re-publish route
* fix(studio-server): publish waveform caches atomically * fix(studio-server): reject linked waveform cache directories
* fix(producer): pin lint entry reads to checked descriptors * test(producer): replace lint entry paths portably
* fix(catalog): bind asset reads to checked project files * fix(catalog): budget the exact bytes published * fix(catalog): bound directory asset reads
Combines two fixes to the motion sidecar path: - An ambiguous selector no longer aborts the whole spec. It is reported as a finding, the assertions that depend on it are skipped and named, and every other assertion still evaluates. `assertionTargets` is now exhaustive over the assertion kinds so a new kind cannot silently fall back to an arbitrary first match. - A sidecar that will not parse is reported as a spec finding instead of ending the run, so the composition is still audited.
…#2811) A code-explainer composition renders selectors and API calls as text. `stripJsStringLiterals` blanks string and template contents (offsets preserved) so `composition_id_selector_in_comment`, `template_literal_selector` and `raf_animation_loop` scan real code only. The scanner tracks regex-vs-division context so a regex literal's own quotes cannot open a phantom string and blank the rest of the script; on any unbalanced scan it returns the source unchanged, so an unmodelled parse degrades to the pre-existing behaviour rather than blanking code on an error-severity gate.
…nd bill a clipped text box once (#2801) Combines two fixes to the layout audit: - `staticIssueKey` no longer includes `issue.text` for `content_overlap` and `text_occluded`; both builders now emit a unique selector for both ends, so the selector pair is the identity. An animating subject (count-up, typewriter, rotating word) no longer splits one held collision into transient groups. - A clipping box that is its own nearest constraint reports `clipped_text` only, instead of also reporting `text_box_overflow`. A non-clipping self-constraint still reports.
…#3737) Hiding several elements in one call is the shortest way to stage a scene, and it was the one form the hidden-selector extractor could not read: a multi-element array failed the target regex, which forbade commas, and a single-element array then failed the selector parse, which accepted only a quoted string or a known alias. So the two error rules that ask whether a hidden element is ever properly revealed had an empty hidden set for every grouped hide. Each part of a group now resolves on its own, and a comma-separated selector string resolves the same way. The target pattern stays paren-free so a set whose vars are a variable cannot run past its own closing paren and swallow the next call. Two false positives the wider hidden set exposed in the fullscreen-overlay rule: a fromTo at 0 seats its from-vars immediately, so hidden from-vars there mean the overlay does start hidden; and an overlay hidden by a standalone gsap.set is what that rule's own fixHint prescribes.
* feat(lint): flag video/img src pointing at an audio file Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(lint): drop m4a from the audio src kinds Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Weekly fork sync
varry-llc/hyperframesis our maintained fallback for publicnpx hyperframes(used by the render action). This PR brings the fork'smainback in line with upstreamheygen-com/hyperframes.State detected by the weekly dep-radar task:
mainwas at upstream commit16eb1136(2026-07-09, PR feat(media-use): usage visibility — shared telemetry identity, miss log, resolve --stats heygen-com/hyperframes#2113 — a clean upstream commit, no fork-specific commits on top).mainis at67edb01(2026-08-16), which includes releases v0.7.106 → v0.7.109 and post-release commits.Interface check (INTEGRATIONS.md): none of v0.7.106–v0.7.109 removed or deprecated a flag the pipeline relies on (
browser ensure,lint,render --fps/--quality/--output,init --example,--version). So this sync is housekeeping — it does not trigger pinning the render action to the fork.Opened automatically by the weekly dependency & integration evaluator. Do not auto-merge — review and merge (or click "Sync fork") at your discretion. Ref: content-pipeline radar issue varry-llc/content-pipeline#15.