diff --git a/packages/core/src/canary.test.ts b/packages/core/src/canary.test.ts index 477c22a2b..e58441c92 100644 --- a/packages/core/src/canary.test.ts +++ b/packages/core/src/canary.test.ts @@ -426,29 +426,3 @@ describe("canary reason property", () => { expect(canaryReasonKey("de-parallel-router")).toBe("canary_reason_de_parallel_router"); }); }); - -/** - * The audio FX rack ships dark. - * - * Pinned as a test rather than trusted to review: the registry's own procedure - * is "start at percentage: 0 and merge that", and the whole point of landing a - * 47-PR stack behind a canary is defeated if the entry reaches main at anything - * else. A ramp is a deliberate edit to this number, and it should have to break - * a test that says so. - */ -describe("the audio-fx-rack canary", () => { - const entry = CANARIES.find((c) => c.name === "audio-fx-rack"); - - it("is registered", () => { - expect(entry, "audio-fx-rack missing from the registry").toBeDefined(); - }); - - it("ships at 0%", () => { - expect(entry?.percentage).toBe(0); - }); - - 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")); - }); -}); diff --git a/packages/core/src/canaryRegistry.ts b/packages/core/src/canaryRegistry.ts index 188f40a9e..bd948a0c5 100644 --- a/packages/core/src/canaryRegistry.ts +++ b/packages/core/src/canaryRegistry.ts @@ -80,36 +80,6 @@ export const CANARIES: readonly CanaryDefinition[] = [ owner: "vance", 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 element and data-audio-group membership " + - "parse and play regardless of enrollment.", - owner: "vance", - sunsetAfter: "2027-01-15", - }, ] as const; export function findCanary(name: string): CanaryDefinition | undefined { diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 54cd942a0..262a08321 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1442,10 +1442,6 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(10) }; initSandboxRuntimeModular(); - // Behind the `audio-track-mute` canary — off until the host pushes it, so a - // composition that already carries data-hidden on an audio element keeps - // playing in preview for anyone not enrolled. - window.__hf?.setCanaries?.({ "audio-track-mute": true }); // `scheduleMediaElementPlayback`, not `decodeAudioElement`: the media-element // transport is the path the runtime tries FIRST for audio, and the decoded @@ -1463,7 +1459,7 @@ describe("initSandboxRuntimeModular", () => { expect(scheduleSpy.mock.calls[0]?.[0]).toBe(audibleAudio); }); - it("still schedules a data-hidden audio clip when the host has not opted in", () => { + it("reschedules only when a data-hidden mutation actually moved something", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); @@ -1484,39 +1480,27 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(10) }; initSandboxRuntimeModular(); - const scheduleSpy = vi - .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback") - .mockResolvedValue(null); const stopSpy = vi.spyOn(WebAudioTransport.prototype, "stopAll"); - const player = window.__player; player?.play(); - // A seek stops the transport itself, so the count is the measure: the - // visibility pass over a data-hidden element must add NO second stop while - // un-enrolled. The skips the reschedule exists to re-run are inert with the - // flag off, so the set it rebuilds is identical and the only observable - // effect is an audible stop-and-restart across the whole mix. - // The dirty flag is set by a data-hidden MUTATION, so the toggle is the - // gesture — a plain seek never reaches the reschedule at all. - hiddenAudio.removeAttribute("data-hidden"); - player?.seek(1, { keepPlaying: true }); - const unenrolled = stopSpy.mock.calls.length; - - window.__hf?.setCanaries?.({ "audio-track-mute": true }); + // A seek stops the transport itself, so the COUNT is the measure. Without + // the dirty gate the reschedule fired on every visibility pass, adding a + // second stop — an audible stop-and-restart across the whole mix — to + // rebuild an identical active set. stopSpy.mockClear(); - hiddenAudio.setAttribute("data-hidden", ""); + player?.seek(1, { keepPlaying: true }); + const seekOnly = stopSpy.mock.calls.length; + + // The dirty flag is set by a data-hidden MUTATION, so the toggle is the + // gesture; a plain seek never reaches the reschedule at all. + stopSpy.mockClear(); + hiddenAudio.removeAttribute("data-hidden"); player?.seek(2, { keepPlaying: true }); - const enrolled = stopSpy.mock.calls.length; + const afterToggle = stopSpy.mock.calls.length; - expect(unenrolled).toBe(1); - expect(enrolled).toBe(unenrolled + 1); - - // And the hidden clip is still audible to the transport un-enrolled: the - // canary flip handler reschedules, and with the flag off the hidden skip - // does not fire, so the element IS scheduled. - window.__hf?.setCanaries?.({ "audio-track-mute": false }); - expect(scheduleSpy.mock.calls.map((call) => call[0])).toContain(hiddenAudio); + expect(seekOnly).toBe(1); + expect(afterToggle).toBe(seekOnly + 1); }); it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => { @@ -1550,10 +1534,6 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(10) }; initSandboxRuntimeModular(); - // The reschedule IS the audio-track-mute feature — un-enrolled, hidden-ness - // does not change the active set, so nothing should stop or restart (see - // the un-enrolled test below). Enrol before asserting on it. - window.__hf?.setCanaries?.({ "audio-track-mute": true }); const player = window.__player; // play() alone (no seek) already runs one visibility pass while the clock @@ -1602,9 +1582,6 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(10) }; initSandboxRuntimeModular(); - // Enrolled: the stop-before-reschedule pairing this test pins only runs for - // the audio-track-mute feature. - window.__hf?.setCanaries?.({ "audio-track-mute": true }); const player = window.__player; player?.play(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 33879f26e..d3400f595 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -180,38 +180,11 @@ export function initSandboxRuntimeModular(): void { webAudioReady = ok; }); window.__hf = window.__hf || {}; - // Canary states the HOST resolved, keyed by registry name. Core cannot - // resolve one itself — bucketing needs an install id it has no access to — - // so every runtime-visible flag arrives through this one channel rather than - // growing an `__hf` setter of its own. - // - // Every flag defaults OFF, which is the shipped behaviour: a host that never - // pushes (CLI preview, the bare player) behaves exactly as before. - const canaries: Record = {}; - // A2's preview/export parity fix: silence `data-hidden` audio the way the - // render already does. Off until enrolled, so a composition carrying - // `data-hidden` on an audio element keeps playing in preview meanwhile. - const silenceHiddenAudioEnabled = (): boolean => canaries["audio-track-mute"] === true; /** Hidden by an ancestor, or by the BUS this clip belongs to. The bus is * never an ancestor — membership is on the member's `data-audio-group` — so * `closest()` alone could not see a muted group, which the render drops. */ const isSilencedByHidden = (el: Element): boolean => el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el); - window.__hf.setCanaries = (states) => { - const wasSilencing = silenceHiddenAudioEnabled(); - for (const [name, enabled] of Object.entries(states)) canaries[name] = enabled === true; - if (silenceHiddenAudioEnabled() === wasSilencing) return; - // The active-clip set is built with this predicate baked in, so a flip - // mid-session has to rebuild it. `stopAll()` first: bumping the generation - // only rejects future STALE schedules, it does not stop sources already - // started, and there is no per-element dedup — so rescheduling on its own - // starts a second buffer source for every in-window clip on top of the ones - // still playing. `applyWebAudioRate` pairs the two for the same reason. - if (clock.isPlaying()) { - webAudio.stopAll(); - scheduleWebAudioForActiveClips(); - } - }; // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the // parser reads back), NOT an animatable property. Register it as a no-op GSAP // plugin so GSAP doesn't log "Invalid property _auto" on every tween build — @@ -1967,7 +1940,7 @@ export function initSandboxRuntimeModular(): void { // one per toggled node. // // The reschedule is paired with `stopAll()` below, for the reason - // `setCanaries` and `applyWebAudioRate` already spell out: scheduling does + // `applyWebAudioRate` already spells out: scheduling does // NOT replace the active set. It bumps a generation, which only rejects // stale schedules still in flight — every source already started keeps // playing, and there is no per-element dedup. This comment used to claim the @@ -2061,12 +2034,11 @@ export function initSandboxRuntimeModular(): void { rawNode.style.display = "none"; } } - // Gated like every other consumer of this canary. The skips this reschedule - // exists to re-run (`silenceHiddenAudioEnabled() && …data-hidden`, below) - // are inert while the flag is off, so un-enrolled the whole stop/reschedule - // rebuilt an IDENTICAL active set — an audible stop-and-restart across the - // entire mix on every visibility toggle, at 100%, for a feature at 0%. - if (hiddenAudioDirty && silenceHiddenAudioEnabled() && clock.isPlaying()) { + // Only when a `data-hidden` mutation actually moved something: the skips + // this reschedule exists to re-run are what change the active set, so + // firing it otherwise was an audible stop-and-restart across the whole mix + // that rebuilt an identical set. + if (hiddenAudioDirty && clock.isPlaying()) { webAudio.stopAll(); scheduleWebAudioForActiveClips(); } @@ -2132,7 +2104,6 @@ export function initSandboxRuntimeModular(): void { webAudio.setElementVolume(el, authorVolume), isWebAudioOwned: (el) => webAudio.ownsElement(el), isWebAudioRouted: (el) => webAudio.routesElement(el), - silenceHiddenAudio: silenceHiddenAudioEnabled(), onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; state.mediaAutoplayBlockedPosted = true; @@ -3013,7 +2984,7 @@ export function initSandboxRuntimeModular(): void { let foundActive = false; for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - if (silenceHiddenAudioEnabled() && isSilencedByHidden(rawEl)) continue; + if (isSilencedByHidden(rawEl)) continue; const start = Number.parseFloat(rawEl.dataset.start ?? ""); const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration); const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity; @@ -3121,7 +3092,7 @@ export function initSandboxRuntimeModular(): void { const audioEls = document.querySelectorAll("audio[data-start]"); for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - if (silenceHiddenAudioEnabled() && isSilencedByHidden(rawEl)) continue; + if (isSilencedByHidden(rawEl)) continue; const compStart = Number.parseFloat(rawEl.dataset.start ?? ""); if (!Number.isFinite(compStart)) continue; const mediaStart = readElementPlaybackStart(rawEl); diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index c4e10753a..3d397a170 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -583,14 +583,13 @@ describe("syncRuntimeMedia", () => { hiddenAncestor.appendChild(clip.el); return clip; }; - const volumeSeen = (clip: ReturnType, silenceHiddenAudio?: boolean) => { + const volumeSeen = (clip: ReturnType) => { let seen = -1; syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1, - ...(silenceHiddenAudio === undefined ? {} : { silenceHiddenAudio }), onElementVolume: (_el, v) => { seen = v; }, @@ -599,19 +598,16 @@ describe("syncRuntimeMedia", () => { }; it("zeroes effective volume for a clip under a data-hidden ancestor", () => { - expect(volumeSeen(hiddenClip(), true)).toBe(0); + expect(volumeSeen(hiddenClip())).toBe(0); }); - // The `audio-track-mute` canary sits at 0%: an existing composition that - // carries data-hidden on an audio element must keep playing in preview - // until its author is enrolled, or the upgrade silences them with no way - // back short of a revert. - it("leaves a hidden clip audible when the host has not opted in", () => { - expect(volumeSeen(hiddenClip(), false)).toBe(0.8); - }); - - it("defaults to audible when the flag is absent entirely", () => { - expect(volumeSeen(hiddenClip())).toBe(0.8); + // A visible clip is the control: the zero above has to come from the + // ancestor, not from the fixture reading 0 for some other reason. + it("leaves a visible clip at its authored volume", () => { + const clip = createMockClip({ start: 0, end: 10, volume: 0.8 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + document.body.appendChild(clip.el); + expect(volumeSeen(clip)).toBe(0.8); }); it("does not touch el.muted when silencing a hidden clip (RULES trap: transport owns el.muted)", () => { @@ -623,7 +619,6 @@ describe("syncRuntimeMedia", () => { timeSeconds: 1, playing: true, playbackRate: 1, - silenceHiddenAudio: true, }); expect(clip.el.muted).toBe(false); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 690273024..ca5909f4a 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -218,11 +218,6 @@ export function syncRuntimeMedia(params: { /** Native media routed through WebAudio keeps its upstream element volume at * unity; do not mistake that transport write for an authored volume edit. */ isWebAudioRouted?: (el: HTMLMediaElement) => boolean; - /** Silence media under a `data-hidden` ancestor, matching the render. Opt-in: - * the host pushes it via `__hf.setCanaries` when the `audio-track-mute` - * canary is on. Absent/false = the shipped behaviour (hidden audio still - * plays in preview). */ - silenceHiddenAudio?: boolean; forceSync?: boolean; }): void { const forceMuteAll = !!(params.outputMuted || params.userMuted); @@ -322,18 +317,15 @@ export function syncRuntimeMedia(params: { } // A data-hidden ancestor is silent in the export (audioMixer.ts drops - // it); preview matches once the host opts in (`silenceHiddenAudio`, the - // `audio-track-mute` canary — see init.ts). Folded into the per-tick - // volume, not el.muted (RULES trap: el.muted is the transport's ownership - // flag). + // it), so preview matches. Folded into the per-tick volume, not + // el.muted (RULES trap: el.muted is the transport's ownership flag). // Two independent ways to be silent, and the second is not an ancestor // question: membership lives on the MEMBER's `data-audio-group`, so a // muted BUS is invisible to `closest()`. The render drops such members // (`memberGroupHidden`), so without this the export was silent where the // fallback played at full level. - const silencedByHidden = params.silenceHiddenAudio - ? el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el) - : false; + const silencedByHidden = + el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el); const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol); el.volume = effectiveVolume; lastRuntimeAppliedVolume.set(el, effectiveVolume); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index e849a36cc..212a69af2 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -37,18 +37,6 @@ declare global { onSwallowed?: (label: string, err: unknown) => void; seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void; duration?: number; - /** - * Canary states resolved by the HOST and pushed in, because core cannot - * resolve one itself: bucketing needs an install id, which lives in the - * studio's localStorage or the CLI's seed. - * - * One channel for every flag rather than a setter each — a per-flag - * setter meant a new `__hf` method, a new pusher and a new type entry - * for every runtime-visible canary. Unknown names are ignored, and any - * flag absent from the record keeps its default (off), so a host that - * knows nothing about a given canary cannot silently enable it. - */ - setCanaries?: (states: Readonly>) => void; }; __playerReady?: boolean; __renderReady?: boolean; diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 53aceede9..03e96ebd4 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -16,7 +16,6 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection"; import { FlatMotionSection } from "./propertyPanelFlatMotionSection"; -import { isCanaryEnabled } from "../../telemetry/canary"; import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js"; import { useVolumeAutomation } from "./useVolumeAutomation"; import { useAudioFxRevealSection } from "./useAudioFxRevealSection"; @@ -470,10 +469,7 @@ export function PropertyPanelFlat({ }); } } - // Behind `audio-fx-rack`, at 0%. Gates the AUTHORING surface only: the runtime - // 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")) { + if (sections.audioFx) { groups.push({ id: "audio-fx", title: "Audio FX", diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 550a9b1ed..0d50fe31b 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -46,17 +46,10 @@ vi.mock("./timelineRowVirtualizationFlag", () => ({ STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false, })); -/** Enrolled in nothing by default, matching a user outside every canary. */ -const enabledCanaries = new Set(); -vi.mock("../../telemetry/canary", () => ({ - isCanaryEnabled: (name: string) => enabledCanaries.has(name), -})); - globalThis.IS_REACT_ACT_ENVIRONMENT = true; afterEach(() => { document.body.innerHTML = ""; - enabledCanaries.clear(); usePlayerStore.getState().reset(); }); @@ -310,7 +303,6 @@ describe("Timeline provider boundary", () => { // while TimelineGroupRow called the THROWING context hook — one grouped clip // and the whole timeline render died, not just the row. it("renders without the provider even when a group row is on screen", () => { - enabledCanaries.add("audio-groups"); const host = createSizedTimelineHost(640); usePlayerStore.setState({ duration: 4, diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 677cd17d0..a539ebcc2 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -3,7 +3,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { defaultTimelineTheme } from "./timelineTheme"; @@ -14,17 +14,6 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -/** Enrolled canaries for the render under test. Both audio canaries sit at 0%, - * so the default here is "enrolled in nothing" — the state a real user is in. */ -const enabledCanaries = new Set(); -vi.mock("../../telemetry/canary", () => ({ - isCanaryEnabled: (name: string) => enabledCanaries.has(name), -})); - -beforeEach(() => { - enabledCanaries.clear(); -}); - afterEach(() => { document.body.innerHTML = ""; }); @@ -814,29 +803,32 @@ describe("TimelineTrackHeader", () => { act(() => view.root.unmount()); }); - it("hides the FX button outside the audio-fx-rack canary", () => { + it("offers the FX button on every audio track", () => { const view = renderHeader({ keyframeClip: VOICE, animations: [], expanded: false, isAudioTrack: true, }); - expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull(); - enabledCanaries.add("audio-fx-rack"); - view.rerender({ - keyframeClip: VOICE, - animations: [], - expanded: false, - isAudioTrack: true, - }); expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull(); act(() => view.root.unmount()); }); - // The group-pointer variant WRITES a group, so it needs the groups canary - // too — otherwise an unenrolled user creates a group and then has no UI to - // manage it. - it("hides the group pointer unless BOTH audio canaries are on", () => { + // A visual track has no chain to open, so the button must not follow the + // header onto every row. + it("withholds the FX button from a non-audio track", () => { + const view = renderHeader({ + keyframeClip: ELEMENT, + animations: [], + expanded: false, + }); + expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull(); + act(() => view.root.unmount()); + }); + + // A chain belongs to ONE bus, so a track carrying several ungrouped clips + // gets the pointer instead of the FX button — group first, then mix. + it("shows the group pointer on a multi-clip ungrouped audio track", () => { const opts = { keyframeClip: VOICE, trackElements: [VOICE, VOICE_2], @@ -848,13 +840,11 @@ describe("TimelineTrackHeader", () => { const pointer = (host: HTMLElement) => host.querySelector('button[aria-label="Effects — group these clips first"]'); const view = renderHeader(opts); - expect(pointer(view.host)).toBeNull(); - enabledCanaries.add("audio-fx-rack"); - view.rerender({ ...opts }); - expect(pointer(view.host)).toBeNull(); - enabledCanaries.add("audio-groups"); - view.rerender({ ...opts }); expect(pointer(view.host)).not.toBeNull(); + // One clip needs no grouping — the real FX button takes its place. + view.rerender({ ...opts, trackElements: [VOICE], clipCount: 1 }); + expect(pointer(view.host)).toBeNull(); + expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull(); act(() => view.root.unmount()); }); @@ -868,7 +858,6 @@ describe("TimelineTrackHeader", () => { // itself therefore centred the two lines in the FULL height, so opening a // lane pushed the name and its controls down on top of the lane rows. it("pins the two lines to the top TRACK_H, whatever the header grows to", () => { - enabledCanaries.add("audio-fx-rack"); const automated: TimelineElement = { ...VOICE, automation: JSON.stringify({ @@ -898,8 +887,6 @@ describe("TimelineTrackHeader", () => { // what let a stray third child overflow the 48px box; now there is a single // row and the controls share one right-aligned group. it("keeps the name and every control on one line, controls to the right", () => { - enabledCanaries.add("audio-fx-rack"); - enabledCanaries.add("audio-groups"); const view = renderHeader({ keyframeClip: VOICE, trackElements: [VOICE, VOICE_2], diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 8a9e3446e..dc43f24bc 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -12,7 +12,6 @@ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditConte import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; import { mintGroupId } from "../../components/editor/useFxCarveGrouping"; import { runtimeAudioId } from "../lib/timelineElementHelpers"; -import { isCanaryEnabled } from "../../telemetry/canary"; import { TimelineFxButton } from "./TimelineFxButton"; import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; @@ -285,7 +284,7 @@ export function TimelineTrackHeader({ // On the control line rather than a third row of its own. trailing={ <> - {singleAudioClip && isCanaryEnabled("audio-fx-rack") && ( + {singleAudioClip && ( openClipFxRack(singleAudioClip)} /> )} - {/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES - a group, so it needs `audio-groups` too — without it a user outside - that canary could create a group and then have no UI to manage it. */} {clipCount > 1 && !isTrackGrouped && - (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && - isCanaryEnabled("audio-fx-rack") && - isCanaryEnabled("audio-groups") && ( + (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && ( Boolean(element.audioGroup))) + // The column exists FOR the group rows, so this must stay in step with + // whatever decides they are drawn (`useTimelineTrackDerivations`) — + // widening it with no group row on screen is a 232px shift of every clip + // with nothing to explain it. + elements.some((element) => Boolean(element.audioGroup)) ); } diff --git a/packages/studio/src/player/components/useTimelineTrackDerivations.ts b/packages/studio/src/player/components/useTimelineTrackDerivations.ts index c83f7c80f..97d9534bb 100644 --- a/packages/studio/src/player/components/useTimelineTrackDerivations.ts +++ b/packages/studio/src/player/components/useTimelineTrackDerivations.ts @@ -1,6 +1,5 @@ import { useMemo } from "react"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; -import { isCanaryEnabled } from "../../telemetry/canary"; import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons"; /** One resolved audio group, positioned in the row order. */ @@ -196,16 +195,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[]) }, [expandedElements]); const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds); - const { tracks, groups, trackGroupOf } = useMemo(() => { - if (!isCanaryEnabled("audio-groups")) { - return { - tracks: rawTracks, - groups: [], - trackGroupOf: new Map(), - }; - } - return groupTimelineTracks(rawTracks, collapsedGroupIds); - }, [rawTracks, collapsedGroupIds]); + const { tracks, groups, trackGroupOf } = useMemo( + () => groupTimelineTracks(rawTracks, collapsedGroupIds), + [rawTracks, collapsedGroupIds], + ); const trackStyles = useMemo(() => { const map = new Map(); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index 2b8587123..e23b08e22 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -3,7 +3,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { LANE_H, TRACK_H } from "./timelineLayout"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; @@ -12,13 +12,7 @@ import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineT globalThis.IS_REACT_ACT_ENVIRONMENT = true; -const enabledCanaries = new Set(); -vi.mock("../../telemetry/canary", () => ({ - isCanaryEnabled: (name: string) => enabledCanaries.has(name), -})); - afterEach(() => { - enabledCanaries.clear(); usePlayerStore.getState().reset(); }); @@ -60,7 +54,6 @@ describe("collapsed audio groups", () => { layout: ReturnType; unmount: () => void; } { - enabledCanaries.add("audio-groups"); if (collapsed) usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) }); const elements = [member("voice-1", 0), member("voice-2", 1)]; let layout: ReturnType | undefined; @@ -79,7 +72,6 @@ describe("collapsed audio groups", () => { // is what "expanding automation on a group doesn't show the automation" // looked like from outside. it("reserves room for the group's own automation rows, not just the strip", () => { - enabledCanaries.add("audio-groups"); const automation = JSON.stringify({ version: 1, lanes: [ diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts index ccb5d55e3..4aa69a826 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts @@ -90,31 +90,21 @@ describe("scrubPreviewAudio", () => { }); describe("applyPreviewAudioFlags", () => { - function fakeIframe(): { iframe: HTMLIFrameElement; calls: Record } { - const calls: Record = {}; - const win = { - __hf: { - setCanaries: (states: Record) => { - calls.canaries = [states]; - }, - }, - }; - const iframe = { - contentWindow: win, - contentDocument: null, - querySelector: () => null, - } as unknown as HTMLIFrameElement; - return { iframe, calls }; - } + // Everything pushed here is state the runtime loses on reload and nothing else + // re-sends, so the push has to carry all of it every time. Volume in + // particular: the transport comes back at unity, so a preview the author had + // turned down came back loud. + it("re-pushes mute and volume together", () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage"); - // Everything pushed here is state the runtime loses on reload and nothing - // else re-sends, so the push has to carry all of it every time. - it("re-pushes the whole audio state", () => { - const { iframe, calls } = fakeIframe(); + applyPreviewAudioFlags(iframe, true, 0.4); - applyPreviewAudioFlags(iframe, false, 1); - - // Every runtime-visible flag in one push, each resolved by the host. - expect(calls.canaries?.[0]).toMatchObject({ "audio-track-mute": expect.any(Boolean) }); + const actions = postMessage.mock.calls.map( + (call) => (call[0] as { action?: string }).action ?? "", + ); + expect(actions).toContain("set-muted"); + expect(actions).toContain("set-volume"); }); }); diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts index 4fb9f3bad..c2845dff6 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts @@ -12,7 +12,6 @@ import type { TimelineElement } from "../store/playerStore"; import type { IframeWindow } from "./playbackTypes"; -import { isCanaryEnabled } from "../../telemetry/canary"; import { readClipTiming } from "@hyperframes/core/composition-contract"; import { getTimelineElementSelector, @@ -144,35 +143,10 @@ export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume: } /** - * Every canary the preview runtime can act on, resolved here and pushed as one - * record (see `window.__hf.setCanaries`). Core has no install id, so it cannot - * bucket for itself; a flag missing from this list simply stays off in the - * runtime, which is the shipped behaviour. - * - * Adding a runtime-visible canary means adding its name here and reading it in - * core — no new `__hf` method, pusher or type entry per flag. - */ -const RUNTIME_CANARIES = ["audio-track-mute", "audio-groups", "audio-fx-rack"] as const; - -function setPreviewCanaries(iframe: HTMLIFrameElement | null): void { - if (!iframe) return; - try { - const win = iframe.contentWindow as - | (Window & { __hf?: { setCanaries?: (states: Record) => void } }) - | null; - if (!win?.__hf?.setCanaries) return; - const states: Record = {}; - for (const name of RUNTIME_CANARIES) states[name] = isCanaryEnabled(name); - win.__hf.setCanaries(states); - } catch {} -} - -/** - * Everything the preview runtime has to be told about audio after it loads: - * the transport's mute and the canary flags core - * cannot resolve for itself. Called from `applyPreviewAudioState`, which is the - * path that re-runs after a preview reload — the runtime comes back with every - * one of these at its default and nothing else pushes them again. + * Everything the preview runtime has to be told about audio after it loads. + * Called from `applyPreviewAudioState`, which is the path that re-runs after a + * preview reload — the runtime comes back with the transport at its defaults + * and nothing else pushes them again. */ export function applyPreviewAudioFlags( iframe: HTMLIFrameElement | null, @@ -183,7 +157,6 @@ export function applyPreviewAudioFlags( // Volume too: the transport comes back at unity after a reload, so a preview // the author had turned down came back loud. setPreviewMediaVolume(iframe, volume); - setPreviewCanaries(iframe); } export function setPreviewPlaybackRate(