mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +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
@@ -194,6 +194,87 @@ describe("toggleTimelineTrackHidden", () => {
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
trackDisplaySuffix,
|
||||
} from "../player/components/timelineTrackDisplay";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { isAudioTimelineElement } from "../utils/timelineInspector";
|
||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
@@ -218,12 +219,21 @@ export async function toggleTimelineTrackHidden({
|
||||
const suffix = trackDisplaySuffix(
|
||||
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({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
elements: timelineElements.filter((element) => element.track === track),
|
||||
elements: trackElements,
|
||||
hidden,
|
||||
label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
|
||||
label,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
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 { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
@@ -61,24 +62,39 @@ interface TimelineTrackHeaderProps {
|
||||
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({
|
||||
hidden,
|
||||
trackNumber,
|
||||
trackDisplayNumber,
|
||||
visible,
|
||||
isAudioTrack,
|
||||
onToggle,
|
||||
}: {
|
||||
hidden: boolean;
|
||||
trackNumber: number;
|
||||
trackDisplayNumber: number | null;
|
||||
visible: boolean;
|
||||
isAudioTrack?: boolean;
|
||||
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
}) {
|
||||
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
|
||||
// conflated in either direction.
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,11 +109,7 @@ function VisibilityButton({
|
||||
void onToggle?.(trackNumber, !hidden);
|
||||
}}
|
||||
>
|
||||
{hidden ? (
|
||||
<EyeSlash size={14} weight="bold" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={14} weight="bold" aria-hidden="true" />
|
||||
)}
|
||||
{visibilityButtonIcon(showAsMute, hidden)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +141,14 @@ function PlainTrackHeader({
|
||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -139,6 +158,7 @@ function PlainTrackHeader({
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
visible
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
</>
|
||||
@@ -406,6 +426,7 @@ function AutomationLaneHeaderRow({
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function TimelineTrackHeader({
|
||||
trackNumber,
|
||||
trackDisplayNumber,
|
||||
@@ -515,6 +536,7 @@ export function TimelineTrackHeader({
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
visible
|
||||
isAudioTrack={isAudioTrack}
|
||||
onToggle={onToggleTrackHidden}
|
||||
/>
|
||||
</LayerDisclosureRow>
|
||||
|
||||
Reference in New Issue
Block a user