mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
v0.8.8
3997
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6f82acf50c | chore: release v0.8.8 (#3411) v0.8.8 | ||
|
|
dac8f9f912 |
feat: add promoted template edit contracts (#3407)
* feat: add promoted template edit contracts * fix: address template contract review feedback |
||
|
|
92a6076807 |
test(engine): budget the ffmpeg audio-level tests, and make a stall say why (#3410)
`places a delayed track on its authored start` timed out on the windows runner and failed an unrelated PR, the second time this week an ffmpeg audio test has done that. The previous fix raised the budget in `audioMixer.grouping.test.ts`, which was the file the symptom named. It was the wrong scope: that file was the only audio suite with explicit timeouts at all. `audioMixer.level.test.ts` had none, so its two real-ffmpeg tests ran on vitest's 5s default. The failing one takes ~137ms locally, so the runner is not 36x slower — but 5s was never a budget anyone chose for a full mix. Applied to the suite rather than to each test, so there is one home for it, and scoped to the ffmpeg-gated describe: the sibling parsing suites are pure and should keep failing fast at 5s. Headroom alone would only have delayed an undiagnosable failure. The ffmpeg process timeout is 5 minutes by default, far above any test budget, so a stalled mix could only ever surface as a bare "Test timed out" with no stderr and no failing stage. Tests now cap it at 20s and assert through a helper that reports `failures` instead of collapsing to `expected false to be true`. Both claims verified rather than asserted: a deliberately 6s test now passes where the 5s default would have killed it, and forcing the process timeout to 1ms reports `stage: "prepare", reason: "ffmpeg_timeout"` instead of a timeout. Reviewing with whitespace ignored is much smaller: adding the third argument to `describe` reindents the suite body, so 131/101 is really 34/4. |
||
|
|
0e9a4f371d |
feat(audio): open the audio FX, group and mute features to everyone (#3401)
* feat(audio): open the audio FX, group and mute features to everyone The twelve-PR audio stack landed on main with all three of its canaries still at 0%, so the FX rack, the group rows, mute and solo are in the build and reachable by nobody. This removes the gates rather than raising the numbers: a canary that gates nothing is a branch every future reader has to evaluate. Gone: - the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries; - the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`, the mute label, the muted strike-through and the solo button in `TimelineTrackPlainHeader`, and the group-row derivation in `useTimelineTrackDerivations`. Each feature now renders on its own precondition (an audio track, a grouped track) exactly as it did for an enrolled user. The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which is the opposite of what should hold now. Replaced with a pin that no `audio-*` canary exists at all: re-registering one silently re-hides a shipped feature, and nothing else in the tree would say so. Verified it fails when one is added back. The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that branch is 105 commits and 310 files divergent from main now that the stack has squash-merged past it. * docs(audio): retire the last references to the audio canaries Two leftovers the gate removal did not reach. `TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the canary, unlike the preview fix" above the function that picks Mute vs Hide. Nothing gates it now, so the comment asserted the opposite of the code. `docs/weekly-updates.mdx` is published, and it told readers the audio work is "staged behind a canary at zero percent, so none of it is visible by default" and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var maps to no registry entry any more, so following the instruction does nothing at all. The week's record stays — it is a dated entry — but it now says the rollout completed and that the variable is inert. * fix(studio): name the mute action per track, and pin the newly-live audio rows Review findings on the canary removal. All three are in code the 0% gate made unreachable, so this is the first time any of it runs for a user. *blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute": the current STATE rather than the action, so nothing told a screen-reader user that activating an already-muted row would unmute it, and it dropped `suffix`, so every audio row shared one accessible name. Music plus VO is the ordinary case, which makes that two identical buttons. Now `Unmute track N` / `Mute track N`, matching the wording `timelineTrackVisibility` already writes into undo history for the same click. `showAsMute` also picks the icon, so this is the control's whole identity, not a tooltip. Tests, for paths that had never executed enabled — a canary at 0% returns `out_of_cohort` before bucketing, and studio additionally excludes `navigator.webdriver`, so no suite could reach them: - `VisibilityButton` — both audio states, two rows staying distinguishable, the visual branch unchanged, and the callback still taking the real track key rather than the display row. Fails on the old label. - `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending order with no groups, and an interleaved group's members become contiguous under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between them keeps its place. Plus label/volume/mute mirroring and the id fallback. Also pins the three retired canary names individually rather than by prefix: `audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a `startsWith("audio-")` check. The family guard stays alongside it. * fix(studio): record the row the mute button announced, not a second derivation Review finding: the header's track number and the undo-history label's are computed from two different orderings, and un-gating `audio-groups` is what makes them diverge. The header's row comes from the group-aware list — `groupTimelineTracks` emits a synthetic anchor row per group and pulls members contiguous. The history's comes from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no anchors. On the fixture in this PR's own derivation test, grouped order `[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this could not happen — the old branch returned raw tracks, so both sides sorted the same way. `onToggleTrackHidden` now carries the display row the clicked control rendered, and `toggleTimelineTrackHidden` prefers it over deriving its own. One number instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of what track number does the user see" already promised. The callback still acts on the real fractional key, so nothing muted the wrong row before or now — only the announced and recorded row was wrong. Also pins the rest of the newly-live surface: the solo button's presence and pressed state, its absence on a visual track, and the strike-through for both a row's own mute and a group mute (with the title that says which). Three existing call-site assertions now check the threaded row too. |
||
|
|
ea95b7d44e |
fix(cli): stop a caught post-render throw reporting a valid render as failed (#3409)
A render that produced and validated its artifact still exited 1. Reported again from the field on 0.8.7: the MP4 was on disk and an independent ffprobe and full decode both passed, and the CLI exited 1 immediately after logging `artifact validated`. `render-success-state.ts` exists for exactly this and documents three earlier cases, so the sentinel was already there. Its gap is which paths read it: the uncaughtException and unhandledRejection handlers both consult `isRenderSucceeded()`, but a post-render throw that the command wrapper CATCHES never reaches either. It becomes an ordinary non-zero CommandResult, and `finalizeCli` wrote that straight to `process.exitCode`. The result was a run that disagreed with itself: `commandSucceededForTelemetry()` already lets a validated render override a failure, so telemetry recorded success while the shell saw exit 1. Sanitize once in `finalizeCli`, where every command result funnels through, rather than wrapping the individual steps. Which step threw does not matter; that the artifact is committed does. The throw is still printed, so it stays visible for diagnosis without being fatal. Reproduced first as a failing test (`expected 1 to be +0`) on macOS, so this is not Windows-specific — the field reports are one instance of it. A second test pins the other side: a command that throws with no validated render still exits non-zero, so the sanitizer cannot swallow a genuine failure. |
||
|
|
9c73e64a07 |
test(engine): give the audio grouping mixes room, and make a stall say why (#3408)
`a group FX chain fully cutting its members leaves an ungrouped track untouched` timed out on the windows runner, failing an unrelated PR. The whole file runs in ~4s locally and that test in ~1.1s, so 30s was not generous — but the runner is roughly 10x slower and this test drives more ffmpeg than any of its siblings, two full mixes plus a group FX chain. 30s was the tightest budget in the package; 60s is what the rest of the ffmpeg-driven engine tests use. Headroom alone would only have moved the same undiagnosable failure later, because nothing here could report why. The production ffmpeg process timeout is 5 minutes, far above any test budget, so a stalled mix could only ever surface as "Test timed out in 30000ms" with no stderr and no failing stage. Tests now cap it at 20s, and the mix wrapper throws the recorded failures instead of returning `success: false` into an `expect(...).toBe(true)` that reports `expected false to be true` and discards the reason. Verified by forcing the process timeout to 1ms: the failure goes from a 30s wall-clock timeout to a 150ms error naming the stage, reason and element (`stage: "prepare", reason: "ffmpeg_timeout", elementId: "a"`). This does not explain the Windows stall itself, which I could not reproduce on macOS. It makes the next occurrence report what it was doing. |
||
|
|
41edbfb2ce |
fix(lint): surface unloadable media variable defaults, stop reading data-var-src ids as paths (#3406)
A data-var-src value the runtime refuses to load is dropped at bind time and the element's authored fallback src renders instead, so the video ships the wrong media and the render still exits 0. lint said nothing, because the scheme allowlist only existed inside the runtime. Move that predicate into @hyperframes/parsers, where both the runtime and the linter can reach it, and error at lint time on any declared default it rejects. The value provably cannot load, so there is no false positive. While reproducing that, lint turned out to report an unrelated missing file: `\bsrc\s*=` also matches the tail of `data-var-src="bg"` (hyphen to `s` is a word boundary), and `[^>]*` is greedy, so the variable id beat the real src earlier in the same tag. Every binding was reported as a missing asset named after the variable, and `<audio data-var-src>` was told the render would be silent. All three copies of that regex now share one helper that requires whitespace before the attribute. |
||
|
|
5842dd8df4 |
fix(studio): invalidate the preview signature off the watcher that sees project writes (#3364)
* fix(studio): invalidate the preview signature off the watcher that sees project writes The preview ETag is a hash of the project's files, memoised per project directory. That cache was cleared from Vite's own watcher, which `server.watch.ignored` deliberately excludes `data/projects/**` from, so nothing ever cleared it: the ETag stayed frozen for the life of the dev server, the preview answered every revalidation with 304, and the browser went on serving the composition as it was when it first loaded. The visible cost is thumbnails. Their disk cache key already content-hashes the composition, so an edit correctly asks for a fresh capture, but the capture is taken against the stale page, and a clip's filmstrip keeps showing frames of a layout that no longer exists until the dev server is restarted. Studio already runs its own chokidar watcher over exactly these directories, because Vite's would answer a composition edit with a full page reload. That watcher now owns the invalidation, and the cache asks it to follow any project directory it has not seen. All five event types count: an added or deleted asset changes the signature as surely as an edited one. The cache moves behind `createProjectSignatureCache` so the invalidation rule is a unit under test rather than a subscription buried in the adapter. * fix(studio): filter signature invalidation, and stop the CLI server missing motion saves Review follow-up on the unfiltered invalidation. The watcher fired on everything under a project dir, but the signature walk skips 14 directories and `.thumbnails` is one of them. That directory is where the thumbnail route keeps its disk cache, and every capture also reads the preview, so populating a timeline row discarded the memo on roughly every request of the one workload it exists for. The filter is a single exported predicate beside the exclusion set it reads, and it is applied inside `invalidate` rather than at the watcher, so no caller can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`: that set is character-identical but drops all of `.hyperframes/`, and the signature reads two manifest files back out of there. Which is the same bug, still live, in the CLI server: its watcher filters through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never reached the listener that clears the cached signature. Studio writes that file at runtime, so saving motion state left the preview ETag stale until restart. The watcher now admits signature-relevant paths and the reload listener re-applies its own filter, so what triggers a browser reload is unchanged. Also from review: drop the `createViteAdapter` signature-cache default, which produced exactly the memo-nothing-clears bug this PR fixes, and correct the docstring — the content hash is already gated behind a stat fingerprint, so what the memo saves is the walk. |
||
|
|
09a5ef7092 |
fix(lint): stop duplicate_composition_id firing on repeated sub-composition mounts (#3404)
sub-compositions.md documents mounting one sub-composition several times with different data-variable-values to get per-instance variations. That necessarily repeats data-composition-id, so the rule reported our own documented pattern as an error and blocked check with no correct way to satisfy it. The rule bucketed every element by id with no awareness of data-composition-src, so it could not tell a composition root from a mount. The runtime already distinguishes them: repeated mounts are rewritten to id__hf1, id__hf2 so they coexist, and render, validate, inspect and snapshot all handle the pattern. Skip mounts, the same way the rule already skips tags inside an inert template. The collision it exists for is unaffected: its own fixHint names a <meta> tag carrying the root's id, and that tag has no data-composition-src. Closes #3403 |
||
|
|
e1191edba6 |
fix(producer): anchor local-font embedding to its url() occurrence (#3405)
The embed step rewrote the compiled document with
result.replaceAll(localPath, dataUri) — a bare substring replace with no
surrounding syntax. That also rewrites the path anywhere else it appears,
including inside a LONGER url whose tail happens to match, producing a
corrupted value like url("file:///abs/data:font/woff2;base64,...").
Any two paths where one is a suffix of the other collide the same way;
img/logo.ttf and assets/img/logo.ttf are enough. Every sibling rewrite in
this file already anchors on url(...), so this one was the outlier.
Also add file: to LOCAL_FONTFACE_URL_RE's exclusion list. Without it an
absolute file:// src was classified as a project-relative path and
resolved to <projectDir>/file:/abs/..., and the failed read was swallowed
by an empty catch. That catch now logs, since a silently skipped font
means the composition renders in a fallback typeface with nothing saying
why.
Closes #3369
|
||
|
|
41af866bcb | chore: release v0.8.7 (#3402) v0.8.7 | ||
|
|
9bb4b4ce60 |
fix(lint): break two fix-loops and drop two rules the runtime owns (#3400)
An eval of time-to-finished-video against Remotion found the whole gap sits
after authoring, in the lint/check -> fix -> recheck loop. Lint execution is
3-5% of wall; the cost is the model turn each finding triggers. So the
expensive rule is the one an agent cannot satisfy, and the next most expensive
is the one that fires on correct code.
Two rules could not be satisfied at all.
`gsap_fullscreen_overlay_starts_visible` on a from() reveal was a closed cycle.
It errored on `tl.from("#flash", { opacity: 0 })`, which is not a defect:
from() seats its start values immediately, so on a paused timeline the overlay
already measures opacity 0 at t=0. Both of its fixHints (authored CSS
`opacity: 0`, or an immediate `gsap.set`) turn that working composition into a
real defect, which `gsap_from_opacity_noop` correctly errors on -- and that
rule's fixHint says to remove the very thing we just asked for. Applying either
hint bounced between the two errors forever.
The root cause was not the reporting condition but `laterHidden`, which counted
the reveal itself: a from-tween records its START values, so
`from({opacity: 0})` read as its own later hide. Excluding the reveal, and
excluding from-tweens (which end visible), is what actually fixes it. The
later-hidden shape still reports and still converges.
`caption_text_overflow_risk` told authors to add `overflow: hidden`, which is
exactly what `caption_overflow_clips_scaled_words` errors on. Following the
warning produced an error. The hint now says to keep overflow visible.
Two rules asserted a failure that cannot happen.
`root_composition_missing_data_start` errored because "the runtime needs
data-start=0 on the root element to begin playback". The runtime sets it itself
-- init.ts:286-292, whose comment reads "Agents sometimes omit data-start on the
root composition element ... Default to 0 for the root." The rule demanded the
fix the runtime had already applied. 16 of the 643 shipped registry files.
`overlapping_clips_same_track` claimed overlapping clips "cause rendering
conflicts". Nothing reads the track index at render: timeline.ts:586 states
"Track index is display-only; render never reads it", and grepping
`trackIndex|track-index` across engine and producer source returns zero hits.
Two clips overlapping on one track is a crossfade.
Two false positives fixed rather than removed, because the invariants are real.
`timeline_id_mismatch` fired on the legal one-liner
`window.__timelines = { main: gsap.timeline({ paused: true }) }`. The body regex
was non-greedy, so it stopped at the brace of the inlined OPTIONS object, and
the entry scanner harvested `paused` as a composition id. The resulting fixHint
named a registration that does not exist, so it could never be applied; hoisting
to a variable was the only escape and nothing said so. It now walks brace depth
and reads top-level keys only.
`non_deterministic_code` fired on `new Date("2026-01-01T00:00:00Z")`, which is
deterministic, and on `Math.random()` inside a string literal that a code-display
composition renders but never executes. Date is now zero-arg only, and patterns
run against string-stripped source -- except the GSAP `"random(...)"` tween
value, where the string IS the executed value and must still be scanned. That
exception is what the first attempt got wrong; the existing tests caught it.
Measured over the 643 shipped registry files: 386 -> 370 errors, no new codes.
The corpus does not exercise the loop cases, so it understates the change -- the
point is the turns those loops cost, not the finding count.
|
||
|
|
d4765512df |
fix(catalog): render the Matrix Decode docs preview; remove Checkout Flow (#3396)
* fix(catalog): reveal Matrix Decode captions with autoAlpha so the docs preview renders The demo and snippet flipped word spans with zero-duration display sets, which the seek-driven docs player never applies after its style restore: the composition played 8s of black. autoAlpha reveals with the scrambles as same-length absolute overlays follow the keyframes contract (never tween display) and survive seeks and loop wraps. Scramble text now matches each word's length so it decodes in place instead of jumping. * chore(registry): remove the Checkout Flow component Owner-directed removal of the checkout-flow catalog item: source, demo, generated docs page and payload, and its entries in the registry manifest, catalog index, docs nav, and search vectors. The deletions are allowlisted in check-no-main-deletions. |
||
|
|
a1c1f519cb |
fix(core): bind native window methods in the scoped sub-composition proxy (#3378)
The scoped window proxy handed native methods back unbound, so `this` at
call time was the Proxy and Chrome rejected it with "Illegal invocation".
That broke window.addEventListener, setTimeout, matchMedia and
getComputedStyle inside every sub-composition, including the
window.addEventListener("hf-seek", ...) form the Three.js and TypeGPU
adapters document. The sibling document and gsap proxies in this file
already bound; this one was the outlier.
Bind only non-constructors: Function.prototype.bind drops static members,
so binding a class exposed on window would silently strip its statics.
Built-in methods have no .prototype, classes and constructors do.
Closes #3376
|
||
|
|
8b67bb6db5 |
fix(cli,studio): surface project lint in Studio (#3393)
* fix(cli,studio): surface project lint in Studio * fix(studio): preserve per-file lint coverage |
||
|
|
77566a198b |
test(engine): give the ffmpeg-bound grouping mixes their 30s timeout (#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. |
||
|
|
6a92d21401 |
feat(studio,core): reach presets and the rack from the timeline (#3292)
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>
|
||
|
|
9ec75a485f |
docs: drop --full-depth from skills install commands (#3399)
* Update skills.mdx * docs: drop --full-depth from skills install commands |
||
|
|
254de3d1c4 |
fix(studio): captions UX — mode exit, undo, autosave surfacing, honest 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> |
||
|
|
0d26072e6c |
feat(studio,core): mute groups, and hear-only-this that cannot reach 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>
|
||
|
|
a01a5d7b3c |
fix(studio): player UX — honest waveform, keyframe menu actions, beat-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> |
||
|
|
44791c3f7d |
fix(studio): sidebar/panels UX — asset delete confirm, rename, search 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> |
||
|
|
5fd84c395b |
feat(studio,core): a volume and a living meter on the group row (#3290)
* 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> |
||
|
|
99f42be04c |
feat(engine): render grouped audio through a summed, FX-processed bus (#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> |
||
|
|
485c037dcf |
feat(studio,lint): carve targets voiceover groups — always, when plural (#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> |
||
|
|
602cf53cf6 |
feat(core): route grouped audio through a group bus in preview (#3287)
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> |
||
|
|
ba607bf886 |
fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel (#1965)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
acfa7c55a2 |
feat(studio): group rows in the timeline, and a split disclosure (#3286)
* 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> |
||
|
|
5240367150 |
feat(core): the audio group model — element, membership, helpers (#3278)
* 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> |
||
|
|
5e0cc75115 |
feat(core,studio): the character presets pitch shift unlocks (#3277)
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> |
||
|
|
63eb35041c |
fix(deps): bump puppeteer so the browser hides its console window on 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 |
||
|
|
315a7b758c |
fix(engine): hide ffmpeg console windows on Windows (#3381)
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 |
||
|
|
a9ea07edde |
fix(cli): reject blank default composition entries (#3392)
* fix(cli): reject blank default composition entry * fix(cli): complete blank entry safeguards |
||
|
|
1b86b56127 |
feat(core): pitch shift — a granular shifter as the fifth FX worklet (#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> |
||
|
|
8f3ab60b5a |
fix(core,studio): silence hidden audio in preview, and call it mute (#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>
|
||
|
|
43c4e6935e |
feat(studio): make presets the primary path into the FX rack (#3274)
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>
|
||
|
|
556fe936f8 |
chore(cli): bump pinned Chromium to 152.0.7977.30 (#3231)
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. |
||
|
|
00f08e8de4 |
fix(producer): attach src URL to ffprobe failures for compile-phase attribution (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> |
||
|
|
13c867267e |
fix(producer): decode percent-encoded video src in HDR pre-extract (#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> |
||
|
|
7563b644a2 |
fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-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. |
||
|
|
24b3ebdf9f |
fix(cli): add missing cache fields to telemetry test fixture (#1915)
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> |
||
|
|
64b94ebf3a |
fix(studio): resume drag-paused timelines instead of only re-seeking (#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> |
||
|
|
7e3bcd9ef1 |
feat(producer): host/render telemetry in RenderPerfSummary (#1551)
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> |
||
|
|
1ff99a50f5 |
fix(core): play bounded WebAudio clips full-length at non-1x playback rate (#1494)
* fix(core): play bounded WebAudio clips full-length at non-1x playback rate startBoundedSource passed `clipDuration * rate` as start()'s duration arg, but that arg is buffer-content seconds while clipDuration is composition seconds. Media advances 1:1 with composition (the global rate scales the transport clock and the source playbackRate together), so the content to play is exactly clipDuration. Multiplying by rate truncated the clip at rate < 1 (audio cut out at the midpoint on half-speed playback) and overran it at rate > 1. Drop the multiply — playbackRate alone stretches the fixed content to the right wall time. Adds a half-speed regression test and corrects the prior test that asserted the rate-scaled (overrunning) bound. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): restore the mediaRate scaling on the WebAudio clip bound The bound this branch removed was correct. `start()`'s duration argument is buffer seconds, and an element with `data-playback-rate="2"` consumes two buffer seconds per composition second, so a `clipDuration`-second clip needs `clipDuration * mediaRate` of buffer. Wall time then works out as `(clipDuration * mediaRate) / (mediaRate * globalRate) = clipDuration / globalRate`, which is the transport duration that was wanted. Dropping the factor truncated authored 2x clips at their midpoint and overran authored 0.5x ones — and the sibling line still scaled `sourceElapsed` by mediaRate, so `remaining` mixed buffer with composition seconds and only landed right at mediaRate = 1. The branch's half-speed regression could not have caught this: it changed the GLOBAL rate on an element whose authored rate is 1, and the global rate cancels out (it scales the transport clock and the source's playbackRate together). Both formulas return 10 there, so the test passed before the change it was meant to justify. Replaced with the two cases that do discriminate — a clip authored at 2x and one at 0.5x, each asserting the buffer-second bound. Both fail if the factor is dropped again, as does the pre-existing authored-2x/global-0.5x contract test the removal was breaking. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
865f7fec52 |
chore: gitignore local chrome-for-testing downloads (#1451)
The chrome/ dir holds Chrome-for-Testing binaries (~220MB each) pulled locally for the drawElement fast-capture work. They must never be committed — GitHub rejects the >100MB framework binary. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> |
||
|
|
c09b0b9183 |
fix(core): prevent symlink path traversal in htmlBundler safePath (F-005) (#1214)
* fix(core): prevent symlink path traversal in htmlBundler safePath (F-005) safePath used resolve() for containment checking, which is lexical and does not follow symlinks. A symlink placed inside the project directory pointing at a file outside it would pass the startsWith(normalizedBase) check and expose arbitrary on-disk content to the bundle. Add isSymlinkWithinProject(), which calls realpathSync() on both the candidate and the project root and re-verifies containment after symlinks are resolved. safePath calls it after the lexical check; safeReadFile gains an optional projectDir parameter that triggers the same check when handling @import-resolved CSS paths (the @import code path bypasses safePath and reads the file directly, so the check is applied there instead). Both attack vectors are covered by new vitest tests that plant a symlink inside the project dir pointing at a file in a sibling tmpdir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): drop the redundant safeReadFile symlink guard, keep its regressions The branch's remaining production hunk called `isSymlinkWithinProject`, a helper that no longer exists: when four of the five original commits were cherry-picked to `main`, that helper was folded into `isSafePath`, which now canonicalizes both sides with `realpathSync`. So the leftover commit did not compile — `TS2304: Cannot find name 'isSymlinkWithinProject'` on Linux and Windows, with Test and regression failing downstream of the build. Swapping in `isSafePath(projectDir, filePath)` compiles, but measurably guards nothing: the sole caller that passes `projectDir` (`inlineCssFile`'s `@import` arm) runs `isSafePath(projectDir, resolved)` on the same path one line above, and both symlink regressions pass with the guard removed. A check no test can distinguish is weight, not defence, so the production delta goes and the tests stay — they now pin the `isSafePath` guard on both attack surfaces (`<link href>` through a symlink, and `@import` through one). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
36c7dffe5c | chore: release v0.8.6 (#3386) v0.8.6 | ||
|
|
a897806798 |
refactor(lint): remove the head_leaked_text rule (#3385)
The rule fired on legitimate content and blocked check. A prose CSS comment naming a tag, such as "the <body> rule below sets the base font", was enough: HEAD_CONTENT_PATTERN ends the head at the first <body> in raw source, so the token inside the comment truncated the capture mid-<style>. The unclosed style tag then defeated the strip-ignorable-blocks pass, and the stylesheet's own rules reached the orphan-CSS matcher, which reported a valid nearby rule as the leak. Removed rather than repaired. Across all 643 shipped registry files it fires zero times, so it has never caught anything real here, while producing at least one confirmed false positive that blocked a working cloud render. It is an error, not a warning, so the cost of a false positive is a blocked pipeline. Leaked text of this kind is also visible in the very first preview frame, which is a faster and more reliable signal than a regex over raw source. Takes its seven helpers and eight now-dead patterns with it, plus four orphaned test fixtures. VISIBLE_MARKUP_COMMENT_PATTERN is kept; it belongs to visible_markup_comment. Refs #3384 |
||
|
|
c056289d83 |
fix(audio): renumber timestamps between apad and atrim in mixed branches (#3380)
On FFmpeg 5.x through 8.0.x the samples `apad` appends carry timestamps the following `atrim` misreads. A delayed branch then sounds at t=0 instead of its offset and, once four or more branches are mixed, the last one disappears from the output entirely. No error is raised; the render succeeds with wrong audio. Reverting to `apad=whole_dur=` is not an option: #2769 moved off that form because some builds reject the option outright ("Error applying option 'whole_dur': Option not found"). Inserting `asetpts=N/SR/TB` between the pad and the trim rebuilds the timestamps from the sample count using only filters every build ships, so it fixes the misplacement without giving up the portability that change bought. Verified on FFmpeg 4.2.7, 7.0.2, an 8.x nightly and 8.1.1: the current form is wrong on the middle two, the new form is correct on all four. audioPadTrim.ts also pads with apad+atrim but has no adelay and is correct on every version tested, so it is left alone. Closes #3344 |
||
|
|
477e09642b | fix(core): replay ended audio after backward seeks (#3383) |