mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(core,studio): silence hidden audio in preview, and call it mute
Preview scheduled every audio[data-start] regardless of data-hidden, so a hidden audio track was silent in the export but audible in preview — render was already correct, this was a preview-only parity bug. Web Audio scheduling now skips (and re-syncs on toggle) any audio clip under a data-hidden ancestor; the HTMLMedia per-tick volume path folds the same check into effectiveVolume without touching el.muted (transport-owned). Ships unflagged since it's a bugfix restoring parity. Also relabels the eye as Mute/Muted on audio-only track rows (icon, strikethrough label, undo-history copy), gated behind the new audio-track-mute canary — the relabel is a copy/UX change, kept separate from the behavior fix above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e966311627
commit
adfdb69a78
@@ -89,6 +89,16 @@ export const CANARIES: readonly CanaryDefinition[] = [
|
|||||||
owner: "vance",
|
owner: "vance",
|
||||||
sunsetAfter: "2026-11-15",
|
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",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function findCanary(name: string): CanaryDefinition | undefined {
|
export function findCanary(name: string): CanaryDefinition | undefined {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { initSandboxRuntimeModular } from "./init";
|
import { initSandboxRuntimeModular } from "./init";
|
||||||
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
|
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
|
||||||
|
import { WebAudioTransport } from "./webAudioTransport";
|
||||||
import type { RuntimeTimelineLike } from "./types";
|
import type { RuntimeTimelineLike } from "./types";
|
||||||
|
|
||||||
it("schedules WebAudio element gain from author volume without bridge volume", () => {
|
it("schedules WebAudio element gain from author volume without bridge volume", () => {
|
||||||
@@ -1321,6 +1322,100 @@ describe("initSandboxRuntimeModular", () => {
|
|||||||
expect(hiddenClip.style.display).toBe("");
|
expect(hiddenClip.style.display).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes a data-hidden audio clip from Web Audio scheduling", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "main");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-start", "0");
|
||||||
|
root.setAttribute("data-duration", "10");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const hiddenAudio = document.createElement("audio");
|
||||||
|
hiddenAudio.setAttribute("data-start", "0");
|
||||||
|
hiddenAudio.setAttribute("data-duration", "10");
|
||||||
|
hiddenAudio.setAttribute("data-hidden", "");
|
||||||
|
hiddenAudio.load = () => {};
|
||||||
|
hiddenAudio.play = vi.fn(() => Promise.resolve());
|
||||||
|
root.appendChild(hiddenAudio);
|
||||||
|
|
||||||
|
const audibleAudio = document.createElement("audio");
|
||||||
|
audibleAudio.setAttribute("data-start", "0");
|
||||||
|
audibleAudio.setAttribute("data-duration", "10");
|
||||||
|
audibleAudio.load = () => {};
|
||||||
|
audibleAudio.play = vi.fn(() => Promise.resolve());
|
||||||
|
root.appendChild(audibleAudio);
|
||||||
|
|
||||||
|
window.__timelines = { main: createMockTimeline(10) };
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
|
||||||
|
const decodeSpy = vi
|
||||||
|
.spyOn(WebAudioTransport.prototype, "decodeAudioElement")
|
||||||
|
.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const player = window.__player;
|
||||||
|
player?.play();
|
||||||
|
player?.seek(0);
|
||||||
|
|
||||||
|
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(decodeSpy.mock.calls[0]?.[0]).toBe(audibleAudio);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.setAttribute("data-composition-id", "main");
|
||||||
|
root.setAttribute("data-root", "true");
|
||||||
|
root.setAttribute("data-start", "0");
|
||||||
|
root.setAttribute("data-duration", "10");
|
||||||
|
root.setAttribute("data-width", "1920");
|
||||||
|
root.setAttribute("data-height", "1080");
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
// Two separately-toggled audio clips (not a wrapper div — the visibility
|
||||||
|
// sweep only walks [data-start] nodes, so the attribute must sit on each
|
||||||
|
// timed element itself, matching how the eye button hides per-element).
|
||||||
|
const audioA = document.createElement("audio");
|
||||||
|
audioA.setAttribute("data-start", "0");
|
||||||
|
audioA.setAttribute("data-duration", "10");
|
||||||
|
audioA.setAttribute("data-hidden", "");
|
||||||
|
audioA.load = () => {};
|
||||||
|
audioA.play = vi.fn(() => Promise.resolve());
|
||||||
|
root.appendChild(audioA);
|
||||||
|
|
||||||
|
const audioB = document.createElement("audio");
|
||||||
|
audioB.setAttribute("data-start", "0");
|
||||||
|
audioB.setAttribute("data-duration", "10");
|
||||||
|
audioB.setAttribute("data-hidden", "");
|
||||||
|
audioB.load = () => {};
|
||||||
|
audioB.play = vi.fn(() => Promise.resolve());
|
||||||
|
root.appendChild(audioB);
|
||||||
|
|
||||||
|
window.__timelines = { main: createMockTimeline(10) };
|
||||||
|
initSandboxRuntimeModular();
|
||||||
|
|
||||||
|
const player = window.__player;
|
||||||
|
// play() alone (no seek) already runs one visibility pass while the clock
|
||||||
|
// is playing, registering both clips as hidden — the baseline this test
|
||||||
|
// toggles away from.
|
||||||
|
player?.play();
|
||||||
|
|
||||||
|
const decodeSpy = vi
|
||||||
|
.spyOn(WebAudioTransport.prototype, "decodeAudioElement")
|
||||||
|
.mockResolvedValue(null);
|
||||||
|
const generationSpy = vi.spyOn(WebAudioTransport.prototype, "startGeneration");
|
||||||
|
|
||||||
|
// Both become visible in the SAME sync pass — must still be one reschedule.
|
||||||
|
// keepPlaying: a plain seek() pauses the clock before re-syncing visibility,
|
||||||
|
// which would make the hiddenAudioDirty branch's isPlaying() gate a no-op.
|
||||||
|
audioA.removeAttribute("data-hidden");
|
||||||
|
audioB.removeAttribute("data-hidden");
|
||||||
|
player?.seek(1, { keepPlaying: true });
|
||||||
|
|
||||||
|
expect(generationSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(decodeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
|
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
|
||||||
withStudioIframe(() => {
|
withStudioIframe(() => {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
|
|||||||
@@ -1916,6 +1916,13 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
};
|
};
|
||||||
const dataHiddenDisplayRestores = new WeakMap<HTMLElement, string>();
|
const dataHiddenDisplayRestores = new WeakMap<HTMLElement, string>();
|
||||||
const dataHiddenDisplayNodes = new WeakSet<HTMLElement>();
|
const dataHiddenDisplayNodes = new WeakSet<HTMLElement>();
|
||||||
|
// A data-hidden toggle on (or affecting) an audio element must re-schedule
|
||||||
|
// WebAudio playback so the hidden clip's source is dropped/restored mid-
|
||||||
|
// playback. Batched to one call per syncTimedElementVisibility pass, not
|
||||||
|
// one per toggled node (schedulePlayback replaces the whole active set).
|
||||||
|
let hiddenAudioDirty = false;
|
||||||
|
const nodeAffectsAudio = (node: HTMLElement): boolean =>
|
||||||
|
node.matches("audio[data-start]") || node.querySelector("audio[data-start]") !== null;
|
||||||
|
|
||||||
const syncTimedElementVisibility = (
|
const syncTimedElementVisibility = (
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
@@ -1929,6 +1936,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
if (!dataHiddenDisplayNodes.has(rawNode)) {
|
if (!dataHiddenDisplayNodes.has(rawNode)) {
|
||||||
dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
|
dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
|
||||||
dataHiddenDisplayNodes.add(rawNode);
|
dataHiddenDisplayNodes.add(rawNode);
|
||||||
|
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
|
||||||
}
|
}
|
||||||
rawNode.style.display = "none";
|
rawNode.style.display = "none";
|
||||||
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
|
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
|
||||||
@@ -1946,6 +1954,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
}
|
}
|
||||||
dataHiddenDisplayRestores.delete(rawNode);
|
dataHiddenDisplayRestores.delete(rawNode);
|
||||||
dataHiddenDisplayNodes.delete(rawNode);
|
dataHiddenDisplayNodes.delete(rawNode);
|
||||||
|
if (nodeAffectsAudio(rawNode)) hiddenAudioDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime);
|
let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime);
|
||||||
@@ -1975,6 +1984,10 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
rawNode.style.display = "none";
|
rawNode.style.display = "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hiddenAudioDirty && clock.isPlaying()) {
|
||||||
|
scheduleWebAudioForActiveClips();
|
||||||
|
}
|
||||||
|
hiddenAudioDirty = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncMediaForCurrentState = () => {
|
const syncMediaForCurrentState = () => {
|
||||||
@@ -2915,6 +2928,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
let foundActive = false;
|
let foundActive = false;
|
||||||
for (const rawEl of audioEls) {
|
for (const rawEl of audioEls) {
|
||||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||||
|
if (rawEl.closest("[data-hidden]")) continue;
|
||||||
const start = Number.parseFloat(rawEl.dataset.start ?? "");
|
const start = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||||
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
||||||
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
|
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
|
||||||
@@ -3022,6 +3036,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||||
for (const rawEl of audioEls) {
|
for (const rawEl of audioEls) {
|
||||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||||
|
if (rawEl.closest("[data-hidden]")) continue;
|
||||||
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||||
if (!Number.isFinite(compStart)) continue;
|
if (!Number.isFinite(compStart)) continue;
|
||||||
const mediaStart = readElementPlaybackStart(rawEl);
|
const mediaStart = readElementPlaybackStart(rawEl);
|
||||||
|
|||||||
@@ -573,6 +573,44 @@ describe("syncRuntimeMedia", () => {
|
|||||||
expect(clip.el.play).toHaveBeenCalled();
|
expect(clip.el.play).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("data-hidden silences preview volume", () => {
|
||||||
|
it("zeroes effective volume for a clip under a data-hidden ancestor", () => {
|
||||||
|
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
|
||||||
|
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||||
|
const hiddenAncestor = document.createElement("div");
|
||||||
|
hiddenAncestor.setAttribute("data-hidden", "");
|
||||||
|
document.body.appendChild(hiddenAncestor);
|
||||||
|
hiddenAncestor.appendChild(clip.el);
|
||||||
|
|
||||||
|
let seen = -1;
|
||||||
|
syncRuntimeMedia({
|
||||||
|
clips: [clip],
|
||||||
|
timeSeconds: 1,
|
||||||
|
playing: true,
|
||||||
|
playbackRate: 1,
|
||||||
|
onElementVolume: (_el, v) => {
|
||||||
|
seen = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch el.muted when silencing a hidden clip (RULES trap: transport owns el.muted)", () => {
|
||||||
|
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
|
||||||
|
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||||
|
const hiddenAncestor = document.createElement("div");
|
||||||
|
hiddenAncestor.setAttribute("data-hidden", "");
|
||||||
|
document.body.appendChild(hiddenAncestor);
|
||||||
|
hiddenAncestor.appendChild(clip.el);
|
||||||
|
clip.el.muted = false;
|
||||||
|
|
||||||
|
syncRuntimeMedia({ clips: [clip], timeSeconds: 1, playing: true, playbackRate: 1 });
|
||||||
|
|
||||||
|
expect(clip.el.muted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("play() storm guard (unplayable elements)", () => {
|
describe("play() storm guard (unplayable elements)", () => {
|
||||||
it("does not play() an element with a media error", () => {
|
it("does not play() an element with a media error", () => {
|
||||||
const clip = createMockClip({ start: 0, end: 10 });
|
const clip = createMockClip({ start: 0, end: 10 });
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ export function syncRuntimeMedia(params: {
|
|||||||
authorVolume = fallbackAuthorVolume;
|
authorVolume = fallbackAuthorVolume;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveVolume = clampVolume(authorVolume * userVol);
|
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
|
||||||
|
// it); preview must match. Folded into the per-tick volume, not
|
||||||
|
// el.muted (RULES trap: el.muted is the transport's ownership flag).
|
||||||
|
const effectiveVolume = el.closest("[data-hidden]") ? 0 : clampVolume(authorVolume * userVol);
|
||||||
el.volume = effectiveVolume;
|
el.volume = effectiveVolume;
|
||||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||||
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
params.onElementVolume?.(el, effectiveVolume, authorVolume);
|
||||||
|
|||||||
@@ -194,6 +194,87 @@ describe("toggleTimelineTrackHidden", () => {
|
|||||||
|
|
||||||
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2");
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels an audio-only track Mute/Unmute instead of Hide/Show", 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,
|
||||||
|
previewIframe: null,
|
||||||
|
writeProjectFile: async () => {},
|
||||||
|
recordEdit,
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set() },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels unmuting an audio-only track back on", async () => {
|
||||||
|
const files = new Map([
|
||||||
|
["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></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", hidden: true }),
|
||||||
|
],
|
||||||
|
track: 0,
|
||||||
|
hidden: false,
|
||||||
|
previewIframe: null,
|
||||||
|
writeProjectFile: async () => {},
|
||||||
|
recordEdit,
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set() },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Unmute track 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Hide/Show wording for a mixed (audio + visual) track", async () => {
|
||||||
|
const files = new Map([
|
||||||
|
[
|
||||||
|
"index.html",
|
||||||
|
`<div id="voiceover" data-start="0" data-duration="2"></div>
|
||||||
|
<div id="caption" 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" }),
|
||||||
|
element({ id: "caption", domId: "caption", track: 0, tag: "div" }),
|
||||||
|
],
|
||||||
|
track: 0,
|
||||||
|
hidden: true,
|
||||||
|
previewIframe: null,
|
||||||
|
writeProjectFile: async () => {},
|
||||||
|
recordEdit,
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set() },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toggleTimelineElementHidden", () => {
|
describe("toggleTimelineElementHidden", () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
trackDisplaySuffix,
|
trackDisplaySuffix,
|
||||||
} from "../player/components/timelineTrackDisplay";
|
} from "../player/components/timelineTrackDisplay";
|
||||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||||
|
import { isAudioTimelineElement } from "../utils/timelineInspector";
|
||||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||||
import {
|
import {
|
||||||
applyPatchByTarget,
|
applyPatchByTarget,
|
||||||
@@ -218,12 +219,21 @@ export async function toggleTimelineTrackHidden({
|
|||||||
const suffix = trackDisplaySuffix(
|
const suffix = trackDisplaySuffix(
|
||||||
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
||||||
);
|
);
|
||||||
|
const trackElements = timelineElements.filter((element) => element.track === track);
|
||||||
|
const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
|
||||||
|
const label = isAudioOnlyTrack
|
||||||
|
? hidden
|
||||||
|
? `Mute track${suffix}`
|
||||||
|
: `Unmute track${suffix}`
|
||||||
|
: hidden
|
||||||
|
? `Hide track${suffix}`
|
||||||
|
: `Show track${suffix}`;
|
||||||
return setElementsHidden({
|
return setElementsHidden({
|
||||||
projectId,
|
projectId,
|
||||||
activeCompPath,
|
activeCompPath,
|
||||||
elements: timelineElements.filter((element) => element.track === track),
|
elements: trackElements,
|
||||||
hidden,
|
hidden,
|
||||||
label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
|
label,
|
||||||
previewIframe,
|
previewIframe,
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
recordEdit,
|
recordEdit,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||||
|
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||||
import { Music } from "../../icons/SystemIcons";
|
import { Music } from "../../icons/SystemIcons";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||||
@@ -61,24 +62,39 @@ interface TimelineTrackHeaderProps {
|
|||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
|
||||||
|
if (showAsMute) return hidden ? "Muted" : "Mute";
|
||||||
|
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
|
||||||
|
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
|
||||||
|
return <Icon size={14} weight="bold" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
function VisibilityButton({
|
function VisibilityButton({
|
||||||
hidden,
|
hidden,
|
||||||
trackNumber,
|
trackNumber,
|
||||||
trackDisplayNumber,
|
trackDisplayNumber,
|
||||||
visible,
|
visible,
|
||||||
|
isAudioTrack,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
trackDisplayNumber: number | null;
|
trackDisplayNumber: number | null;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
isAudioTrack?: boolean;
|
||||||
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||||
}) {
|
}) {
|
||||||
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
||||||
// 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 label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
const showAsMute = Boolean(isAudioTrack) && isCanaryEnabled("audio-track-mute");
|
||||||
|
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -93,11 +109,7 @@ function VisibilityButton({
|
|||||||
void onToggle?.(trackNumber, !hidden);
|
void onToggle?.(trackNumber, !hidden);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hidden ? (
|
{visibilityButtonIcon(showAsMute, hidden)}
|
||||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,7 +141,14 @@ function PlainTrackHeader({
|
|||||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||||
)}
|
)}
|
||||||
{showTrackLabel && (
|
{showTrackLabel && (
|
||||||
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate text-[11px] ${
|
||||||
|
isAudioTrack && isTrackHidden && isCanaryEnabled("audio-track-mute")
|
||||||
|
? "line-through"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
title={trackLabel}
|
||||||
|
>
|
||||||
{trackLabel}
|
{trackLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -139,6 +158,7 @@ function PlainTrackHeader({
|
|||||||
trackNumber={trackNumber}
|
trackNumber={trackNumber}
|
||||||
trackDisplayNumber={trackDisplayNumber}
|
trackDisplayNumber={trackDisplayNumber}
|
||||||
visible
|
visible
|
||||||
|
isAudioTrack={isAudioTrack}
|
||||||
onToggle={onToggleTrackHidden}
|
onToggle={onToggleTrackHidden}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -406,6 +426,7 @@ function AutomationLaneHeaderRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
export function TimelineTrackHeader({
|
export function TimelineTrackHeader({
|
||||||
trackNumber,
|
trackNumber,
|
||||||
trackDisplayNumber,
|
trackDisplayNumber,
|
||||||
@@ -515,6 +536,7 @@ export function TimelineTrackHeader({
|
|||||||
trackNumber={trackNumber}
|
trackNumber={trackNumber}
|
||||||
trackDisplayNumber={trackDisplayNumber}
|
trackDisplayNumber={trackDisplayNumber}
|
||||||
visible
|
visible
|
||||||
|
isAudioTrack={isAudioTrack}
|
||||||
onToggle={onToggleTrackHidden}
|
onToggle={onToggleTrackHidden}
|
||||||
/>
|
/>
|
||||||
</LayerDisclosureRow>
|
</LayerDisclosureRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user