mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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.
This commit is contained in:
@@ -25,11 +25,11 @@ For exact versioned release notes, see the [Changelog](/changelog).
|
|||||||
/>
|
/>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
Audio is the week's work. A full effects and automation system landed in Studio, staged behind a canary at zero percent, so none of it is visible by default yet. Text became editable directly in the preview. The catalog gained new templates, and its previews were fixed in place. Rendered files now carry hidden provenance tags. Four releases shipped, v0.7.106 through v0.7.109.
|
Audio is the week's work. A full effects and automation system landed in Studio, staged behind a canary at zero percent at the time of writing; the rollout has since completed and it is on for everyone. Text became editable directly in the preview. The catalog gained new templates, and its previews were fixed in place. Rendered files now carry hidden provenance tags. Four releases shipped, v0.7.106 through v0.7.109.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **An audio effects rack in Studio, staged behind a flag.** Studio can put an effects rack on an audio track, with named presets, a multi-band EQ on faders, a voiceover carve, and a levelling script called Even Out Levels. The chain renders offline as well as in the preview, and an effect tail now decays past the end of its clip instead of being cut off. The rack sits behind the `audio-fx-rack` canary at zero percent, so it is not visible by default. The flag gates the authoring surface only: a composition that already carries `data-fx-chain` plays and renders it for everyone. Set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today. ([5752d2249](https://github.com/heygen-com/hyperframes/commit/5752d224929f00ac2fa2757df02339f781cdd6fa), [#3019](https://github.com/heygen-com/hyperframes/pull/3019), [2350f9b69](https://github.com/heygen-com/hyperframes/commit/2350f9b69b10c32ec12d0f84800133aa5173804e), [#3020](https://github.com/heygen-com/hyperframes/pull/3020), [cc40e35aa](https://github.com/heygen-com/hyperframes/commit/cc40e35aa0995c2731c4d34a4502d45927997a2d), [#3021](https://github.com/heygen-com/hyperframes/pull/3021), [c2996c862](https://github.com/heygen-com/hyperframes/commit/c2996c8626135db5253519359d8a063d3bafad8d), [#3022](https://github.com/heygen-com/hyperframes/pull/3022), [7d0d74dca](https://github.com/heygen-com/hyperframes/commit/7d0d74dcae1bd9110f64f6836101fc25e2146500), [#3177](https://github.com/heygen-com/hyperframes/pull/3177), [9f6762052](https://github.com/heygen-com/hyperframes/commit/9f676205239a4e1ce03498223ef9f6ce5448aeec), [#3178](https://github.com/heygen-com/hyperframes/pull/3178), [818a7b281](https://github.com/heygen-com/hyperframes/commit/818a7b281b44182b6a4e47b7f30f92df332f62a2), [#3179](https://github.com/heygen-com/hyperframes/pull/3179), [71066fc25](https://github.com/heygen-com/hyperframes/commit/71066fc25ba362a18d52ba0648b6b279e9ee3206), [#3180](https://github.com/heygen-com/hyperframes/pull/3180), [27cdd4d5b](https://github.com/heygen-com/hyperframes/commit/27cdd4d5b1684afae16f96fe3e4af30c73178e1a), [#3184](https://github.com/heygen-com/hyperframes/pull/3184), [31fa523b7](https://github.com/heygen-com/hyperframes/commit/31fa523b7ed71c6fbbc0c4b2558fb83bf636fc88), [#3185](https://github.com/heygen-com/hyperframes/pull/3185), [96fd4d061](https://github.com/heygen-com/hyperframes/commit/96fd4d061ecddef59e3949b4fb3e30d51209bd76), [#3186](https://github.com/heygen-com/hyperframes/pull/3186), [2020b82c9](https://github.com/heygen-com/hyperframes/commit/2020b82c9b8f2d2050b5b11c76bdb27323d64e07), [#3187](https://github.com/heygen-com/hyperframes/pull/3187), [29f516d49](https://github.com/heygen-com/hyperframes/commit/29f516d490c00697594f3b88a565d69115b7af12), [#3188](https://github.com/heygen-com/hyperframes/pull/3188), [acdb11250](https://github.com/heygen-com/hyperframes/commit/acdb11250ce3bb97a70cdd63d080968900a2f003), [#3189](https://github.com/heygen-com/hyperframes/pull/3189), [e3ec48adc](https://github.com/heygen-com/hyperframes/commit/e3ec48adce494d40d787cd4cbca6464a3b53362f), [#3191](https://github.com/heygen-com/hyperframes/pull/3191), [0e4da52c8](https://github.com/heygen-com/hyperframes/commit/0e4da52c8222b8d18a1211b34f2fb3bd0f7e79ee), [#3192](https://github.com/heygen-com/hyperframes/pull/3192), [d18cbcb7f](https://github.com/heygen-com/hyperframes/commit/d18cbcb7f31862a37b9051b85ebb562f104a516c), [#3213](https://github.com/heygen-com/hyperframes/pull/3213), [d6c4774ef](https://github.com/heygen-com/hyperframes/commit/d6c4774ef41c54f66dc02c6dd433facee822a8f8), [#3229](https://github.com/heygen-com/hyperframes/pull/3229), [6a0e409fc](https://github.com/heygen-com/hyperframes/commit/6a0e409fc2704683613b0a94cc734635dd9ded94), [#3036](https://github.com/heygen-com/hyperframes/pull/3036)).
|
- **An audio effects rack in Studio, staged behind a flag.** Studio can put an effects rack on an audio track, with named presets, a multi-band EQ on faders, a voiceover carve, and a levelling script called Even Out Levels. The chain renders offline as well as in the preview, and an effect tail now decays past the end of its clip instead of being cut off. The rack shipped behind the `audio-fx-rack` canary at zero percent, gating the authoring surface only — a composition that already carried `data-fx-chain` played and rendered it for everyone. That canary has since been retired and the rack is on by default, so `HF_CANARY_AUDIO_FX_RACK` no longer does anything. ([5752d2249](https://github.com/heygen-com/hyperframes/commit/5752d224929f00ac2fa2757df02339f781cdd6fa), [#3019](https://github.com/heygen-com/hyperframes/pull/3019), [2350f9b69](https://github.com/heygen-com/hyperframes/commit/2350f9b69b10c32ec12d0f84800133aa5173804e), [#3020](https://github.com/heygen-com/hyperframes/pull/3020), [cc40e35aa](https://github.com/heygen-com/hyperframes/commit/cc40e35aa0995c2731c4d34a4502d45927997a2d), [#3021](https://github.com/heygen-com/hyperframes/pull/3021), [c2996c862](https://github.com/heygen-com/hyperframes/commit/c2996c8626135db5253519359d8a063d3bafad8d), [#3022](https://github.com/heygen-com/hyperframes/pull/3022), [7d0d74dca](https://github.com/heygen-com/hyperframes/commit/7d0d74dcae1bd9110f64f6836101fc25e2146500), [#3177](https://github.com/heygen-com/hyperframes/pull/3177), [9f6762052](https://github.com/heygen-com/hyperframes/commit/9f676205239a4e1ce03498223ef9f6ce5448aeec), [#3178](https://github.com/heygen-com/hyperframes/pull/3178), [818a7b281](https://github.com/heygen-com/hyperframes/commit/818a7b281b44182b6a4e47b7f30f92df332f62a2), [#3179](https://github.com/heygen-com/hyperframes/pull/3179), [71066fc25](https://github.com/heygen-com/hyperframes/commit/71066fc25ba362a18d52ba0648b6b279e9ee3206), [#3180](https://github.com/heygen-com/hyperframes/pull/3180), [27cdd4d5b](https://github.com/heygen-com/hyperframes/commit/27cdd4d5b1684afae16f96fe3e4af30c73178e1a), [#3184](https://github.com/heygen-com/hyperframes/pull/3184), [31fa523b7](https://github.com/heygen-com/hyperframes/commit/31fa523b7ed71c6fbbc0c4b2558fb83bf636fc88), [#3185](https://github.com/heygen-com/hyperframes/pull/3185), [96fd4d061](https://github.com/heygen-com/hyperframes/commit/96fd4d061ecddef59e3949b4fb3e30d51209bd76), [#3186](https://github.com/heygen-com/hyperframes/pull/3186), [2020b82c9](https://github.com/heygen-com/hyperframes/commit/2020b82c9b8f2d2050b5b11c76bdb27323d64e07), [#3187](https://github.com/heygen-com/hyperframes/pull/3187), [29f516d49](https://github.com/heygen-com/hyperframes/commit/29f516d490c00697594f3b88a565d69115b7af12), [#3188](https://github.com/heygen-com/hyperframes/pull/3188), [acdb11250](https://github.com/heygen-com/hyperframes/commit/acdb11250ce3bb97a70cdd63d080968900a2f003), [#3189](https://github.com/heygen-com/hyperframes/pull/3189), [e3ec48adc](https://github.com/heygen-com/hyperframes/commit/e3ec48adce494d40d787cd4cbca6464a3b53362f), [#3191](https://github.com/heygen-com/hyperframes/pull/3191), [0e4da52c8](https://github.com/heygen-com/hyperframes/commit/0e4da52c8222b8d18a1211b34f2fb3bd0f7e79ee), [#3192](https://github.com/heygen-com/hyperframes/pull/3192), [d18cbcb7f](https://github.com/heygen-com/hyperframes/commit/d18cbcb7f31862a37b9051b85ebb562f104a516c), [#3213](https://github.com/heygen-com/hyperframes/pull/3213), [d6c4774ef](https://github.com/heygen-com/hyperframes/commit/d6c4774ef41c54f66dc02c6dd433facee822a8f8), [#3229](https://github.com/heygen-com/hyperframes/pull/3229), [6a0e409fc](https://github.com/heygen-com/hyperframes/commit/6a0e409fc2704683613b0a94cc734635dd9ded94), [#3036](https://github.com/heygen-com/hyperframes/pull/3036)).
|
||||||
- **Every effect parameter can be automated on its own lane.** You can draw a curve, snap it, type an exact value, select a time range, reshape or simplify that selection, retime and edge-stretch it, and copy it to another lane. Envelopes play in the preview and bake at render, and the panel shows every automated knob at the playhead. This is part of the same staged rack, behind the same flag. ([9b0c5e855](https://github.com/heygen-com/hyperframes/commit/9b0c5e85596efaf93823bf5f19b7f1d1216ca7d5), [#3014](https://github.com/heygen-com/hyperframes/pull/3014), [fd97926cf](https://github.com/heygen-com/hyperframes/commit/fd97926cf67e860330624072050043c2bc03ccfe), [#3015](https://github.com/heygen-com/hyperframes/pull/3015), [23ba58cfd](https://github.com/heygen-com/hyperframes/commit/23ba58cfd33a5a5ec627e2e88d65da2cbc43a55c), [#3016](https://github.com/heygen-com/hyperframes/pull/3016), [3cfacf440](https://github.com/heygen-com/hyperframes/commit/3cfacf440d4d0631618df48ed50cd06f42922db6), [#3023](https://github.com/heygen-com/hyperframes/pull/3023), [0d3c0246d](https://github.com/heygen-com/hyperframes/commit/0d3c0246d00fe1cfb33492581fc06c5d0abf17ba), [#3024](https://github.com/heygen-com/hyperframes/pull/3024), [7203e0a73](https://github.com/heygen-com/hyperframes/commit/7203e0a73ca9f51cde74f0820bc31fed16454972), [#3025](https://github.com/heygen-com/hyperframes/pull/3025), [32f29721d](https://github.com/heygen-com/hyperframes/commit/32f29721d2fd9ab7e85c0cf87108aa2c1b9859a9), [#3026](https://github.com/heygen-com/hyperframes/pull/3026), [e465a07a3](https://github.com/heygen-com/hyperframes/commit/e465a07a322db8a63fc757bda5a53a842a0c269f), [#3038](https://github.com/heygen-com/hyperframes/pull/3038), [3670e9a92](https://github.com/heygen-com/hyperframes/commit/3670e9a921ddcbcd84e5d448faec5b08fca0ba40), [#3050](https://github.com/heygen-com/hyperframes/pull/3050), [64a85e775](https://github.com/heygen-com/hyperframes/commit/64a85e775a2259938e24d2fc7dc279c402397761), [#3055](https://github.com/heygen-com/hyperframes/pull/3055), [c7c95e2f0](https://github.com/heygen-com/hyperframes/commit/c7c95e2f018efa961b7a5e06fb142a98fd14ecc9), [#3056](https://github.com/heygen-com/hyperframes/pull/3056), [120ea37c2](https://github.com/heygen-com/hyperframes/commit/120ea37c2fbd7202a443b94c0ff21c1ee7a63996), [#3207](https://github.com/heygen-com/hyperframes/pull/3207), [7d38fed97](https://github.com/heygen-com/hyperframes/commit/7d38fed97fd50f70e5a67229250a186baac98d3c), [#3210](https://github.com/heygen-com/hyperframes/pull/3210), [9b18fa7a2](https://github.com/heygen-com/hyperframes/commit/9b18fa7a26bd5e7b4f8b4b4e10c984585767620b), [#3214](https://github.com/heygen-com/hyperframes/pull/3214)).
|
- **Every effect parameter can be automated on its own lane.** You can draw a curve, snap it, type an exact value, select a time range, reshape or simplify that selection, retime and edge-stretch it, and copy it to another lane. Envelopes play in the preview and bake at render, and the panel shows every automated knob at the playhead. This is part of the same staged rack, behind the same flag. ([9b0c5e855](https://github.com/heygen-com/hyperframes/commit/9b0c5e85596efaf93823bf5f19b7f1d1216ca7d5), [#3014](https://github.com/heygen-com/hyperframes/pull/3014), [fd97926cf](https://github.com/heygen-com/hyperframes/commit/fd97926cf67e860330624072050043c2bc03ccfe), [#3015](https://github.com/heygen-com/hyperframes/pull/3015), [23ba58cfd](https://github.com/heygen-com/hyperframes/commit/23ba58cfd33a5a5ec627e2e88d65da2cbc43a55c), [#3016](https://github.com/heygen-com/hyperframes/pull/3016), [3cfacf440](https://github.com/heygen-com/hyperframes/commit/3cfacf440d4d0631618df48ed50cd06f42922db6), [#3023](https://github.com/heygen-com/hyperframes/pull/3023), [0d3c0246d](https://github.com/heygen-com/hyperframes/commit/0d3c0246d00fe1cfb33492581fc06c5d0abf17ba), [#3024](https://github.com/heygen-com/hyperframes/pull/3024), [7203e0a73](https://github.com/heygen-com/hyperframes/commit/7203e0a73ca9f51cde74f0820bc31fed16454972), [#3025](https://github.com/heygen-com/hyperframes/pull/3025), [32f29721d](https://github.com/heygen-com/hyperframes/commit/32f29721d2fd9ab7e85c0cf87108aa2c1b9859a9), [#3026](https://github.com/heygen-com/hyperframes/pull/3026), [e465a07a3](https://github.com/heygen-com/hyperframes/commit/e465a07a322db8a63fc757bda5a53a842a0c269f), [#3038](https://github.com/heygen-com/hyperframes/pull/3038), [3670e9a92](https://github.com/heygen-com/hyperframes/commit/3670e9a921ddcbcd84e5d448faec5b08fca0ba40), [#3050](https://github.com/heygen-com/hyperframes/pull/3050), [64a85e775](https://github.com/heygen-com/hyperframes/commit/64a85e775a2259938e24d2fc7dc279c402397761), [#3055](https://github.com/heygen-com/hyperframes/pull/3055), [c7c95e2f0](https://github.com/heygen-com/hyperframes/commit/c7c95e2f018efa961b7a5e06fb142a98fd14ecc9), [#3056](https://github.com/heygen-com/hyperframes/pull/3056), [120ea37c2](https://github.com/heygen-com/hyperframes/commit/120ea37c2fbd7202a443b94c0ff21c1ee7a63996), [#3207](https://github.com/heygen-com/hyperframes/pull/3207), [7d38fed97](https://github.com/heygen-com/hyperframes/commit/7d38fed97fd50f70e5a67229250a186baac98d3c), [#3210](https://github.com/heygen-com/hyperframes/pull/3210), [9b18fa7a2](https://github.com/heygen-com/hyperframes/commit/9b18fa7a26bd5e7b4f8b4b4e10c984585767620b), [#3214](https://github.com/heygen-com/hyperframes/pull/3214)).
|
||||||
- **Edit and style text directly in the preview.** A style can be applied to a run of characters, and the swatch shows every colour present in a mixed selection. Rich text is sanitized on the way into a composition. ([636dc042a](https://github.com/heygen-com/hyperframes/commit/636dc042a7b773d9038b6bea589b4118051764c5), [#3141](https://github.com/heygen-com/hyperframes/pull/3141), [cb73c8dc2](https://github.com/heygen-com/hyperframes/commit/cb73c8dc2e0d9c6b911044de0e0d7fb5fbb49752), [#3142](https://github.com/heygen-com/hyperframes/pull/3142), [4cc46f5f9](https://github.com/heygen-com/hyperframes/commit/4cc46f5f9f6ba063bcebcc663a9b04c008f119d4), [#3143](https://github.com/heygen-com/hyperframes/pull/3143), [896bc336a](https://github.com/heygen-com/hyperframes/commit/896bc336a2eb058fa7cd21e6686b3c6a23879d28), [#3144](https://github.com/heygen-com/hyperframes/pull/3144)).
|
- **Edit and style text directly in the preview.** A style can be applied to a run of characters, and the swatch shows every colour present in a mixed selection. Rich text is sanitized on the way into a composition. ([636dc042a](https://github.com/heygen-com/hyperframes/commit/636dc042a7b773d9038b6bea589b4118051764c5), [#3141](https://github.com/heygen-com/hyperframes/pull/3141), [cb73c8dc2](https://github.com/heygen-com/hyperframes/commit/cb73c8dc2e0d9c6b911044de0e0d7fb5fbb49752), [#3142](https://github.com/heygen-com/hyperframes/pull/3142), [4cc46f5f9](https://github.com/heygen-com/hyperframes/commit/4cc46f5f9f6ba063bcebcc663a9b04c008f119d4), [#3143](https://github.com/heygen-com/hyperframes/pull/3143), [896bc336a](https://github.com/heygen-com/hyperframes/commit/896bc336a2eb058fa7cd21e6686b3c6a23879d28), [#3144](https://github.com/heygen-com/hyperframes/pull/3144)).
|
||||||
- **Rendered files carry hidden provenance tags.** A render stamps the renderer name and version into the container metadata, and it survives the mux, concat and faststart stages that used to drop it. The tags are unsigned and anyone can write them, so treat a tag as a hint about where a file came from rather than proof of it. ([f7d2260f9](https://github.com/heygen-com/hyperframes/commit/f7d2260f9d8edb33e708138067a0eaf393c1f214), [#3264](https://github.com/heygen-com/hyperframes/pull/3264)).
|
- **Rendered files carry hidden provenance tags.** A render stamps the renderer name and version into the container metadata, and it survives the mux, concat and faststart stages that used to drop it. The tags are unsigned and anyone can write them, so treat a tag as a hint about where a file came from rather than proof of it. ([f7d2260f9](https://github.com/heygen-com/hyperframes/commit/f7d2260f9d8edb33e708138067a0eaf393c1f214), [#3264](https://github.com/heygen-com/hyperframes/pull/3264)).
|
||||||
|
|||||||
@@ -428,27 +428,26 @@ describe("canary reason property", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The audio FX rack ships dark.
|
* The audio features ship to everyone.
|
||||||
*
|
*
|
||||||
* Pinned as a test rather than trusted to review: the registry's own procedure
|
* They landed on main across twelve PRs while all three of their canaries sat
|
||||||
* is "start at percentage: 0 and merge that", and the whole point of landing a
|
* at 0%, which meant the FX rack, the group rows, mute and solo were present in
|
||||||
* 47-PR stack behind a canary is defeated if the entry reaches main at anything
|
* the build and reachable by nobody. Pinned as a test because the failure mode
|
||||||
* else. A ramp is a deliberate edit to this number, and it should have to break
|
* is silent: re-registering one of these re-hides a shipped feature, and
|
||||||
* a test that says so.
|
* nothing else would say so.
|
||||||
*/
|
*/
|
||||||
describe("the audio-fx-rack canary", () => {
|
describe("the audio rollout", () => {
|
||||||
const entry = CANARIES.find((c) => c.name === "audio-fx-rack");
|
// The three names by hand, because that is the assertion the intent needs: a
|
||||||
|
// prefix check catches `audio-fx-rack` coming back but not `fx-rack` or
|
||||||
it("is registered", () => {
|
// `audiofx-rack`, and it is the specific feature being re-hidden that matters.
|
||||||
expect(entry, "audio-fx-rack missing from the registry").toBeDefined();
|
it("registers none of the three retired audio canaries", () => {
|
||||||
|
const retired = ["audio-fx-rack", "audio-track-mute", "audio-groups"];
|
||||||
|
expect(retired.filter((name) => findCanary(name))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ships at 0%", () => {
|
// The family guard, kept alongside it: a fourth audio canary would gate a
|
||||||
expect(entry?.percentage).toBe(0);
|
// feature that now ships to everyone.
|
||||||
});
|
it("registers no audio canary at all", () => {
|
||||||
|
expect(CANARIES.filter((c) => c.name.startsWith("audio-")).map((c) => c.name)).toEqual([]);
|
||||||
it("carries a sunset date, so the fork cannot outlive the rollout", () => {
|
|
||||||
expect(entry?.sunsetAfter).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
||||||
expect(Date.parse(`${entry?.sunsetAfter}T00:00:00Z`)).toBeGreaterThan(Date.parse("2026-08-12"));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,36 +80,6 @@ export const CANARIES: readonly CanaryDefinition[] = [
|
|||||||
owner: "vance",
|
owner: "vance",
|
||||||
sunsetAfter: "2026-09-15",
|
sunsetAfter: "2026-09-15",
|
||||||
},
|
},
|
||||||
// ── Audio FX ─────────────────────────────────────────────────────────────
|
|
||||||
{
|
|
||||||
name: "audio-fx-rack",
|
|
||||||
percentage: 0,
|
|
||||||
description:
|
|
||||||
"Show the Audio FX rack on audio tracks in Studio — effects, presets, the voiceover carve and levelling. Gates the AUTHORING surface only: a composition that already carries data-fx-chain still plays and renders it, because a canary should stage who can reach a feature, not make a written attribute silently inert.",
|
|
||||||
owner: "vance",
|
|
||||||
sunsetAfter: "2026-11-15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "audio-track-mute",
|
|
||||||
percentage: 0,
|
|
||||||
description:
|
|
||||||
"Label the visibility control as Mute on audio tracks, and make preview " +
|
|
||||||
"silence data-hidden audio the way the render already does. Fixes a " +
|
|
||||||
"shipped preview/export mismatch, so it is gated separately.",
|
|
||||||
owner: "vance",
|
|
||||||
sunsetAfter: "2026-12-15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "audio-groups",
|
|
||||||
percentage: 0,
|
|
||||||
description:
|
|
||||||
"Group audio tracks under a shared label, FX chain, and automation " +
|
|
||||||
"clock. Gates the Studio UI for creating and managing groups; the " +
|
|
||||||
"underlying <hf-audio-group> element and data-audio-group membership " +
|
|
||||||
"parse and play regardless of enrollment.",
|
|
||||||
owner: "vance",
|
|
||||||
sunsetAfter: "2027-01-15",
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function findCanary(name: string): CanaryDefinition | undefined {
|
export function findCanary(name: string): CanaryDefinition | undefined {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
|||||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||||
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
||||||
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
||||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
|
||||||
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
||||||
import { useVolumeAutomation } from "./useVolumeAutomation";
|
import { useVolumeAutomation } from "./useVolumeAutomation";
|
||||||
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
||||||
@@ -428,10 +427,7 @@ export function PropertyPanelFlat({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Behind `audio-fx-rack`, at 0%. Gates the AUTHORING surface only: the runtime
|
if (sections.audioFx) {
|
||||||
// and render still honour a `data-fx-chain` already on an element, so a
|
|
||||||
// composition written through the skill does not go silently dry off-cohort.
|
|
||||||
if (sections.audioFx && isCanaryEnabled("audio-fx-rack")) {
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: "audio-fx",
|
id: "audio-fx",
|
||||||
title: "Audio FX",
|
title: "Audio FX",
|
||||||
|
|||||||
@@ -223,6 +223,37 @@ describe("toggleTimelineTrackHidden", () => {
|
|||||||
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The header derives its row from the GROUP-AWARE order (a synthetic anchor
|
||||||
|
// row, members pulled contiguous); this function's own fallback derives it
|
||||||
|
// from ascending element-bearing keys. Once a group exists those disagree, so
|
||||||
|
// the same click announced "Mute track 2" and recorded "Mute track 1". The
|
||||||
|
// clicked control passes the row it showed, and that is what gets recorded.
|
||||||
|
it("records the display row the clicked control announced, not its own derivation", async () => {
|
||||||
|
const files = new Map([
|
||||||
|
["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
|
||||||
|
]);
|
||||||
|
stubProjectFiles(files);
|
||||||
|
|
||||||
|
const recordEdit = vi.fn();
|
||||||
|
|
||||||
|
await toggleTimelineTrackHidden({
|
||||||
|
projectId: "project-1",
|
||||||
|
activeCompPath: "index.html",
|
||||||
|
timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
|
||||||
|
track: 0,
|
||||||
|
hidden: true,
|
||||||
|
// Row 2 in the header: the group's anchor row sits above this member.
|
||||||
|
displayNumber: 2,
|
||||||
|
previewIframe: null,
|
||||||
|
writeProjectFile: async () => {},
|
||||||
|
recordEdit,
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set() },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 2");
|
||||||
|
});
|
||||||
|
|
||||||
it("labels unmuting an audio-only track back on", async () => {
|
it("labels unmuting an audio-only track back on", async () => {
|
||||||
const files = new Map([
|
const files = new Map([
|
||||||
["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
|
["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ interface ToggleTimelineTrackHiddenInput {
|
|||||||
timelineElements: readonly TimelineElement[];
|
timelineElements: readonly TimelineElement[];
|
||||||
track: number;
|
track: number;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
/**
|
||||||
|
* The display row the clicked control announced, when the caller has one.
|
||||||
|
* Absent (a keyboard path or a programmatic call), the row is derived here
|
||||||
|
* from ascending element-bearing keys — correct whenever no group has
|
||||||
|
* reordered the header.
|
||||||
|
*/
|
||||||
|
displayNumber?: number | null;
|
||||||
previewIframe: HTMLIFrameElement | null;
|
previewIframe: HTMLIFrameElement | null;
|
||||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||||
@@ -209,6 +216,7 @@ export async function toggleTimelineTrackHidden({
|
|||||||
timelineElements,
|
timelineElements,
|
||||||
track,
|
track,
|
||||||
hidden,
|
hidden,
|
||||||
|
displayNumber,
|
||||||
previewIframe,
|
previewIframe,
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
recordEdit,
|
recordEdit,
|
||||||
@@ -216,9 +224,12 @@ export async function toggleTimelineTrackHidden({
|
|||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
||||||
// `track` is the fractional sort key the callback needs; the history entry is
|
// `track` is the fractional sort key the callback needs; the history entry is
|
||||||
// read by a human, so it gets the display row instead.
|
// read by a human, so it gets the display row instead — the one the clicked
|
||||||
|
// control announced, when the caller passed it. Deriving it again here would
|
||||||
|
// use ascending element-bearing keys, which stop matching the header as soon
|
||||||
|
// as an audio group reorders the rows and inserts an anchor.
|
||||||
const suffix = trackDisplaySuffix(
|
const suffix = trackDisplaySuffix(
|
||||||
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
||||||
);
|
);
|
||||||
const trackElements = timelineElements.filter((element) => element.track === track);
|
const trackElements = timelineElements.filter((element) => element.track === track);
|
||||||
const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
|
const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
|
||||||
@@ -397,14 +408,18 @@ export function useTimelineTrackVisibilityEditing({
|
|||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
forceReloadSdkSession,
|
forceReloadSdkSession,
|
||||||
}: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
|
}: UseTimelineTrackVisibilityEditingInput): (
|
||||||
|
track: number,
|
||||||
|
hidden: boolean,
|
||||||
|
displayNumber?: number | null,
|
||||||
|
) => Promise<void> {
|
||||||
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
|
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
|
||||||
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
||||||
// so filtering the raw store list by a virtual track number would hide the wrong
|
// so filtering the raw store list by a virtual track number would hide the wrong
|
||||||
// outer-scene sibling sharing that index.
|
// outer-scene sibling sharing that index.
|
||||||
const expandedElements = useExpandedTimelineElements();
|
const expandedElements = useExpandedTimelineElements();
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (track: number, hidden: boolean) => {
|
async (track: number, hidden: boolean, displayNumber?: number | null) => {
|
||||||
if (isRecordingRef?.current) {
|
if (isRecordingRef?.current) {
|
||||||
showToast("Cannot edit timeline while recording", "error");
|
showToast("Cannot edit timeline while recording", "error");
|
||||||
return;
|
return;
|
||||||
@@ -418,6 +433,7 @@ export function useTimelineTrackVisibilityEditing({
|
|||||||
timelineElements: expandedElements,
|
timelineElements: expandedElements,
|
||||||
track,
|
track,
|
||||||
hidden,
|
hidden,
|
||||||
|
displayNumber,
|
||||||
previewIframe: previewIframeRef.current,
|
previewIframe: previewIframeRef.current,
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
recordEdit,
|
recordEdit,
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ describe("Timeline provider boundary", () => {
|
|||||||
const row = button.parentElement?.parentElement;
|
const row = button.parentElement?.parentElement;
|
||||||
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
||||||
const trackContent = row?.children.item(1);
|
const trackContent = row?.children.item(1);
|
||||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
|
||||||
expect(trackContent).toBeInstanceOf(HTMLElement);
|
expect(trackContent).toBeInstanceOf(HTMLElement);
|
||||||
if (!(trackContent instanceof HTMLElement)) {
|
if (!(trackContent instanceof HTMLElement)) {
|
||||||
throw new Error("Expected track content element");
|
throw new Error("Expected track content element");
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ describe("TimelineLanes track numbering", () => {
|
|||||||
const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
|
const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
|
||||||
act(() => second?.click());
|
act(() => second?.click());
|
||||||
|
|
||||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
|
// Third argument is the display row the button announced, so the undo entry
|
||||||
|
// records the same row the user just read off it.
|
||||||
|
expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true, 2);
|
||||||
act(() => view.root.unmount());
|
act(() => view.root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,8 @@ describe("TimelineTrackHeader", () => {
|
|||||||
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
||||||
|
|
||||||
act(() => eye?.click());
|
act(() => eye?.click());
|
||||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
|
// The real fractional key acts; the display row rides along for the label.
|
||||||
|
expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true, 1);
|
||||||
act(() => view.root.unmount());
|
act(() => view.root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visibility control's accessible contract.
|
||||||
|
*
|
||||||
|
* The audio wording ran behind the `audio-track-mute` canary at 0%, so it had
|
||||||
|
* never rendered in any suite: it returned "Muted" / "Mute", which named the
|
||||||
|
* CURRENT state rather than the action, and dropped the track suffix so every
|
||||||
|
* audio row shared one accessible name. Music plus VO is the ordinary case, so
|
||||||
|
* that is two identical buttons. Pinned here because the label and the icon are
|
||||||
|
* the whole identity of this control.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PlainTrackHeader, VisibilityButton } from "./TimelineTrackPlainHeader";
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderButton(props: {
|
||||||
|
hidden: boolean;
|
||||||
|
isAudioTrack?: boolean;
|
||||||
|
trackDisplayNumber: number | null;
|
||||||
|
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
React.createElement(VisibilityButton, {
|
||||||
|
hidden: props.hidden,
|
||||||
|
trackNumber: 7,
|
||||||
|
trackDisplayNumber: props.trackDisplayNumber,
|
||||||
|
visible: true,
|
||||||
|
isAudioTrack: props.isAudioTrack,
|
||||||
|
onToggle,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { host, unmount: () => act(() => root.unmount()), onToggle };
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
|
||||||
|
|
||||||
|
describe("VisibilityButton", () => {
|
||||||
|
it("names the action, not the state, on an audible audio track", () => {
|
||||||
|
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
|
||||||
|
expect(labelOf(view.host)).toBe("Mute track 2");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The half that was wrong: a muted row read "Muted", so nothing told a
|
||||||
|
// screen-reader user that activating it would unmute.
|
||||||
|
it("promises the un-mute when the audio track is already muted", () => {
|
||||||
|
const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
|
||||||
|
expect(labelOf(view.host)).toBe("Unmute track 2");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
|
||||||
|
const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
|
||||||
|
const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
|
||||||
|
expect(labelOf(first.host)).toBe("Mute track 1");
|
||||||
|
expect(labelOf(second.host)).toBe("Mute track 3");
|
||||||
|
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
|
||||||
|
first.unmount();
|
||||||
|
second.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still says Hide/Show on a visual track", () => {
|
||||||
|
const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
|
||||||
|
expect(labelOf(shown.host)).toBe("Hide track 2");
|
||||||
|
shown.unmount();
|
||||||
|
const hiddenRow = renderButton({ hidden: true, trackDisplayNumber: 2 });
|
||||||
|
expect(labelOf(hiddenRow.host)).toBe("Show track 2");
|
||||||
|
hiddenRow.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The callback acts on the REAL track key; the display row rides along so the
|
||||||
|
// undo-history label announces the same row this button just did, instead of
|
||||||
|
// re-deriving it from an ordering that has no group anchors in it.
|
||||||
|
it("toggles the real track number and passes the row it announced", () => {
|
||||||
|
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
|
||||||
|
view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PlainTrackHeader", () => {
|
||||||
|
function renderHeader(overrides: Partial<Parameters<typeof PlainTrackHeader>[0]> = {}) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const onToggleSolo = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
root.render(
|
||||||
|
React.createElement(PlainTrackHeader, {
|
||||||
|
trackNumber: 0,
|
||||||
|
trackDisplayNumber: 1,
|
||||||
|
trackLabel: "Voiceover",
|
||||||
|
clipCount: 1,
|
||||||
|
isTrackHidden: false,
|
||||||
|
isAudioTrack: true,
|
||||||
|
onToggleTrackHidden: vi.fn(),
|
||||||
|
showTrackLabel: true,
|
||||||
|
isGroupMuted: false,
|
||||||
|
isSoloed: false,
|
||||||
|
onToggleSolo,
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { host, unmount: () => act(() => root.unmount()), onToggleSolo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const soloButton = (host: HTMLElement) =>
|
||||||
|
host.querySelector('button[aria-label="Hear only this"]');
|
||||||
|
const labelSpan = (host: HTMLElement) => host.querySelector("span.min-w-0");
|
||||||
|
|
||||||
|
it("offers solo on an audio track and reports its pressed state", () => {
|
||||||
|
const view = renderHeader({ isSoloed: true });
|
||||||
|
expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds solo from a visual track", () => {
|
||||||
|
const view = renderHeader({ isAudioTrack: false });
|
||||||
|
expect(soloButton(view.host)).toBeNull();
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A group-muted member is silent without being hidden itself, so the strike
|
||||||
|
// is the only thing that says so — and the title has to explain why, since a
|
||||||
|
// user who never touched THIS row's mute is looking for the reason.
|
||||||
|
it("strikes the label through when the row's own mute is on", () => {
|
||||||
|
const view = renderHeader({ isTrackHidden: true });
|
||||||
|
expect(labelSpan(view.host)?.className).toContain("line-through");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strikes it through for a group mute too, and says which", () => {
|
||||||
|
const view = renderHeader({ isGroupMuted: true });
|
||||||
|
expect(labelSpan(view.host)?.className).toContain("line-through");
|
||||||
|
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an audible row unstruck", () => {
|
||||||
|
const view = renderHeader();
|
||||||
|
expect(labelSpan(view.host)?.className).not.toContain("line-through");
|
||||||
|
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
|
||||||
|
view.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
|
||||||
import { Music } from "../../icons/SystemIcons";
|
import { Music } from "../../icons/SystemIcons";
|
||||||
import { TimelineSoloButton } from "./TimelineSoloButton";
|
import { TimelineSoloButton } from "./TimelineSoloButton";
|
||||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||||
import { TrackClipCount } from "./TrackClipCount";
|
import { TrackClipCount } from "./TrackClipCount";
|
||||||
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||||
|
|
||||||
// Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
/**
|
||||||
// Gated: the relabel ships behind the canary, unlike the preview fix.
|
* Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
|
||||||
|
*
|
||||||
|
* Action-phrased and per-track, like the visual branch: `hidden` here is the
|
||||||
|
* CURRENT state, so the name has to promise the opposite. It read "Muted" /
|
||||||
|
* "Mute" until the rollout, which named the state instead of the action (a
|
||||||
|
* screen-reader user could not tell that activating an already-muted row would
|
||||||
|
* unmute it) and dropped `suffix`, so every audio row shared one accessible
|
||||||
|
* name — music plus VO being the ordinary case. The wording matches the undo
|
||||||
|
* entry `timelineTrackVisibility` writes for the same click, where `hidden` is
|
||||||
|
* the INCOMING state and the two therefore read inverted.
|
||||||
|
*/
|
||||||
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
||||||
if (showAsMute) return hidden ? "Muted" : "Mute";
|
if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
|
||||||
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +46,7 @@ export function VisibilityButton({
|
|||||||
// Display number in the text, real key in the callback. The two must not be
|
// Display number in the text, real key in the callback. The two must not be
|
||||||
// conflated in either direction.
|
// conflated in either direction.
|
||||||
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
||||||
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
|
const showAsMute = Boolean(isAudioTrack);
|
||||||
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -50,7 +59,7 @@ export function VisibilityButton({
|
|||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void onToggle?.(trackNumber, !hidden);
|
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibilityButtonIcon(showAsMute, hidden)}
|
{visibilityButtonIcon(showAsMute, hidden)}
|
||||||
@@ -93,9 +102,7 @@ export function PlainTrackHeader({
|
|||||||
{showTrackLabel && (
|
{showTrackLabel && (
|
||||||
<span
|
<span
|
||||||
className={`min-w-0 flex-1 truncate text-[11px] ${
|
className={`min-w-0 flex-1 truncate text-[11px] ${
|
||||||
isAudioTrack && (isTrackHidden || isGroupMuted) && isCanaryEnabled("audio-track-mute")
|
isAudioTrack && (isTrackHidden || isGroupMuted) ? "line-through" : ""
|
||||||
? "line-through"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
|
||||||
>
|
>
|
||||||
@@ -111,7 +118,7 @@ export function PlainTrackHeader({
|
|||||||
isAudioTrack={isAudioTrack}
|
isAudioTrack={isAudioTrack}
|
||||||
onToggle={onToggleTrackHidden}
|
onToggle={onToggleTrackHidden}
|
||||||
/>
|
/>
|
||||||
{isAudioTrack && isCanaryEnabled("audio-track-mute") && onToggleSolo && (
|
{isAudioTrack && onToggleSolo && (
|
||||||
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -67,7 +67,20 @@ export interface TimelineEditCallbacks {
|
|||||||
}>,
|
}>,
|
||||||
options?: { coalesceKey?: string },
|
options?: { coalesceKey?: string },
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
/**
|
||||||
|
* `displayNumber` is the row the CLICKED control announced. It travels with
|
||||||
|
* the click because the header and the undo-history label derive the row from
|
||||||
|
* two different orderings: the header's comes from the group-aware row list
|
||||||
|
* (synthetic anchor rows, members pulled contiguous), the history's from a
|
||||||
|
* plain ascending sort of element-bearing keys. Once a group exists those
|
||||||
|
* disagree, so the same click said "Mute track 2" and recorded "Mute track 1".
|
||||||
|
* Passing the rendered number keeps one answer instead of two derivations.
|
||||||
|
*/
|
||||||
|
onToggleTrackHidden?: (
|
||||||
|
track: number,
|
||||||
|
hidden: boolean,
|
||||||
|
displayNumber?: number | null,
|
||||||
|
) => Promise<void> | void;
|
||||||
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
||||||
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
||||||
/** ...and persist one undo entry on release. */
|
/** ...and persist one undo entry on release. */
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row derivation for audio groups.
|
||||||
|
*
|
||||||
|
* This ran behind the `audio-groups` canary at 0% until the rollout, so it had
|
||||||
|
* never executed in the enabled state in any suite — the off-cohort branch
|
||||||
|
* returned raw tracks and stopped. Now it reorders rows and emits synthetic
|
||||||
|
* anchor rows for every user, which is exactly the pair of invariants pinned
|
||||||
|
* here: an ungrouped project is untouched, and a group's members become
|
||||||
|
* contiguous under an anchor at `memberTracks[0] - 0.5`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||||
|
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||||
|
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clip(id: string, track: number, extra: Partial<TimelineElement> = {}): TimelineElement {
|
||||||
|
return { id, label: id, tag: "audio", start: 0, duration: 1, track, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function derive(elements: TimelineElement[]): ReturnType<typeof useTimelineTrackDerivations> {
|
||||||
|
let result: ReturnType<typeof useTimelineTrackDerivations> | undefined;
|
||||||
|
function Probe() {
|
||||||
|
result = useTimelineTrackDerivations(elements);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const root = createRoot(document.createElement("div"));
|
||||||
|
act(() => root.render(React.createElement(Probe)));
|
||||||
|
act(() => root.unmount());
|
||||||
|
if (!result) throw new Error("derivations did not render");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTimelineTrackDerivations", () => {
|
||||||
|
it("leaves an ungrouped project in raw ascending order", () => {
|
||||||
|
const { tracks, groups, trackGroupOf } = derive([clip("b", 2), clip("a", 0), clip("c", 1)]);
|
||||||
|
|
||||||
|
expect(tracks.map(([track]) => track)).toEqual([0, 1, 2]);
|
||||||
|
expect(tracks.map(([, els]) => els.map((el) => el.id))).toEqual([["a"], ["c"], ["b"]]);
|
||||||
|
expect(groups).toEqual([]);
|
||||||
|
expect(trackGroupOf.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reordering case: track 1 sits between the group's two members in raw
|
||||||
|
// order and must not be dragged into the group.
|
||||||
|
it("pulls interleaved members contiguous under a synthetic anchor row", () => {
|
||||||
|
const { tracks, groups, trackGroupOf } = derive([
|
||||||
|
clip("vo-1", 0, { audioGroup: "voiceover", audioGroupLabel: "Voiceover" }),
|
||||||
|
clip("music", 1),
|
||||||
|
clip("vo-2", 2, { audioGroup: "voiceover" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Anchor immediately above the lowest member, then both members, then the
|
||||||
|
// ungrouped track keeps its own position after them.
|
||||||
|
expect(tracks.map(([track]) => track)).toEqual([-0.5, 0, 2, 1]);
|
||||||
|
// The anchor row owns no clips of its own.
|
||||||
|
expect(tracks[0]?.[1]).toEqual([]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
id: "voiceover",
|
||||||
|
label: "Voiceover",
|
||||||
|
anchorKey: -0.5,
|
||||||
|
memberTracks: [0, 2],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trackGroupOf.get(0)?.id).toBe("voiceover");
|
||||||
|
expect(trackGroupOf.get(2)?.id).toBe("voiceover");
|
||||||
|
expect(trackGroupOf.has(1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the group element's own label, volume and mute onto the row", () => {
|
||||||
|
const { groups } = derive([
|
||||||
|
clip("vo-1", 3, {
|
||||||
|
audioGroup: "vo",
|
||||||
|
audioGroupLabel: "VO bus",
|
||||||
|
audioGroupVolume: 0.5,
|
||||||
|
audioGroupHidden: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
label: "VO bus",
|
||||||
|
anchorKey: 2.5,
|
||||||
|
volume: 0.5,
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the group id and unity volume when the bus carries neither", () => {
|
||||||
|
const { groups } = derive([clip("sfx-1", 0, { audioGroup: "sfx" })]);
|
||||||
|
expect(groups[0]).toMatchObject({ id: "sfx", label: "sfx", volume: 1, hidden: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
|
||||||
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
|
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
|
||||||
|
|
||||||
/** One resolved audio group, positioned in the row order. */
|
/** One resolved audio group, positioned in the row order. */
|
||||||
@@ -157,16 +156,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
|||||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
||||||
}, [expandedElements]);
|
}, [expandedElements]);
|
||||||
|
|
||||||
const { tracks, groups, trackGroupOf } = useMemo(() => {
|
const { tracks, groups, trackGroupOf } = useMemo(
|
||||||
if (!isCanaryEnabled("audio-groups")) {
|
() => groupTimelineTracks(rawTracks),
|
||||||
return {
|
[rawTracks],
|
||||||
tracks: rawTracks,
|
);
|
||||||
groups: [],
|
|
||||||
trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return groupTimelineTracks(rawTracks);
|
|
||||||
}, [rawTracks]);
|
|
||||||
|
|
||||||
const trackStyles = useMemo(() => {
|
const trackStyles = useMemo(() => {
|
||||||
const map = new Map<number, TrackVisualStyle>();
|
const map = new Map<number, TrackVisualStyle>();
|
||||||
|
|||||||
Reference in New Issue
Block a user