diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 4d5cc7f74d..4cd3676df2 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -17,7 +17,7 @@ import type { JsonPatchOp, } from "../types.js"; import type { ParsedDocument } from "./model.js"; -import { MEDIA_LINK_ATTR } from "@hyperframes/core/media-link"; +import { MEDIA_LINK_ATTR, linkScopeOf } from "@hyperframes/core/media-link"; import { idsToUnlink, linkedPartnerIds } from "./linkedTiming.js"; import { resolveScoped, @@ -298,16 +298,96 @@ function applySetTiming( const own = handleSetTiming(parsed, ids, timing); return concatResults(own, handleSetAttribute(parsed, unlink, MEDIA_LINK_ATTR, null)); } - const partners = - op.start === undefined && op.duration === undefined - ? [] - : linkedPartnerIds(parsed.document, ids); - const own = handleSetTiming(parsed, ids, timing); - if (partners.length === 0) return own; - return concatResults( - own, - handleSetTiming(parsed, partners, { start: op.start, duration: op.duration }), - ); + const plan = planLinkedTiming(parsed, ids, timing); + if (plan.refusal) throw new Error(plan.refusal); + let result = handleSetTiming(parsed, ids, timing); + for (const partner of plan.partners) { + result = concatResults(result, handleSetTiming(parsed, [partner.id], partner.timing)); + } + return result; +} + +type PartnerEdit = { id: HfId; timing: { start?: number; duration?: number } }; + +function planLinkedTiming( + parsed: ParsedDocument, + ids: HfId[], + timing: { start?: number; duration?: number }, +): { partners: PartnerEdit[]; refusal: string | null } { + if (timing.start === undefined && timing.duration === undefined) { + return { partners: [], refusal: null }; + } + const grabbedFor = grabbedBaselines(parsed, ids); + const partners: PartnerEdit[] = []; + for (const id of linkedPartnerIds(parsed.document, ids)) { + const el = resolveScoped(parsed.document, id); + const grabbed = el ? grabbedFor(el) : undefined; + if (!el || !grabbed) continue; + const partnerEdit = partnerTiming(grabbed, readClipTiming(el), timing); + if (partnerEdit.duration !== undefined && partnerEdit.duration <= 0) { + const tag = el.tagName.toLowerCase(); + return { + partners: [], + refusal: `Linked ${tag} would start after the new end — unlink or trim the ${tag} first.`, + }; + } + partners.push({ id, timing: partnerEdit }); + } + return { partners, refusal: null }; +} + +type ClipWindow = { start: number | null; duration: number | null }; + +function grabbedBaselines( + parsed: ParsedDocument, + ids: HfId[], +): (partner: Element) => ClipWindow | undefined { + const byScope = new Map>(); + for (const id of ids) { + const el = resolveScoped(parsed.document, id); + const link = el?.getAttribute(MEDIA_LINK_ATTR); + if (!el || !link) continue; + const byLink = byScope.get(linkScopeOf(el)) ?? new Map(); + byLink.set(link, readClipTiming(el)); + byScope.set(linkScopeOf(el), byLink); + } + return (partner) => { + const link = partner.getAttribute(MEDIA_LINK_ATTR); + return link ? byScope.get(linkScopeOf(partner))?.get(link) : undefined; + }; +} +const ALIGN_EPSILON_S = 1e-3; + +/** + * A partner follows the edit without resyncing: a start change shifts it by the + * same delta (keeping any offset); a duration change carries over only when the + * partner's end sat at the edited clip's end. + */ +function partnerTiming( + grabbed: ClipWindow, + partner: ClipWindow, + edit: { start?: number; duration?: number }, +): { start?: number; duration?: number } { + const timing: { start?: number; duration?: number } = {}; + const [gStart, pStart] = [grabbed.start ?? 0, partner.start ?? 0]; + if (edit.start !== undefined) timing.start = pStart + (edit.start - gStart); + const duration = partnerDuration(grabbed, partner, edit, timing.start ?? pStart); + if (duration !== undefined) timing.duration = duration; + return timing; +} + +function partnerDuration( + grabbed: ClipWindow, + partner: ClipWindow, + edit: { start?: number; duration?: number }, + partnerStart: number, +): number | undefined { + if (edit.duration === undefined) return undefined; + const grabbedStart = grabbed.start ?? 0; + const grabbedEnd = grabbedStart + (grabbed.duration ?? 0); + const partnerEnd = (partner.start ?? 0) + (partner.duration ?? 0); + if (Math.abs(partnerEnd - grabbedEnd) >= ALIGN_EPSILON_S) return undefined; + return (edit.start ?? grabbedStart) + edit.duration - partnerStart; } export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { @@ -1667,7 +1747,11 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { `Element(s) not found: ${missing.join(", ")}.`, "Verify the id against comp.getElements() or comp.find().", ); - return CAN_OK; + const refusal = + op.type === "setTiming" && op.linked !== false + ? planLinkedTiming(parsed, ids, op).refusal + : null; + return refusal ? canErr("E_LINKED_PARTNER_CROSSED", refusal) : CAN_OK; } case "addElement": { if (op.parent !== null && resolveScoped(parsed.document, op.parent) === null) diff --git a/packages/sdk/src/session.linked.test.ts b/packages/sdk/src/session.linked.test.ts index 4baac66e70..7829f284b5 100644 --- a/packages/sdk/src/session.linked.test.ts +++ b/packages/sdk/src/session.linked.test.ts @@ -36,6 +36,31 @@ describe("setTiming on linked clips", () => { expect(attr(html, "hf-talk-audio", "data-duration")).toBe("6"); }); + it("moves a misaligned pair by the same delta, preserving the offset", async () => { + const html = LINKED_HTML.replace( + 'src="talk.mp4" data-link="lk-1" data-start="2"', + 'src="talk.mp4" data-link="lk-1" data-start="3"', + ); + const comp = await openComposition(html); + comp.setTiming("hf-talk", { start: 5 }); + const out = comp.serialize(); + expect(attr(out, "hf-talk", "data-start")).toBe("5"); + expect(attr(out, "hf-talk-audio", "data-start")).toBe("6"); + }); + + it("keeps a common end when partners start at different times", async () => { + const offset = LINKED_HTML.replace( + 'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6"', + 'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5"', + ); + const comp = await openComposition(offset); + comp.setTiming("hf-talk", { duration: 4 }); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-duration")).toBe("4"); + expect(attr(html, "hf-talk-audio", "data-start")).toBe("3"); + expect(attr(html, "hf-talk-audio", "data-duration")).toBe("3"); + }); + it("{ linked: false } edits one member and unlinks the pair", async () => { const comp = await openComposition(LINKED_HTML); comp.setTiming("hf-talk", { start: 5 }, { linked: false }); @@ -127,4 +152,100 @@ describe("setTiming on linked clips", () => { ); expect(comp.serialize()).toBe((await openComposition(html)).serialize()); }); + + describe("a duration edit whose new end would cross a linked partner's start", () => { + const OFFSET_HTML = LINKED_HTML.replace( + 'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6"', + 'data-hf-id="hf-talk-audio" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5"', + ); + + it.each([1, 0.5])("refuses duration %s atomically, before any mutation", async (duration) => { + const comp = await openComposition(OFFSET_HTML); + comp.setTiming("hf-music", { start: 1 }); + const before = comp.serialize(); + expect(comp.can({ type: "setTiming", target: "hf-talk", duration })).toMatchObject({ + ok: false, + code: "E_LINKED_PARTNER_CROSSED", + }); + expect(() => comp.setTiming("hf-talk", { duration })).toThrow( + /linked audio would start after the new end/i, + ); + expect(comp.serialize()).toBe(before); + comp.undo(); + expect(attr(comp.serialize(), "hf-music", "data-start")).toBe("0"); + expect(comp.canUndo()).toBe(false); + }); + + it("trims the partner to the shared end while that end stays after its start", async () => { + const comp = await openComposition(OFFSET_HTML); + expect(comp.can({ type: "setTiming", target: "hf-talk", duration: 1.5 }).ok).toBe(true); + comp.setTiming("hf-talk", { duration: 1.5 }); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-duration")).toBe("1.5"); + expect(attr(html, "hf-talk-audio", "data-start")).toBe("3"); + expect(attr(html, "hf-talk-audio", "data-duration")).toBe("0.5"); + }); + + it("allows the same edit once the pair is unlinked", async () => { + const comp = await openComposition(OFFSET_HTML); + comp.setTiming("hf-talk", { duration: 0.5 }, { linked: false }); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-duration")).toBe("0.5"); + expect(attr(html, "hf-talk-audio", "data-duration")).toBe("5"); + }); + }); + + describe.each([ + { host: 'data-composition-id="child" data-composition-file="child.html"', cv: "hf-host/hf-cv" }, + { host: 'data-composition-id="child"', cv: "hf-cv" }, + ])( + "one edit naming linked clips in two compositions that reuse a link id ($host)", + ({ host, cv }) => { + const TWO_SCOPES_HTML = ` +
+ + +
+ + +
+
`.trim(); + + it("trims each audio against its own video's baseline", async () => { + const comp = await openComposition(TWO_SCOPES_HTML); + comp.dispatch({ type: "setTiming", target: ["hf-v", cv], duration: 0.5 }); + const html = comp.serialize(); + expect([attr(html, "hf-v", "data-start"), attr(html, "hf-v", "data-duration")]).toEqual([ + "2", + "0.5", + ]); + expect([attr(html, "hf-a", "data-start"), attr(html, "hf-a", "data-duration")]).toEqual([ + "2", + "0.5", + ]); + expect([attr(html, "hf-cv", "data-start"), attr(html, "hf-cv", "data-duration")]).toEqual([ + "0", + "0.5", + ]); + expect([attr(html, "hf-ca", "data-start"), attr(html, "hf-ca", "data-duration")]).toEqual([ + "0", + "0.5", + ]); + }); + + it("refuses against the scoped baseline when one scope's audio would cross its video's end", async () => { + const crossing = TWO_SCOPES_HTML.replace( + 'data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6"', + 'data-hf-id="hf-a" src="talk.mp4" data-link="lk-1" data-start="3" data-duration="5"', + ); + const comp = await openComposition(crossing); + const before = comp.serialize(); + const op = { type: "setTiming" as const, target: ["hf-v", cv], duration: 0.5 }; + expect(comp.can(op)).toMatchObject({ ok: false, code: "E_LINKED_PARTNER_CROSSED" }); + expect(() => comp.dispatch(op)).toThrow(/linked audio would start after the new end/i); + expect(comp.serialize()).toBe(before); + expect(comp.canUndo()).toBe(false); + }); + }, + ); }); diff --git a/packages/studio/src/components/editor/mediaLinkEdits.test.ts b/packages/studio/src/components/editor/mediaLinkEdits.test.ts index 5fbc15e534..5e9da0df07 100644 --- a/packages/studio/src/components/editor/mediaLinkEdits.test.ts +++ b/packages/studio/src/components/editor/mediaLinkEdits.test.ts @@ -10,6 +10,7 @@ import { pickDetachedAudioTrack, removeElementInSource, setLinkInSource, + sharesSourceFile, } from "./mediaLinkEdits"; const automation = JSON.stringify({ @@ -192,6 +193,15 @@ describe("linkInSource", () => { }); }); +describe("linkInSource without a sync origin", () => { + it("writes only data-link", () => { + const src = ''; + expect(linkInSource(src, [{ id: "v" }, { id: "a" }], { syncOrigin: false })).toBe( + '', + ); + }); +}); + describe("setLinkInSource", () => { it("writes and removes data-link on every target", () => { const src = ''; @@ -248,9 +258,12 @@ describe("predicates", () => { expect(findMergePair(v, [v, el("a", "audio", { src: "other.mp4" })])).toBeNull(); }); - it("links exactly one video and one audio of the same file and timing", () => { + it("links exactly one unlinked video and one audio, whatever their timing or file", () => { expect(canLinkPair([el("v", "video"), el("a", "audio")])).toBe(true); - expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3 })])).toBe(false); + expect(canLinkPair([el("v", "video"), el("a", "audio", { start: 3, duration: 2 })])).toBe(true); + expect(canLinkPair([el("v", "video"), el("a", "audio", { src: "other.mp3" })])).toBe(true); + expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio")])).toBe(false); + expect(canLinkPair([el("v", "video"), el("a", "audio", { link: "y" })])).toBe(false); expect(canLinkPair([el("v", "video"), el("w", "video")])).toBe(false); expect(canLinkPair([el("v", "video", { link: "x" }), el("a", "audio", { link: "x" })])).toBe( false, @@ -266,16 +279,16 @@ describe("predicates compare the whole asset path", () => { const v = el("v", "video", { muted: true, ...one }); const a = el("a", "audio", two); expect(findMergePair(v, [v, a])).toBeNull(); - expect(canLinkPair([el("v", "video", one), el("a", "audio", two)])).toBe(false); + expect(sharesSourceFile([el("v", "video", one), el("a", "audio", two)])).toBe(false); }); it("resolves each src against its own source file, and links only inside one", () => { const v = el("v", "video", { src: "../assets/talk.mp4", sourceFile: "scenes/a.html" }); const a = el("a", "audio", { src: "./assets/talk.mp4", sourceFile: "index.html" }); expect(canLinkPair([v, a])).toBe(false); - expect( - canLinkPair([v, { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" }]), - ).toBe(true); + expect(sharesSourceFile([v, a])).toBe(false); + const sameFileAudio = { ...a, src: "../assets/./talk.mp4", sourceFile: "scenes/a.html" }; + expect(sharesSourceFile([v, sameFileAudio])).toBe(true); }); it("does not take a same-id link from another source file as the merge partner", () => { diff --git a/packages/studio/src/components/editor/mediaLinkEdits.ts b/packages/studio/src/components/editor/mediaLinkEdits.ts index 999895e721..10113c77be 100644 --- a/packages/studio/src/components/editor/mediaLinkEdits.ts +++ b/packages/studio/src/components/editor/mediaLinkEdits.ts @@ -1,5 +1,10 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation"; -import { MEDIA_LINK_ATTR, SYNC_ORIGIN_ATTR, mintLinkId } from "@hyperframes/core/media-link"; +import { + MEDIA_LINK_ATTR, + SYNC_ORIGIN_ATTR, + mintLinkId, + sourceZeroTime, +} from "@hyperframes/core/media-link"; import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement"; import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink"; import { @@ -62,14 +67,17 @@ export function setLinkInSource( ); } -/** Link the targets under one freshly minted id, which is also their sync origin. */ -export function linkInSource(source: string, targets: readonly PatchTarget[]): string { +/** Link the targets under one fresh id, also their sync origin when they share a source file. */ +export function linkInSource( + source: string, + targets: readonly PatchTarget[], + options: { syncOrigin: boolean } = { syncOrigin: true }, +): string { const linkId = mintLinkIdForSource(source); - return targets.reduce( - (html, target) => - applyOps(html, target, [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]), - source, - ); + const ops = options.syncOrigin + ? [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)] + : [dataOp(LINK_PROPERTY, linkId)]; + return targets.reduce((html, target) => applyOps(html, target, ops), source); } /** Remove one element (open tag through its matching close tag) and its line's indentation. */ @@ -242,15 +250,28 @@ export function findMergePair( return video.muted === true ? { video, audio } : null; } -/** Exactly one video and one audio, same file, identical timing, not already linked together. */ +/** One video and one audio in one composition, neither linked: timing and file don't matter. */ export function canLinkPair(selected: readonly TimedElement[]): boolean { if (selected.length !== 2) return false; const [a, b] = selected; - if (!a || !b) return false; + if (!a || !b || a.link || b.link) return false; const tags = new Set([tagOf(a), tagOf(b)]); - if (!tags.has("video") || !tags.has("audio")) return false; - if (sharesLinkGroup(a, b)) return false; - return sameAssetInScope(a, b) && hasIdenticalTiming(a, b); + return tags.has("video") && tags.has("audio") && sameCompositionScope(a, b); +} + +export function sharesSourceFile(selected: readonly TimedElement[]): boolean { + const [a, b] = selected; + return selected.length === 2 && !!a && !!b && sameAssetInScope(a, b); +} + +export function isPairInSync(a: TimedElement, b: TimedElement): boolean { + const timing = (el: TimedElement) => ({ + start: el.start, + mediaStart: el.playbackStart ?? 0, + playbackRate: el.playbackRate ?? 1, + }); + const [x, y] = [timing(a), timing(b)]; + return near(x.playbackRate, y.playbackRate) && near(sourceZeroTime(x), sourceZeroTime(y)); } type TrackedElement = Pick & { diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index beacb819ed..8f66485f15 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -72,6 +72,7 @@ export interface TimelineEditCallbackDeps { value: string | null, label: string, ) => Promise; + setMany?: TimelineEditCallbacks["onSetElementsAttributeQuiet"]; }; } @@ -228,6 +229,7 @@ export function useTimelineEditCallbacks({ onGroupClips: handleGroupClips, onSetElementAttributeLive: setElementFxAttribute?.setLive, onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet, + onSetElementsAttributeQuiet: setElementFxAttribute?.setMany, onRevertElementAttributeLive: setElementFxAttribute?.revertLive, onBlockedEditAttempt: handleBlockedTimelineEdit, onSplitElement: handleTimelineElementSplit, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 3144ee661f..48d7989111 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -37,6 +37,7 @@ const EDIT_CALLBACK_KEY_SET: Record = { onGroupClips: true, onSetElementAttributeLive: true, onSetElementAttributeQuiet: true, + onSetElementsAttributeQuiet: true, onRevertElementAttributeLive: true, onBlockedEditAttempt: true, onLinkEdit: true, diff --git a/packages/studio/src/hooks/appHotkeysDispatch.ts b/packages/studio/src/hooks/appHotkeysDispatch.ts index 35c82f6ef2..f7dae35234 100644 --- a/packages/studio/src/hooks/appHotkeysDispatch.ts +++ b/packages/studio/src/hooks/appHotkeysDispatch.ts @@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; import { canSplitElement } from "../utils/timelineElementSplit"; import { trackStudioEvent } from "../utils/studioTelemetry"; import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts"; +import { openAudioGainDialog } from "../player/components/audioGainDialogStore"; import type { LinkShortcutCallbacks } from "./linkShortcuts"; // Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap, @@ -190,6 +191,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa } } + // Only with a sound clip selected, so bare G still reaches the canvas grid toggle otherwise. + if (key === STUDIO_PLAIN_KEYS.audioGain && !event.shiftKey && !event.altKey) { + if (!cb.readOnlyPreview && openAudioGainDialog()) { + event.preventDefault(); + return; + } + } + if (key === "b" && !event.shiftKey && !event.altKey) { event.preventDefault(); const { activeTool, setActiveTool } = usePlayerStore.getState(); diff --git a/packages/studio/src/hooks/timelineElementsAttribute.test.ts b/packages/studio/src/hooks/timelineElementsAttribute.test.ts new file mode 100644 index 0000000000..781dd10566 --- /dev/null +++ b/packages/studio/src/hooks/timelineElementsAttribute.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); +import type { TimelineElement } from "../player"; + +const saved = vi.hoisted(() => ({ inputs: [] as Array> })); +vi.mock("../utils/studioFileHistory", () => ({ + saveProjectFilesWithHistory: vi.fn(async (input: Record) => { + saved.inputs.push(input); + return []; + }), +})); + +import { useSetElementsAttribute } from "./timelineElementsAttribute"; + +const clip = (id: string, sourceFile?: string): TimelineElement => ({ + id, + domId: id, + tag: "audio", + start: 0, + duration: 1, + track: 0, + sourceFile, +}); + +describe("useSetElementsAttribute", () => { + it("writes every clip's attribute in one history entry, one builder per file", async () => { + const showToast = vi.fn(); + let write: ReturnType | null = null; + function Probe() { + write = useSetElementsAttribute({ + projectIdRef: { current: "p1" }, + activeCompPath: "index.html", + showToast, + writeProjectFile: vi.fn(async () => {}), + recordEdit: vi.fn(async () => {}), + previewIframeRef: { current: null }, + pendingTimelineEditPathRef: { current: new Set() }, + }); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render(createElement(Probe))); + await write?.( + [ + { element: clip("a"), value: "0.5" }, + { element: clip("b"), value: "2" }, + { element: clip("c", "scene.html"), value: "1.5" }, + ], + "data-volume", + "Audio Gain", + ); + act(() => root.unmount()); + expect(showToast).not.toHaveBeenCalled(); + expect(saved.inputs).toHaveLength(1); + const input = saved.inputs[0]; + expect(input?.["label"]).toBe("Audio Gain"); + const files = input?.["files"]; + if (typeof files !== "object" || files === null) throw new Error("no files"); + expect(Object.keys(files).sort()).toEqual(["index.html", "scene.html"]); + const build = Reflect.get(files, "index.html"); + const html = ''; + const out = typeof build === "function" ? String(build(html)) : ""; + expect(out).toContain('id="a" data-volume="0.5"'); + expect(out).toMatch(/id="b"[^>]*data-volume="2"/); + }); + + it("refuses while recording, like the single-clip write", async () => { + saved.inputs.length = 0; + const showToast = vi.fn(); + let write: ReturnType | null = null; + function Probe() { + write = useSetElementsAttribute({ + projectIdRef: { current: "p1" }, + activeCompPath: "index.html", + showToast, + writeProjectFile: vi.fn(async () => {}), + recordEdit: vi.fn(async () => {}), + previewIframeRef: { current: null }, + pendingTimelineEditPathRef: { current: new Set() }, + isRecordingRef: { current: true }, + }); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render(createElement(Probe))); + await write?.([{ element: clip("a"), value: "0.5" }], "data-volume", "Audio Gain"); + act(() => root.unmount()); + expect(saved.inputs).toHaveLength(0); + expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error"); + }); +}); diff --git a/packages/studio/src/hooks/timelineElementsAttribute.ts b/packages/studio/src/hooks/timelineElementsAttribute.ts new file mode 100644 index 0000000000..9254339ce9 --- /dev/null +++ b/packages/studio/src/hooks/timelineElementsAttribute.ts @@ -0,0 +1,113 @@ +import { useCallback } from "react"; +import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks"; +import { syncStoredElementAttribute } from "../player/lib/automationStoreSync"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { applyPatchByTarget } from "../utils/sourcePatcher"; +import { + buildPatchTarget, + findTimelineElementInIframe, + readFileContent, +} from "./timelineEditingHelpers"; +import type { UseTimelineElementVisibilityEditingInput } from "./timelineTrackVisibility"; +import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission"; + +type ElementAttributeEdits = Parameters< + NonNullable +>[0]; + +type PatchTarget = NonNullable>; + +function editsByFile( + edits: ElementAttributeEdits, + activeCompPath: string | null, +): Map> { + const byFile = new Map>(); + for (const { element, value } of edits) { + const target = buildPatchTarget(element); + if (!target) throw new Error("A clip has no id to save it by"); + const path = element.sourceFile || activeCompPath || "index.html"; + byFile.set(path, [...(byFile.get(path) ?? []), { target, value }]); + } + return byFile; +} + +function patchLive( + iframe: HTMLIFrameElement | null, + edits: ElementAttributeEdits, + attr: string, + activeCompPath: string | null, +): void { + for (const { element, value } of edits) { + const node = findTimelineElementInIframe(iframe, element, activeCompPath); + if (value === null) node?.removeAttribute(attr); + else node?.setAttribute(attr, value); + syncStoredElementAttribute(element, attr, value); + } +} + +/** One attribute written on several clips as a single save and a single undo step. */ +export function useSetElementsAttribute({ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + previewIframeRef, + pendingTimelineEditPathRef, + isRecordingRef, +}: UseTimelineElementVisibilityEditingInput) { + return useCallback( + async ( + edits: ElementAttributeEdits, + attr: string, + label: string, + ): Promise => { + const projectId = projectForTimelineSave( + isRecordingRef?.current, + projectIdRef.current, + showToast, + ); + if (typeof projectId !== "string") return projectId; + if (edits.length === 0) return { status: "saved" }; + try { + const byFile = editsByFile(edits, activeCompPath); + const files = Object.fromEntries( + [...byFile].map(([path, patches]) => [ + path, + (before: string) => + patches.reduce( + (html, { target, value }) => + applyPatchByTarget(html, target, { type: "attribute", property: attr, value }), + before, + ), + ]), + ); + for (const path of byFile.keys()) pendingTimelineEditPathRef.current.add(path); + await saveProjectFilesWithHistory({ + projectId, + label, + files, + readFile: (path) => readFileContent(projectId, path), + writeFile: writeProjectFile, + recordEdit, + }); + patchLive(previewIframeRef.current, edits, attr, activeCompPath); + return { status: "saved" }; + } catch (error) { + const reason = error instanceof Error ? error.message : "Could not save the clips"; + showToast(reason, "error"); + return { status: "failed", reason }; + } + }, + [ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + previewIframeRef, + pendingTimelineEditPathRef, + isRecordingRef, + ], + ); +} diff --git a/packages/studio/src/hooks/timelineLinkEditPlan.test.ts b/packages/studio/src/hooks/timelineLinkEditPlan.test.ts index 5c95035193..644e7e89ad 100644 --- a/packages/studio/src/hooks/timelineLinkEditPlan.test.ts +++ b/packages/studio/src/hooks/timelineLinkEditPlan.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it } from "vitest"; import type { TimelineElement } from "../player"; +import { clipSyncState } from "../player/components/clipSync"; import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan"; const clip = (id: string, tag: string, extra: Partial = {}): TimelineElement => ({ @@ -111,3 +112,30 @@ describe("planLinkEdit into sync", () => { expect(html).toBe(''); }); }); + +describe("link edit plan", () => { + const PAIR = + '
'; + const linkPlan = (src: string, audioEl: TimelineElement) => { + const v = clip("talk", "video", { muted: true }); + const plan = planLinkEdit({ kind: "link", elements: [v, audioEl] }, [v, audioEl]); + return plan?.transform(PAIR.replace("%SRC%", src)) ?? ""; + }; + + it("links a trimmed same-file audio with a sync origin that shows a nonzero offset", () => { + const trimmed = clip("a", "audio", { start: 3, duration: 3, playbackStart: 0 }); + const out = linkPlan("talk.mp4", trimmed); + expect(out).toMatch(/]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/); + expect(out).toMatch(/]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/); + const v = clip("talk", "video", { muted: true, syncOrigin: "lk-1" }); + const a = { ...trimmed, syncOrigin: "lk-1" }; + expect(clipSyncState(a, [v, a], 30)?.frames).toBe(30); + }); + + it("links different files without a sync origin", () => { + const music = clip("a", "audio", { src: "bgm.mp3" }); + const out = linkPlan("bgm.mp3", music); + expect(out).toContain('data-link="lk-1"'); + expect(out).not.toContain("data-sync-origin"); + }); +}); diff --git a/packages/studio/src/hooks/timelineLinkEditPlan.ts b/packages/studio/src/hooks/timelineLinkEditPlan.ts index c3d6bce414..56f293c816 100644 --- a/packages/studio/src/hooks/timelineLinkEditPlan.ts +++ b/packages/studio/src/hooks/timelineLinkEditPlan.ts @@ -13,6 +13,7 @@ import { mergeAudioInSource, pickDetachedAudioTrack, setLinkInSource, + sharesSourceFile, } from "../components/editor/mediaLinkEdits"; export interface LinkEditPlan { @@ -92,7 +93,11 @@ export function planLinkEdit( const targets = targetsOf(edit.elements); const anchor = edit.elements[0]; if (!targets || !anchor) return null; - return { label: "Link clips", anchor, transform: (s) => linkInSource(s, targets) }; + return { + label: "Link clips", + anchor, + transform: (s) => linkInSource(s, targets, { syncOrigin: sharesSourceFile(edit.elements) }), + }; } case "detach": { const target = buildPatchTarget(edit.element); diff --git a/packages/studio/src/hooks/useAppHotkeys.test.ts b/packages/studio/src/hooks/useAppHotkeys.test.ts index f02e3e0551..2d3a850470 100644 --- a/packages/studio/src/hooks/useAppHotkeys.test.ts +++ b/packages/studio/src/hooks/useAppHotkeys.test.ts @@ -6,6 +6,7 @@ import type { DomEditSelection } from "../components/editor/domEditing"; import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard"; import { VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import type { TimelineElement } from "../player/store/timelineElement"; +import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore"; /** Minimal valid fixture — TimelineElement only requires these five fields. */ const bgmElement: TimelineElement = { @@ -474,3 +475,36 @@ describe("hotkeys with the preview read-only", () => { expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1); }); }); + +describe("dispatchPlainKey — G opens Audio Gain", () => { + const video: TimelineElement = { + id: "b-roll", + key: "b-roll", + tag: "video", + start: 0, + duration: 4, + track: 1, + }; + + afterEach(() => useAudioGainDialogStore.getState().close()); + + it("opens the dialog for the selected clips with sound and owns the key", () => { + usePlayerStore.setState({ + elements: [bgmElement, video], + selectedElementId: "bgm", + selectedElementIds: new Set(["bgm", "b-roll"]), + }); + const event = press("g"); + dispatchPlainKey(event, "g", callbacks()); + expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["bgm"]); + expect(event.defaultPrevented).toBe(true); + }); + + it("leaves G to the grid toggle when nothing selected has sound", () => { + usePlayerStore.setState({ elements: [video], selectedElementId: "b-roll" }); + const event = press("g"); + dispatchPlainKey(event, "g", callbacks()); + expect(useAudioGainDialogStore.getState().targetKeys).toBeNull(); + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index 87d4d7a657..b677c531a4 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -14,6 +14,7 @@ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWavefo import { ImageThumbnail } from "../player/components/ImageThumbnail"; import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent"; import { ClipPeakMarks } from "../player/components/ClipPeakMarks"; +import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap"; import { clipHasSound } from "../player/components/clipMenuNormalize"; import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils"; import { usePlayerStore } from "../player/store/playerStore"; @@ -91,11 +92,8 @@ function renderAudioClip( return createElement( ClipPeakMarks, { - peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined, - sourceWindow: { - mediaStart: el.playbackStart ?? 0, - sourceSpan: el.duration * (el.playbackRate ?? 1), - }, + peaksUrl: clipPeaksUrl(el.src, pid), + sourceWindow: clipSourceWindow(el), gain: el.volume ?? 1, }, waveform, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index c4103daad8..3471a11e3d 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -26,6 +26,7 @@ import { import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume"; import { useSetElementAttribute } from "./timelineElementFxAttribute"; +import { useSetElementsAttribute } from "./timelineElementsAttribute"; import { useTimelineDeleteOps } from "./useTimelineDeleteOps"; import { useTimelineEditGuard } from "./useTimelineEditGuard"; import { @@ -409,6 +410,17 @@ export function useTimelineEditing({ isRecordingRef, }); + const setElementsAttribute = useSetElementsAttribute({ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + previewIframeRef, + pendingTimelineEditPathRef, + isRecordingRef, + }); + const setAudioGroupAttribute = useSetAudioGroupAttribute({ projectIdRef, activeCompPath, @@ -532,6 +544,7 @@ export function useTimelineEditing({ }, setElementFxAttribute: { ...setElementFxAttribute, + setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)), setQuiet: track( guard( (element) => [element], diff --git a/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx b/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx index de7d0cd473..8cc1398e0f 100644 --- a/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx @@ -109,4 +109,16 @@ describe("useTimelineLinkEditing", () => { expect(recordEdit.mock.calls[0]?.[0].label).toBe("Unlink clips"); unmount(); }); + + it("unlink leaves nothing selected, so a later trim moves only the grabbed clip", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ content: SOURCE }), { status: 200 }), + ); + const { api, unmount } = await renderLinkEditing(); + usePlayerStore.getState().setSelection(["talk", "talk-audio"], "talk"); + await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] })); + expect(usePlayerStore.getState().selectedElementIds.size).toBe(0); + expect(usePlayerStore.getState().selectedElementId).toBeNull(); + unmount(); + }); }); diff --git a/packages/studio/src/hooks/useTimelineLinkEditing.ts b/packages/studio/src/hooks/useTimelineLinkEditing.ts index 6423e3acee..df5377030b 100644 --- a/packages/studio/src/hooks/useTimelineLinkEditing.ts +++ b/packages/studio/src/hooks/useTimelineLinkEditing.ts @@ -49,6 +49,10 @@ export function withLinkPartners(selection: readonly TimelineElement[]): Timelin return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))]; } +function clearSelectionOnUnlink(edit: TimelineLinkEdit): void { + if (edit.kind === "unlink") usePlayerStore.getState().clearSelection(); +} + export function useTimelineLinkEditing({ projectIdRef, activeCompPath, @@ -71,6 +75,7 @@ export function useTimelineLinkEditing({ const pid = projectIdRef.current; const plan = planLinkEdit(edit, usePlayerStore.getState().elements); if (!pid || !plan) return; + clearSelectionOnUnlink(edit); const path = plan.anchor.sourceFile || activeCompPath || "index.html"; pendingTimelineEditPathRef.current.add(path); const queued = editQueueRef.current.then(() => diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index b891063737..da6e19ce2a 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -32,6 +32,10 @@ export { formatTime, } from "./player"; export { usePreviewIframeStore } from "./player/store/previewIframeStore"; +export { + openAudioGainDialog, + useAudioGainDialogStore, +} from "./player/components/audioGainDialogStore"; export type { PlayerHandle, PlayerHandleElement, diff --git a/packages/studio/src/player/components/AudioGainDialog.test.tsx b/packages/studio/src/player/components/AudioGainDialog.test.tsx new file mode 100644 index 0000000000..c70e8214db --- /dev/null +++ b/packages/studio/src/player/components/AudioGainDialog.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { audioGainToDb } from "@hyperframes/core/audio-gain"; +import { createHappyDomRootHarness } from "./testRootHarness"; +import { AudioGainDialog } from "./AudioGainDialog"; +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineElement } from "../store/timelineElement"; + +const onNotice = vi.fn(); +const setQuiet = vi.fn(async () => {}); +const setMany = vi.fn( + async (_edits: ReadonlyArray<{ value: string | null }>, _attr: string, _label: string) => {}, +); + +vi.mock("../../contexts/StudioContext", () => ({ + useStudioShellContextOptional: () => null, +})); +vi.mock("../../contexts/TimelineEditContext", () => ({ + useTimelineEditContextOptional: () => ({ + onSetElementAttributeQuiet: setQuiet, + onSetElementsAttributeQuiet: setMany, + onNotice, + }), +})); + +const harness = createHappyDomRootHarness(); +const clip = (id: string, extra: Partial = {}): TimelineElement => ({ + id, + tag: "audio", + start: 0, + duration: 2, + track: 0, + src: `${id}.wav`, + ...extra, +}); + +function stubPeaks(peaks: Record) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + const name = Object.keys(peaks).find((key) => String(url).includes(`/peaks/${key}.wav`)); + return name + ? Response.json({ binSeconds: 1, bins: peaks[name] }) + : new Response("", { status: 404 }); + }), + ); +} + +function open(elements: TimelineElement[]) { + const host = document.createElement("div"); + document.body.appendChild(host); + act(() => harness.mount(host).render( {}} />)); +} + +const row = (text: string) => + [...document.querySelectorAll("label")].find((label) => label.textContent?.startsWith(text)); +const choose = (text: string) => + act(() => row(text)?.querySelector("input[type=radio]")?.click()); +function typeDb(text: string, value: string) { + const input = row(text)?.querySelector("input[type=number]"); + if (!input) throw new Error(`no field for ${text}`); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + act(() => { + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const ok = () => [...document.querySelectorAll("button")].find((b) => b.textContent === "OK"); +const peakText = () => document.querySelector("[data-testid='audio-gain-peak']")?.textContent; + +beforeEach(() => { + usePlayerStore.getState().beginTimelineSession("p1"); + vi.clearAllMocks(); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("AudioGainDialog", () => { + it("shows the selection's peak amplitude at the clips' current gain", async () => { + stubPeaks({ pk1: [0.25, 0.5] }); + open([clip("pk1", { volume: 2 })]); + await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: 0.0 dB")); + }); + + it("Set Gain writes every selected clip in one undo step", async () => { + stubPeaks({}); + const a = clip("set-a"); + const b = clip("set-b", { volume: 0.5 }); + open([a, b]); + choose("Set Gain to"); + typeDb("Set Gain to", "-6"); + await act(async () => ok()?.click()); + expect(setMany).toHaveBeenCalledTimes(1); + const [edits, attr, label] = setMany.mock.calls[0] ?? []; + expect(attr).toBe("data-volume"); + expect(label).toBe("Audio Gain"); + expect(edits?.map((e) => audioGainToDb(Number(e.value)))).toEqual([ + expect.closeTo(-6, 3), + expect.closeTo(-6, 3), + ]); + expect(setQuiet).not.toHaveBeenCalled(); + }); + + it("Normalize All Peaks brings each clip's own peak to the target", async () => { + stubPeaks({ na1: [0.5], na2: [0.25] }); + open([clip("na1"), clip("na2")]); + await vi.waitFor(() => expect(peakText()).not.toContain("measuring")); + choose("Normalize All Peaks to"); + typeDb("Normalize All Peaks to", "-3"); + await act(async () => ok()?.click()); + const [edits] = setMany.mock.calls[0] ?? []; + const gains = edits?.map((e) => Number(e.value)) ?? []; + expect(audioGainToDb(0.5 * (gains[0] ?? 0))).toBeCloseTo(-3, 3); + expect(audioGainToDb(0.25 * (gains[1] ?? 0))).toBeCloseTo(-3, 3); + }); + + it("refuses a peak target above 0 dB", async () => { + stubPeaks({ over: [0.5] }); + open([clip("over")]); + await vi.waitFor(() => expect(peakText()).not.toContain("measuring")); + choose("Normalize Max Peak to"); + typeDb("Normalize Max Peak to", "2"); + expect(ok()?.disabled).toBe(true); + }); + + it("disables the peak options when the clip cannot be measured", async () => { + stubPeaks({}); + open([clip("unmeasured")]); + await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: —")); + expect(row("Normalize Max Peak to")?.querySelector("input")?.disabled).toBe(true); + expect(row("Adjust Gain by")?.querySelector("input")?.disabled).toBe(false); + }); +}); diff --git a/packages/studio/src/player/components/AudioGainDialog.tsx b/packages/studio/src/player/components/AudioGainDialog.tsx new file mode 100644 index 0000000000..3dceed07de --- /dev/null +++ b/packages/studio/src/player/components/AudioGainDialog.tsx @@ -0,0 +1,226 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { audioGainToDb } from "@hyperframes/core/audio-gain"; +import type { TimelineElement } from "../store/timelineElement"; +import { usePlayerStore } from "../store/playerStore"; +import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { measureClipSourcePeak } from "./clipPeakMap"; +import { AUDIO_GAIN_LABEL, normalizeClipsLoudness, writeClipGains } from "./audioGainApply"; +import { useAudioGainDialogStore } from "./audioGainDialogStore"; +import { + peakAmplitudeDb, + planAudioGain, + type AudioGainClip, + type AudioGainMode, +} from "./audioGainPlan"; + +type DialogMode = AudioGainMode | "loudness"; + +const PEAK_MODES: ReadonlySet = new Set(["normalize-max", "normalize-all"]); + +const ROWS: ReadonlyArray<{ mode: AudioGainMode; label: string }> = [ + { mode: "set", label: "Set Gain to" }, + { mode: "adjust", label: "Adjust Gain by" }, + { mode: "normalize-max", label: "Normalize Max Peak to" }, + { mode: "normalize-all", label: "Normalize All Peaks to" }, +]; + +const keyOf = (el: TimelineElement) => el.key ?? el.id; +const currentGainDb = (el: TimelineElement | undefined) => { + const gain = el?.volume ?? 1; + return gain > 0 ? Math.round(audioGainToDb(gain) * 10) / 10 : -60; +}; +const dbText = (db: number) => `${db < 0 ? "−" : ""}${Math.abs(db).toFixed(1)} dB`; + +function useSourcePeaks(elements: readonly TimelineElement[], projectId: string | null) { + const [peaks, setPeaks] = useState | null>(null); + useEffect(() => { + if (!projectId) return; + let live = true; + void Promise.all(elements.map((el) => measureClipSourcePeak(el, projectId))).then((found) => { + if (live) setPeaks(new Map(elements.map((el, i) => [keyOf(el), found[i] ?? null]))); + }); + return () => { + live = false; + }; + }, [elements, projectId]); + return peaks; +} + +function Row({ + checked, + disabled, + label, + onSelect, + children, +}: { + checked: boolean; + disabled?: boolean; + label: string; + onSelect: () => void; + children?: ReactNode; +}) { + return ( + + ); +} + +/** Premiere's Audio Gain dialog for the clips the store names. */ +export function AudioGainDialog({ + elements, + onClose, +}: { + elements: readonly TimelineElement[]; + onClose: () => void; +}) { + const shell = useStudioShellContextOptional(); + const edit = useTimelineEditContextOptional(); + const sessionProjectId = usePlayerStore((s) => s.timelineProjectId); + const projectId = shell?.projectId ?? sessionProjectId; + const showToast = shell?.showToast ?? edit.onNotice; + const peaks = useSourcePeaks(elements, projectId); + const [mode, setMode] = useState("adjust"); + const [values, setValues] = useState>(() => ({ + set: currentGainDb(elements[0]), + adjust: 0, + "normalize-max": 0, + "normalize-all": 0, + })); + const clips: AudioGainClip[] = elements.map((el) => ({ + key: keyOf(el), + gain: el.volume ?? 1, + sourcePeak: peaks?.get(keyOf(el)) ?? null, + })); + const peakDb = peakAmplitudeDb(clips); + const outOfRange = PEAK_MODES.has(mode) && mode !== "loudness" && values[mode] > 0; + const canApply = !outOfRange && !(PEAK_MODES.has(mode) && peakDb === null); + + const apply = async () => { + if (!canApply) return; + onClose(); + try { + if (mode === "loudness") { + if (!projectId) return; + showToast?.(await normalizeClipsLoudness(projectId, elements, edit), "info"); + return; + } + const byKey = new Map(elements.map((el) => [keyOf(el), el])); + const plan = planAudioGain(clips, { mode, db: values[mode] }); + const edits = plan.flatMap(({ key, gain }) => { + const element = byKey.get(key); + return element ? [{ element, gain }] : []; + }); + await writeClipGains(edits, edit, AUDIO_GAIN_LABEL); + } catch (error) { + showToast?.(error instanceof Error ? error.message : String(error), "error"); + } + }; + + const field = (rowMode: AudioGainMode) => ( + + row.mode === rowMode)?.label ?? ""} dB`} + value={values[rowMode]} + disabled={mode !== rowMode} + className="w-16 rounded border border-neutral-700 bg-neutral-950 px-1 text-right disabled:opacity-50" + onChange={(event) => + setValues((prev) => ({ ...prev, [rowMode]: Number(event.target.value) || 0 })) + } + /> + dB + + ); + + return createPortal( +
event.target === event.currentTarget && onClose()} + > +
{ + event.stopPropagation(); + if (event.key === "Escape") onClose(); + if (event.key !== "Enter") return; + event.preventDefault(); + void apply(); + }} + > +
{AUDIO_GAIN_LABEL}
+ {ROWS.map((row) => ( + setMode(row.mode)} + > + {field(row.mode)} + + ))} + setMode("loudness")} + /> +
+ Peak Amplitude: {peaks === null ? "measuring…" : peakDb === null ? "—" : dbText(peakDb)} +
+ {outOfRange &&
Peak targets must be 0 dB or lower.
} +
+ + +
+
+
, + document.body, + ); +} + +/** Mounted in the timeline's overlays so Studio and embedders that mount the Timeline both get it. */ +export function TimelineAudioGainOverlay() { + const targetKeys = useAudioGainDialogStore((s) => s.targetKeys); + const close = useAudioGainDialogStore((s) => s.close); + const elements = usePlayerStore((s) => s.elements); + const targets = useMemo(() => { + const wanted = new Set(targetKeys ?? []); + return elements.filter((el) => wanted.has(keyOf(el))); + }, [elements, targetKeys]); + if (!targetKeys || targets.length === 0) return null; + return ; +} diff --git a/packages/studio/src/player/components/ClipBadges.test.tsx b/packages/studio/src/player/components/ClipBadges.test.tsx index e03e8c6617..57a53fc813 100644 --- a/packages/studio/src/player/components/ClipBadges.test.tsx +++ b/packages/studio/src/player/components/ClipBadges.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { usePreviewIframeStore } from "../store/previewIframeStore"; import { ClipBadges } from "./ClipBadges"; @@ -54,29 +54,53 @@ function mountPreview(html: string): Document { } describe("ClipBadges", () => { - it("renders nothing for an untouched clip", () => { + it("shows a grey fx badge and no volume on an untouched clip", () => { render(talk); - expect(document.querySelector("[data-testid='clip-badges']")).toBeNull(); + expect(labels()).toEqual(["fx:fx"]); + const fx = document.querySelector("[data-badge='fx']"); + expect(fx?.getAttribute("data-fx-active")).toBe("false"); + expect(fx?.getAttribute("title")).toBe("No effects"); }); it("falls back to the store's volume without a preview", () => { render({ ...talk, volume: 1.8 }); - expect(labels()).toEqual(["volume:180%"]); + expect(labels()).toEqual(["volume:180%", "fx:fx"]); }); - it("reads the live node and follows an agent's attribute edit", async () => { + it("lights the fx badge from the live node, lists the effects, and shows no link badge", async () => { const doc = mountPreview( ``, ); render(talk); - expect(labels()).toEqual(["link:🔗", "crop:Crop"]); + const fx = () => document.querySelector("[data-badge='fx']"); + expect(labels()).toEqual(["fx:fx"]); + expect(fx()?.getAttribute("data-fx-active")).toBe("true"); + expect(fx()?.getAttribute("title")).toBe("Crop"); const node = doc.getElementById("talk"); await act(async () => { node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}'); node?.setAttribute("data-volume", "0.6"); await new Promise((resolve) => setTimeout(resolve, 0)); }); - expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]); - expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%"); + expect(labels()).toEqual(["volume:60%", "fx:fx"]); + expect(fx()?.getAttribute("title")).toBe("Look: Warm daylight · Crop"); + }); + + it("opens the clip menu from the fx badge without reaching the clip", () => { + const host = document.createElement("div"); + document.body.append(host); + const onOpenMenu = vi.fn(); + const onClipClick = vi.fn(); + root = createRoot(host); + act(() => + root?.render( +
+ +
, + ), + ); + act(() => document.querySelector("[data-badge='fx']")?.click()); + expect(onOpenMenu).toHaveBeenCalledTimes(1); + expect(onClipClick).not.toHaveBeenCalled(); }); }); diff --git a/packages/studio/src/player/components/ClipBadges.tsx b/packages/studio/src/player/components/ClipBadges.tsx index 9792ae8039..3e3c514b9a 100644 --- a/packages/studio/src/player/components/ClipBadges.tsx +++ b/packages/studio/src/player/components/ClipBadges.tsx @@ -1,6 +1,6 @@ -import { memo } from "react"; +import { memo, type MouseEvent } from "react"; import type { TimelineElement } from "../store/playerStore"; -import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs"; +import { clipVolumeBadge, readClipEffects } from "./clipToolAttrs"; import { useClipToolState } from "./useClipToolState"; function SpeakerGlyph({ muted }: { muted: boolean }) { @@ -21,47 +21,53 @@ function SpeakerGlyph({ muted }: { muted: boolean }) { ); } -function BadgeContent({ badge }: { badge: ClipBadge }) { - if (badge.kind === "link") return ; - if (badge.kind === "volume") { - const muted = badge.label === "Muted"; - return ( - <> - - {muted ? null : {badge.label}} - - ); - } - return {badge.label}; -} - const BADGE_CLASS = - "inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap"; + "timeline-clip__badge inline-flex items-center gap-0.5 rounded-[3px] border bg-black/55 px-1 text-[9px] leading-[14px] whitespace-nowrap"; +const FX_ON = "border-white/40 text-white font-semibold"; +const FX_OFF = "border-white/10 text-white/35"; -/** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */ -export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) { +/** Premiere's fx badge — grey with no effects, white with any; hover lists them, click opens the clip menu. */ +export const ClipBadges = memo(function ClipBadges({ + el, + onOpenMenu, +}: { + el: TimelineElement; + onOpenMenu?: (event: MouseEvent) => void; +}) { const state = useClipToolState(el); - const { visible, hidden } = splitVisibleBadges(readClipBadges(state)); - if (visible.length === 0) return null; + const effects = readClipEffects(state); + const volume = clipVolumeBadge(state); + const hasEffects = effects.length > 0; + const openMenu = (event: MouseEvent) => { + event.stopPropagation(); + onOpenMenu?.(event); + }; return ( - {visible.map((badge) => ( - - - - ))} - {hidden.length > 0 && ( + {volume && ( badge.label).join(", ")} - data-badge="more" + className={`${BADGE_CLASS} border-white/20 text-white/90`} + title={volume} + data-badge="volume" > - +{hidden.length} + + {volume === "Muted" ? null : {volume}} )} + event.stopPropagation()} + onClick={openMenu} + onContextMenu={openMenu} + > + fx + ); }); diff --git a/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx b/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx index dd3f660b49..c0ac08d96e 100644 --- a/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx @@ -84,7 +84,7 @@ describe("ClipContextMenu order", () => { const expected = [ "Split at 2.00s", "Freeze frame", - "Normalize loudness", + "Audio Gain…", "Voice", "Detach audio", "Unlink from audio", diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 438b09def5..37bddac8fa 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -175,7 +175,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
- + >(); - -function loadPeakMap(url: string): Promise { - const pending = peakMapRequests.get(url); - if (pending) return pending; - const request = fetch(url) - .then((res) => (res.ok ? res.json() : null)) - .then((body: unknown) => (isPeakMap(body) ? body : null)) - .catch(() => null); - peakMapRequests.set(url, request); - return request; -} +import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns"; +import { loadPeakMap } from "./clipPeakMap"; const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`; diff --git a/packages/studio/src/player/components/TimelineClip.test.tsx b/packages/studio/src/player/components/TimelineClip.test.tsx index 3c4786be67..8a9ebe7f8c 100644 --- a/packages/studio/src/player/components/TimelineClip.test.tsx +++ b/packages/studio/src/player/components/TimelineClip.test.tsx @@ -133,6 +133,47 @@ describe("TimelineClip", () => { act(() => root.unmount()); }); + it("names a speed-changed clip like Premiere: [150%] for a constant rate, [ramp] for a lane", () => { + const fast = renderClip({ + element: { + id: "a", + label: "Hero", + tag: "video", + start: 0, + duration: 2, + track: 0, + playbackRate: 1.5, + }, + }); + expect(fast.host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero [150%]"); + act(() => fast.root.unmount()); + const automation = JSON.stringify({ + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 0.5 }, + { t: 1, v: 1 }, + ], + }, + ], + }); + const ramp = renderClip({ + element: { + id: "b", + label: "Hero", + tag: "video", + start: 0, + duration: 2, + track: 0, + automation, + }, + }); + expect(ramp.host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero [ramp]"); + act(() => ramp.root.unmount()); + }); + it("drops the label chip under 60px even when the clip is selected", () => { const { host, root } = renderClip({ element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 }, diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 2b63755c05..c5d959c883 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -14,6 +14,7 @@ import { rendersWaveform } from "./AudioWaveform"; import { ClipBadges } from "./ClipBadges"; import { linkLabelColor } from "./linkLabelColor"; import { OutOfSyncBadge } from "./OutOfSyncBadge"; +import { clipSpeedSuffix } from "./clipToolAttrs"; interface TimelineClipProps { el: TimelineElement; @@ -68,7 +69,7 @@ export const TimelineClip = memo(function TimelineClip({ const leftPx = el.start * pps; const widthPx = Math.max(el.duration * pps, 4); const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging }); - const displayLabel = el.label || el.id || el.tag; + const displayLabel = `${el.label || el.id || el.tag}${clipSpeedSuffix(el.playbackRate, el.automation)}`; const ladder = clipWidthLadder(widthPx); const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected); const showLabel = ladder === "labeled"; @@ -210,7 +211,7 @@ export const TimelineClip = memo(function TimelineClip({
)} {showLabel && {displayLabel}} - {showLabel && !isGestureActor && } + {showLabel && !isGestureActor && } {!isGestureActor && el.syncOrigin && } {showDefaultText && ( diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 6b374ea93f..8a2e6834f9 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -83,8 +83,14 @@ interface RenderLanesOptions { hoveredClip?: string | null; renderClipContent?: React.ComponentProps["renderClipContent"]; snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null; + withoutSelectHandler?: boolean; } +const selectHandlerFor = ( + options: RenderLanesOptions, + handler: (element: TimelineElement | null) => void, +) => (options.withoutSelectHandler ? undefined : handler); + function renderLanes(options: RenderLanesOptions = {}): { host: HTMLDivElement; root: Root; @@ -163,7 +169,6 @@ function renderLanes(options: RenderLanesOptions = {}): { setResizingClip={vi.fn()} setDraggedClip={vi.fn()} setSelectedElementId={setSelectedElementId} - shiftClickClipRef={createRef()} getPreviewElement={(el) => el} getTrackStyle={getTrackStyle} gsapAnimations={gsapAnimations} @@ -174,7 +179,7 @@ function renderLanes(options: RenderLanesOptions = {}): { onTogglePropertyGroupKeyframe={vi.fn()} onResizeElement={vi.fn()} onMoveElement={vi.fn()} - onSelectElement={onSelectElement} + onSelectElement={selectHandlerFor(next, onSelectElement)} onRazorSplit={vi.fn()} onRazorSplitAll={vi.fn()} />, @@ -439,6 +444,44 @@ describe("TimelineLanes selection", () => { expect(view.onSelectElement).toHaveBeenCalledWith(selected); act(() => view.root.unmount()); }); + + it.each([["shiftKey"], ["metaKey"], ["ctrlKey"]])( + "%s-click adds a clip to the selection instead of replacing it", + (modifier) => { + const first = element("clip-a", TRACK_A); + const second = element("clip-b", TRACK_B); + usePlayerStore.getState().setElements([first, second]); + usePlayerStore.getState().setSelection([first.id], first.id); + const view = renderLanes({ elements: [first, second] }); + const clipB = view.host.querySelector('[data-el-id="clip-b"]'); + const click = (init: MouseEventInit) => + act(() => clipB?.dispatchEvent(new MouseEvent("click", { bubbles: true, ...init }))); + + click({ [modifier]: true }); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"])); + expect(view.setSelectedElementId).not.toHaveBeenCalled(); + expect(view.onSelectElement).toHaveBeenLastCalledWith(second); + + click({ [modifier]: true }); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a"])); + expect(view.onSelectElement).toHaveBeenLastCalledWith(first); + act(() => view.root.unmount()); + }, + ); + it("Cmd-click adds a clip in a host that passes no onSelectElement", () => { + const first = element("clip-a", TRACK_A); + const second = element("clip-b", TRACK_B); + usePlayerStore.getState().setElements([first, second]); + usePlayerStore.getState().setSelection([first.id], first.id); + const view = renderLanes({ elements: [first, second], withoutSelectHandler: true }); + act(() => + view.host + .querySelector('[data-el-id="clip-b"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true })), + ); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"])); + act(() => view.root.unmount()); + }); }); describe("TimelineLanes clip thumbnails", () => { diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 6ccedb5856..5652419c09 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -77,7 +77,6 @@ export function TimelineLanes({ setResizingClip, setDraggedClip, setSelectedElementId, - shiftClickClipRef, getPreviewElement, getTrackStyle, keyframeCache, @@ -404,7 +403,6 @@ export function TimelineLanes({ onRazorSplit, onRazorSplitAll, blockedClipRef, - shiftClickClipRef, suppressClickRef, scrollRef, setShowPopover, diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx index 561b1b6522..97fa10d70c 100644 --- a/packages/studio/src/player/components/TimelineOverlays.tsx +++ b/packages/studio/src/player/components/TimelineOverlays.tsx @@ -1,3 +1,4 @@ +import { TimelineAudioGainOverlay } from "./AudioGainDialog"; import { useEffect, useMemo } from "react"; import type { TimelineElement } from "../store/playerStore"; import { EditPopover } from "./EditModal"; @@ -222,6 +223,7 @@ export function TimelineOverlays() { + ); } diff --git a/packages/studio/src/player/components/audioClipLink.test.ts b/packages/studio/src/player/components/audioClipLink.test.ts index c4e81bb248..3a023cec66 100644 --- a/packages/studio/src/player/components/audioClipLink.test.ts +++ b/packages/studio/src/player/components/audioClipLink.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { audioPillFlags, expandToLinkedMembers, + dropMisalignedTrimPartners, linkedGestureKeys, linkedMembersOf, mediaAssetIdentity, @@ -204,6 +205,53 @@ describe("selectClipWithLinks", () => { }); }); +describe("toggleClipWithLinks", () => { + const clips = [ + { id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" }, + { id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" }, + { id: "title", tag: "div", start: 0, duration: 4, track: 2 }, + { id: "music", tag: "audio", start: 0, duration: 4, track: 3 }, + ]; + + async function store() { + const { usePlayerStore } = await import("../store/playerStore"); + const { toggleClipWithLinks } = await import("./timelineLinkSelection"); + usePlayerStore.getState().setElements(clips); + usePlayerStore.getState().setSelection(["title"], "title"); + return { usePlayerStore, toggle: toggleClipWithLinks }; + } + + it("adds an unlinked clip to the selection and makes it primary", async () => { + const { usePlayerStore, toggle } = await store(); + expect(toggle("music", false)?.id).toBe("music"); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "music"])); + expect(usePlayerStore.getState().selectedElementId).toBe("music"); + }); + + it("adding a linked clip adds its partner too", async () => { + const { usePlayerStore, toggle } = await store(); + toggle("talk-audio", false); + expect(usePlayerStore.getState().selectedElementIds).toEqual( + new Set(["title", "talk", "talk-audio"]), + ); + }); + + it("toggling a selected clip removes it and its partner, keeping the rest", async () => { + const { usePlayerStore, toggle } = await store(); + toggle("talk", false); + expect(toggle("talk", false)?.id).toBe("title"); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title"])); + expect(toggle("title", false)).toBeNull(); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set()); + }); + + it("Alt adds only the clicked member of a linked pair", async () => { + const { usePlayerStore, toggle } = await store(); + toggle("talk", true); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["title", "talk"])); + }); +}); + describe("Linked Selection off", () => { const linkedPair = [ { id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" }, @@ -229,3 +277,15 @@ describe("Linked Selection off", () => { } }); }); + +describe("dropMisalignedTrimPartners", () => { + const v = { id: "v", link: "lk", start: 2, duration: 6 }; + const a = { id: "a", link: "lk", start: 3, duration: 5 }; + const keys = new Set(["v", "a"]); + it("keeps a partner whose grabbed edge is at the same time", () => { + expect(dropMisalignedTrimPartners(keys, v, [v, a], "end")).toEqual(keys); + }); + it("trims only the grabbed clip when the partner's edge is elsewhere", () => { + expect(dropMisalignedTrimPartners(keys, v, [v, a], "start")).toEqual(new Set(["v"])); + }); +}); diff --git a/packages/studio/src/player/components/audioClipLink.ts b/packages/studio/src/player/components/audioClipLink.ts index 355659d0b6..b5e8e322cf 100644 --- a/packages/studio/src/player/components/audioClipLink.ts +++ b/packages/studio/src/player/components/audioClipLink.ts @@ -1,4 +1,5 @@ import { sameCompositionScope, type TimelineElement } from "../store/timelineElement"; +import { syncPartnerOf } from "./clipSync"; type LinkScoped = Pick; type LinkedElement = Pick & LinkScoped; @@ -78,3 +79,79 @@ export function linkedGestureKeys( const base = selected.has(grabbedKey) ? selected : [grabbedKey]; return expandToLinkedMembers(base, elements, linked); } + +type TimedLinked = LinkedElement & Pick; + +const edgeTime = (el: Pick, edge: "start" | "end") => + edge === "start" ? el.start : el.start + el.duration; + +export function dropMisalignedTrimPartners( + keys: ReadonlySet, + grabbed: TimedLinked, + elements: readonly TimedLinked[], + edge: "start" | "end", +): Set { + const kept = new Set(keys); + if (!isLinked(grabbed)) return kept; + for (const el of elements) { + if (!sharesLinkGroup(el, grabbed) || keyOf(el) === keyOf(grabbed)) continue; + if (Math.abs(edgeTime(el, edge) - edgeTime(grabbed, edge)) > 1e-3) kept.delete(keyOf(el)); + } + return kept; +} + +type BoundedElement = Pick< + TimelineElement, + | "id" + | "key" + | "tag" + | "start" + | "duration" + | "link" + | "syncOrigin" + | "playbackStart" + | "playbackRate" + | "sourceFile" +>; + +const tagOf = (el: Pick) => el.tag.trim().toLowerCase(); + +function partnerVideoBounds( + audio: BoundedElement, + elements: readonly BoundedElement[], +): { videoKey: string; start: number; end: number } | null { + if (tagOf(audio) !== "audio") return null; + const linkedVideo = isLinked(audio) + ? elements.find((el) => sharesLinkGroup(el, audio) && tagOf(el) === "video") + : undefined; + const video = linkedVideo ?? syncPartnerOf(audio, elements); + return video + ? { videoKey: keyOf(video), start: video.start, end: video.start + video.duration } + : null; +} + +export function heldAudioShiftRange( + movers: readonly BoundedElement[], + elements: readonly BoundedElement[], + moving: ReadonlySet, +): { min: number; max: number } { + let min = Number.NEGATIVE_INFINITY; + let max = Number.POSITIVE_INFINITY; + for (const mover of movers) { + const bounds = heldPartnerVideoBounds(mover, elements, moving); + if (!bounds) continue; + const low = bounds.start - mover.start; + min = Math.max(min, low); + max = Math.min(max, Math.max(low, bounds.end - mover.duration - mover.start)); + } + return { min, max }; +} + +export function heldPartnerVideoBounds( + audio: BoundedElement, + elements: readonly BoundedElement[], + gestureKeys: ReadonlySet, +): { start: number; end: number } | null { + const bounds = partnerVideoBounds(audio, elements); + return bounds && !gestureKeys.has(bounds.videoKey) ? bounds : null; +} diff --git a/packages/studio/src/player/components/audioGainApply.test.ts b/packages/studio/src/player/components/audioGainApply.test.ts new file mode 100644 index 0000000000..5bdb60bd5b --- /dev/null +++ b/packages/studio/src/player/components/audioGainApply.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/timelineElement"; +import { normalizeClipsLoudness, writeClipGains } from "./audioGainApply"; + +const clip = (id: string, extra: Partial = {}): TimelineElement => ({ + id, + domId: id, + tag: "audio", + src: `${id}.mp3`, + start: 0, + duration: 4, + track: 0, + ...extra, +}); + +const volumeLane = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.25 }] }], +}); +const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null }; + +afterEach(() => vi.unstubAllGlobals()); + +describe("normalizeClipsLoudness", () => { + it("refuses a clip whose volume lane owns its gain, before measuring or writing", async () => { + const fetchSpy = vi.fn(async () => Response.json({ plan })); + vi.stubGlobal("fetch", fetchSpy); + const onSetElementAttributeQuiet = vi.fn(async () => ({ status: "saved" as const })); + await expect( + normalizeClipsLoudness("p1", [clip("vo", { automation: volumeLane })], { + onSetElementAttributeQuiet, + }), + ).rejects.toThrow(/volume is automated/); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(onSetElementAttributeQuiet).not.toHaveBeenCalled(); + }); + + it("rejects instead of claiming success when the save fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ plan })), + ); + const onSetElementAttributeQuiet = vi.fn(async () => ({ + status: "failed" as const, + reason: "disk full", + })); + await expect( + normalizeClipsLoudness("p1", [clip("vo")], { onSetElementAttributeQuiet }), + ).rejects.toThrow("disk full"); + }); +}); + +describe("writeClipGains", () => { + it("stops at the first refused single save", async () => { + const onSetElementAttributeQuiet = vi.fn(async () => ({ + status: "refused" as const, + reason: "Cannot edit timeline while recording", + })); + await expect( + writeClipGains( + [ + { element: clip("a"), gain: 2 }, + { element: clip("b"), gain: 2 }, + ], + { onSetElementAttributeQuiet }, + ), + ).rejects.toThrow("Cannot edit timeline while recording"); + expect(onSetElementAttributeQuiet).toHaveBeenCalledTimes(1); + }); + + it("surfaces a failed multi-clip save", async () => { + const onSetElementsAttributeQuiet = vi.fn(async () => ({ + status: "failed" as const, + reason: "disk full", + })); + await expect( + writeClipGains( + [ + { element: clip("a"), gain: 2 }, + { element: clip("b"), gain: 2 }, + ], + { onSetElementsAttributeQuiet }, + ), + ).rejects.toThrow("disk full"); + }); +}); diff --git a/packages/studio/src/player/components/audioGainApply.ts b/packages/studio/src/player/components/audioGainApply.ts new file mode 100644 index 0000000000..081e08a746 --- /dev/null +++ b/packages/studio/src/player/components/audioGainApply.ts @@ -0,0 +1,57 @@ +import { formatAudioGain } from "@hyperframes/core/audio-gain"; +import type { TimelineElement } from "../store/timelineElement"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import { + normalizeToastText, + requestNormalizePlan, + throwUnlessSaved, + VOLUME_LANE_REFUSAL, + volumeLaneOwnsGain, +} from "./clipMenuNormalize"; + +export const AUDIO_GAIN_LABEL = "Audio Gain"; + +type VolumeWriters = Pick< + TimelineEditCallbacks, + "onSetElementAttributeQuiet" | "onSetElementsAttributeQuiet" +>; + +/** Every clip's new gain as `data-volume`, one undo step when the host can save them together. */ +export async function writeClipGains( + edits: ReadonlyArray<{ element: TimelineElement; gain: number }>, + writers: VolumeWriters, + label = AUDIO_GAIN_LABEL, +): Promise { + if (edits.some(({ element }) => volumeLaneOwnsGain(element))) { + throw new Error(VOLUME_LANE_REFUSAL); + } + const values = edits.map(({ element, gain }) => ({ element, value: formatAudioGain(gain) })); + if (values.length > 1 && writers.onSetElementsAttributeQuiet) { + throwUnlessSaved(await writers.onSetElementsAttributeQuiet(values, "data-volume", label)); + return; + } + for (const { element, value } of values) { + throwUnlessSaved( + await writers.onSetElementAttributeQuiet?.(element, "data-volume", value, label), + ); + } +} + +/** Measure each clip against −16 LUFS, write the gains, and say what happened. */ +export async function normalizeClipsLoudness( + projectId: string, + elements: readonly TimelineElement[], + writers: VolumeWriters, +): Promise { + if (elements.some(volumeLaneOwnsGain)) throw new Error(VOLUME_LANE_REFUSAL); + const plans = await Promise.all(elements.map((el) => requestNormalizePlan(projectId, el))); + await writeClipGains( + elements.map((element, index) => ({ element, gain: plans[index]?.volume ?? 1 })), + writers, + "Normalize loudness", + ); + const [only] = plans; + return plans.length === 1 && only + ? normalizeToastText(only) + : `Normalized ${plans.length} clips to −16 LUFS`; +} diff --git a/packages/studio/src/player/components/audioGainDialogStore.ts b/packages/studio/src/player/components/audioGainDialogStore.ts new file mode 100644 index 0000000000..af0bc397c2 --- /dev/null +++ b/packages/studio/src/player/components/audioGainDialogStore.ts @@ -0,0 +1,36 @@ +import { create } from "zustand"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { clipHasSound } from "./clipMenuNormalize"; + +interface AudioGainDialogState { + targetKeys: readonly string[] | null; + open: (keys: readonly string[]) => void; + close: () => void; +} + +/** Which clips the Audio Gain dialog (G) is open for. */ +export const useAudioGainDialogStore = create((set) => ({ + targetKeys: null, + open: (keys) => set({ targetKeys: keys }), + close: () => set({ targetKeys: null }), +})); + +const keyOf = (el: TimelineElement) => el.key ?? el.id; + +/** The selected clips with sound, or just `clicked` when it is outside the selection. */ +function audioGainTargetKeys(clicked?: TimelineElement): string[] { + const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState(); + const selected = new Set(selectedElementIds); + if (selectedElementId) selected.add(selectedElementId); + if (clicked && !selected.has(keyOf(clicked))) + return clipHasSound(clicked) ? [keyOf(clicked)] : []; + return elements.filter((el) => selected.has(keyOf(el)) && clipHasSound(el)).map(keyOf); +} + +/** Open Audio Gain for the selection (or the clicked clip); false when none of it has sound. */ +export function openAudioGainDialog(clicked?: TimelineElement): boolean { + const keys = audioGainTargetKeys(clicked); + if (keys.length === 0) return false; + useAudioGainDialogStore.getState().open(keys); + return true; +} diff --git a/packages/studio/src/player/components/audioGainPlan.test.ts b/packages/studio/src/player/components/audioGainPlan.test.ts new file mode 100644 index 0000000000..b55594efbc --- /dev/null +++ b/packages/studio/src/player/components/audioGainPlan.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { audioGainToDb, MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain"; +import { peakAmplitudeDb, planAudioGain, type AudioGainClip } from "./audioGainPlan"; + +const clip = (key: string, gain: number, sourcePeak: number | null): AudioGainClip => ({ + key, + gain, + sourcePeak, +}); +const db = (gain: number | undefined) => audioGainToDb(gain ?? Number.NaN); +const gainOf = (plan: ReturnType, key: string) => + plan.find((edit) => edit.key === key)?.gain; + +describe("planAudioGain", () => { + it("Set Gain to puts every clip at the same gain", () => { + const plan = planAudioGain([clip("a", 2, 0.5), clip("b", 0.5, null)], { mode: "set", db: -6 }); + expect(db(gainOf(plan, "a"))).toBeCloseTo(-6, 6); + expect(db(gainOf(plan, "b"))).toBeCloseTo(-6, 6); + }); + + it("Adjust Gain by moves each clip by the same dB from where it is", () => { + const plan = planAudioGain([clip("a", 2, null), clip("b", 0.5, null)], { + mode: "adjust", + db: 3, + }); + expect(db(gainOf(plan, "a"))).toBeCloseTo(db(2) + 3, 6); + expect(db(gainOf(plan, "b"))).toBeCloseTo(db(0.5) + 3, 6); + }); + + it("Normalize Max Peak moves all clips together until the loudest peak hits the target", () => { + const plan = planAudioGain([clip("loud", 1, 0.5), clip("quiet", 1, 0.25)], { + mode: "normalize-max", + db: -1, + }); + expect(db(0.5 * (gainOf(plan, "loud") ?? 0))).toBeCloseTo(-1, 6); + expect(db(gainOf(plan, "loud")) - db(gainOf(plan, "quiet"))).toBeCloseTo(0, 6); + }); + + it("Normalize Max Peak counts each clip's current gain", () => { + const plan = planAudioGain([clip("a", 0.5, 0.8), clip("b", 2, 0.25)], { + mode: "normalize-max", + db: -3, + }); + expect(db(0.25 * (gainOf(plan, "b") ?? 0))).toBeCloseTo(-3, 6); + }); + + it("Normalize All Peaks brings each clip's own peak to the target", () => { + const plan = planAudioGain([clip("a", 1, 0.5), clip("b", 3, 0.25)], { + mode: "normalize-all", + db: -3, + }); + expect(db(0.5 * (gainOf(plan, "a") ?? 0))).toBeCloseTo(-3, 6); + expect(db(0.25 * (gainOf(plan, "b") ?? 0))).toBeCloseTo(-3, 6); + }); + + it("leaves clips it could not measure out of a normalize", () => { + const plan = planAudioGain([clip("a", 1, 0.5), clip("b", 1, null), clip("c", 1, 0)], { + mode: "normalize-all", + db: -1, + }); + expect(plan.map((edit) => edit.key)).toEqual(["a"]); + }); + + it("clamps to the +12 dB ceiling and to silence", () => { + const loud = planAudioGain([clip("a", 1, 0.001)], { mode: "normalize-all", db: 0 }); + expect(gainOf(loud, "a")).toBe(MAX_AUDIO_GAIN); + expect(gainOf(planAudioGain([clip("a", 1, null)], { mode: "set", db: 40 }), "a")).toBe( + MAX_AUDIO_GAIN, + ); + expect(gainOf(planAudioGain([clip("a", 0, null)], { mode: "adjust", db: 6 }), "a")).toBe(0); + }); +}); + +describe("peakAmplitudeDb", () => { + it("is the loudest peak across the selection at each clip's gain", () => { + expect(peakAmplitudeDb([clip("a", 1, 0.5), clip("b", 2, 0.5)])).toBeCloseTo(0, 6); + }); + + it("is null when nothing was measured", () => { + expect(peakAmplitudeDb([clip("a", 1, null)])).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/audioGainPlan.ts b/packages/studio/src/player/components/audioGainPlan.ts new file mode 100644 index 0000000000..b0b4c898f5 --- /dev/null +++ b/packages/studio/src/player/components/audioGainPlan.ts @@ -0,0 +1,53 @@ +import { audioDbToGain, audioGainToDb, clampAudioGain } from "@hyperframes/core/audio-gain"; + +export type AudioGainMode = "set" | "adjust" | "normalize-max" | "normalize-all"; + +export interface AudioGainChoice { + mode: AudioGainMode; + db: number; +} + +/** A selected clip: its `data-volume` gain and its source's linear sample peak over the played window. */ +export interface AudioGainClip { + key: string; + gain: number; + sourcePeak: number | null; +} + +export interface AudioGainEdit { + key: string; + gain: number; +} + +function isMeasured(clip: AudioGainClip): clip is AudioGainClip & { sourcePeak: number } { + return clip.sourcePeak !== null && clip.sourcePeak > 0; +} + +const heardPeak = (clip: AudioGainClip & { sourcePeak: number }) => clip.sourcePeak * clip.gain; + +/** Premiere's Audio Gain options as new `data-volume` gains, clamped to silence..+12 dB. */ +export function planAudioGain( + clips: readonly AudioGainClip[], + choice: AudioGainChoice, +): AudioGainEdit[] { + const target = audioDbToGain(choice.db); + const edit = (clip: AudioGainClip, gain: number) => ({ + key: clip.key, + gain: clampAudioGain(gain), + }); + if (choice.mode === "set") return clips.map((clip) => edit(clip, target)); + if (choice.mode === "adjust") return clips.map((clip) => edit(clip, clip.gain * target)); + const measured = clips.filter(isMeasured); + if (choice.mode === "normalize-all") { + return measured.map((clip) => edit(clip, target / clip.sourcePeak)); + } + const loudest = Math.max(0, ...measured.map(heardPeak)); + if (loudest <= 0) return []; + return measured.map((clip) => edit(clip, (clip.gain * target) / loudest)); +} + +/** The loudest peak the selection plays at its current gains, in dBFS. */ +export function peakAmplitudeDb(clips: readonly AudioGainClip[]): number | null { + const loudest = Math.max(0, ...clips.filter(isMeasured).map(heardPeak)); + return loudest > 0 ? audioGainToDb(loudest) : null; +} diff --git a/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx b/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx index a91d2e4768..63665b7ed2 100644 --- a/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx +++ b/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createHappyDomRootHarness } from "./testRootHarness"; import { ClipMenuAudioItems } from "./clipMenuAudioItems"; +import { AudioGainDialog } from "./AudioGainDialog"; import { usePlayerStore } from "../store/playerStore"; import { usePreviewIframeStore } from "../store/previewIframeStore"; import type { TimelineElement } from "../store/timelineElement"; @@ -38,7 +39,7 @@ function mountPreview(): void { usePreviewIframeStore.getState().setIframe(iframe); } -function render(part: "normalize" | "duck") { +function render(part: "gain" | "duck") { const host = document.createElement("div"); document.body.appendChild(host); act(() => @@ -55,23 +56,30 @@ afterEach(() => { }); describe("ClipMenuAudioItems in a host without Studio's shell", () => { - it("offers Normalize and Duck from the timeline session and the live preview", () => { + it("offers Audio Gain and Duck from the timeline session and the live preview", () => { usePlayerStore.getState().beginTimelineSession("p1"); mountPreview(); - expect(render("normalize").textContent).toContain("Normalize loudness"); + expect(render("gain").textContent).toContain("Audio Gain…"); expect(render("duck").textContent).toContain("Duck under voice"); }); - it("normalizes through the session's project and reports through onNotice", async () => { + it("the dialog's loudness row normalizes through the session's project and onNotice", async () => { usePlayerStore.getState().beginTimelineSession("p1"); - mountPreview(); const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null }; const fetchSpy = vi.fn(async (_url: string) => Response.json({ plan })); vi.stubGlobal("fetch", fetchSpy); - const button = render("normalize").querySelector("button"); - await act(async () => button?.click()); + const host = document.createElement("div"); + document.body.appendChild(host); + act(() => harness.mount(host).render( {}} />)); + const loudness = [...document.querySelectorAll("label")].find((l) => + l.textContent?.includes("−16 LUFS"), + ); + act(() => loudness?.querySelector("input")?.click()); + const ok = [...document.querySelectorAll("button")].find((b) => b.textContent === "OK"); + await act(async () => ok?.click()); await vi.waitFor(() => expect(onNotice).toHaveBeenCalled()); - expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/projects/p1/loudness/normalize"); + const urls = fetchSpy.mock.calls.map((call) => String(call[0])); + expect(urls).toContain("/api/projects/p1/loudness/normalize"); expect(setQuiet).toHaveBeenCalledWith(tour, "data-volume", "2", "Normalize loudness"); expect(onNotice).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info"); }); diff --git a/packages/studio/src/player/components/clipMenuAudioItems.test.tsx b/packages/studio/src/player/components/clipMenuAudioItems.test.tsx index a73119a647..50dccc2780 100644 --- a/packages/studio/src/player/components/clipMenuAudioItems.test.tsx +++ b/packages/studio/src/player/components/clipMenuAudioItems.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createHappyDomRootHarness } from "./testRootHarness"; import { ClipMenuAudioItems } from "./clipMenuAudioItems"; import type { TimelineElement } from "../store/timelineElement"; +import { useAudioGainDialogStore } from "./audioGainDialogStore"; const showToast = vi.fn(); const setQuiet = vi.fn<(...args: unknown[]) => Promise>(async () => {}); @@ -27,7 +28,7 @@ vi.mock("../../contexts/TimelineEditContext", () => ({ const harness = createHappyDomRootHarness(); -function render(element: TimelineElement, part: "normalize" | "duck" = "normalize") { +function render(element: TimelineElement, part: "gain" | "duck" = "gain") { const host = document.createElement("div"); document.body.appendChild(host); act(() => @@ -39,7 +40,6 @@ function render(element: TimelineElement, part: "normalize" | "duck" = "normaliz } const base = { start: 0, duration: 4, track: 0 }; -const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null }; const clickItem = async (host: HTMLElement, label: string) => { const button = [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(label)); await act(async () => button?.click()); @@ -95,12 +95,8 @@ describe("ClipMenuAudioItems", () => { }); }); - it("normalizes a video with sound by writing data-volume as one edit", async () => { - const plan = { targetLufs: -16, projectedLufs: -16, volume: 2, changeDb: 6, limitedBy: null }; - vi.stubGlobal( - "fetch", - vi.fn(async () => Response.json({ plan })), - ); + it("Audio Gain… opens the G dialog for the clicked clip", async () => { + useAudioGainDialogStore.getState().close(); const element: TimelineElement = { ...base, id: "a-roll", @@ -110,42 +106,12 @@ describe("ClipMenuAudioItems", () => { }; const host = render(element); const button = [...host.querySelectorAll("button")].find((b) => - b.textContent?.includes("Normalize loudness"), + b.textContent?.includes("Audio Gain…"), ); + expect(button?.textContent).toBe("Audio Gain…G"); await act(async () => button?.click()); - await vi.waitFor(() => expect(setQuiet).toHaveBeenCalled()); - expect(setQuiet).toHaveBeenCalledWith(element, "data-volume", "2", "Normalize loudness"); - expect(showToast).toHaveBeenCalledWith("Normalized to −16 LUFS (+6.0 dB)", "info"); - vi.unstubAllGlobals(); - }); - - it("refuses to normalize a clip whose volume lane owns its gain", async () => { - const fetchSpy = vi.fn(async () => Response.json({ plan })); - vi.stubGlobal("fetch", fetchSpy); - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 0.25 }] }], - }); - const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3", automation }); - await clickItem(host, "Normalize loudness"); - await vi.waitFor(() => expect(showToast).toHaveBeenCalled()); - expect(showToast).toHaveBeenCalledWith(expect.stringContaining("volume is automated"), "error"); - expect(setQuiet).not.toHaveBeenCalled(); - expect(fetchSpy).not.toHaveBeenCalled(); - vi.unstubAllGlobals(); - }); - - it("reports a failed save instead of claiming the clip was normalized", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => Response.json({ plan })), - ); - setQuiet.mockResolvedValue({ status: "failed", reason: "disk full" }); - const host = render({ ...base, id: "vo", tag: "audio", src: "vo.mp3" }); - await clickItem(host, "Normalize loudness"); - await vi.waitFor(() => expect(showToast).toHaveBeenCalledWith("disk full", "error")); - expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Normalized"), "info"); - vi.unstubAllGlobals(); + expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["a-roll"]); + expect(host.textContent).not.toContain("Normalize loudness"); }); it("stops ducking at the first refused save and says why", async () => { diff --git a/packages/studio/src/player/components/clipMenuAudioItems.tsx b/packages/studio/src/player/components/clipMenuAudioItems.tsx index 5488d84301..a543593416 100644 --- a/packages/studio/src/player/components/clipMenuAudioItems.tsx +++ b/packages/studio/src/player/components/clipMenuAudioItems.tsx @@ -1,18 +1,10 @@ -import { formatAudioGain } from "@hyperframes/core/audio-gain"; import type { TimelineElement } from "../store/timelineElement"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useStudioShellContextOptional } from "../../contexts/StudioContext"; import { usePlayerStore } from "../store/playerStore"; import { useLivePreviewIframe } from "../store/previewIframeStore"; -import { - clipHasSound, - normalizeToastText, - requestNormalizePlan, - throwUnlessSaved, - TimelineSaveError, - VOLUME_LANE_REFUSAL, - volumeLaneOwnsGain, -} from "./clipMenuNormalize"; +import { clipHasSound, throwUnlessSaved, TimelineSaveError } from "./clipMenuNormalize"; +import { openAudioGainDialog } from "./audioGainDialogStore"; import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck"; const ITEM_CLASS = @@ -25,13 +17,13 @@ const DUCK_TOAST: Record = { aborted: "Could not group the voices to duck under.", }; -/** Normalize loudness (one-shot) or Duck under voice (toggle), placed separately in the sound group. */ +/** Audio Gain… (opens the G dialog) or Duck under voice (toggle), placed separately in the sound group. */ export function ClipMenuAudioItems({ part, element, onClose, }: { - part: "normalize" | "duck"; + part: "gain" | "duck"; element: TimelineElement; onClose: () => void; }) { @@ -48,28 +40,6 @@ export function ClipMenuAudioItems({ return null; } - const normalize = async () => { - onClose(); - if (volumeLaneOwnsGain(element)) { - showToast(VOLUME_LANE_REFUSAL, "error"); - return; - } - try { - const plan = await requestNormalizePlan(projectId, element); - throwUnlessSaved( - await onSetElementAttributeQuiet( - element, - "data-volume", - formatAudioGain(plan.volume), - "Normalize loudness", - ), - ); - showToast(normalizeToastText(plan), "info"); - } catch (error) { - showToast(error instanceof Error ? error.message : String(error), "error"); - } - }; - const toggleDuck = async () => { onClose(); if (!doc || !bed) return; @@ -93,10 +63,19 @@ export function ClipMenuAudioItems({ showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info"); }; - if (part === "normalize") { + if (part === "gain") { return ( - ); } diff --git a/packages/studio/src/player/components/clipMenuLinkItems.test.ts b/packages/studio/src/player/components/clipMenuLinkItems.test.ts index 2c94db56b1..1a4c9e30ea 100644 --- a/packages/studio/src/player/components/clipMenuLinkItems.test.ts +++ b/packages/studio/src/player/components/clipMenuLinkItems.test.ts @@ -48,6 +48,51 @@ describe("resolveLinkMenuItems", () => { expect(labels(audio, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to video"); }); + it("offers Link to the lone same-file partner on another track when one clip is right-clicked", () => { + const video = clip("talk", "video", { track: 0 }); + const audio = clip("talk-audio", "audio", { track: 1, start: 2 }); + expect(labels(audio, [video, audio], ["talk-audio"])[0]).toBe("Link to video"); + expect(labels(video, [video, audio])[0]).toBe("Link to audio"); + }); + + it("dispatches the convenience link with the clip and its partner", () => { + const onLinkEdit = vi.fn(); + const video = clip("talk", "video", { track: 0 }); + const audio = clip("talk-audio", "audio", { track: 1 }); + const [link] = resolveLinkMenuItems({ + element: audio, + elements: [video, audio], + selectedKeys: new Set(["talk-audio"]), + onLinkEdit, + }); + link?.run(); + expect(onLinkEdit).toHaveBeenCalledWith({ kind: "link", elements: [audio, video] }); + }); + + it("offers no convenience Link when the partner is ambiguous, linked, another file or the same track", () => { + const audio = clip("talk-audio", "audio", { track: 1 }); + const video = clip("talk", "video", { track: 0 }); + const twin = clip("talk-2", "video", { track: 2 }); + expect(labels(audio, [video, twin, audio], ["talk-audio"])).not.toContain("Link to video"); + const linked = clip("talk", "video", { track: 0, link: "lk-9" }); + expect(labels(audio, [linked, audio], ["talk-audio"])).not.toContain("Link to video"); + const other = clip("other", "video", { track: 0, src: "other.mp4" }); + expect(labels(audio, [other, audio], ["talk-audio"])).not.toContain("Link to video"); + const sameTrack = clip("talk", "video", { track: 1 }); + expect(labels(audio, [sameTrack, audio], ["talk-audio"])).not.toContain("Link to video"); + }); + + it("offers no convenience Link while other clips are also selected", () => { + const audio = clip("talk-audio", "audio", { track: 1 }); + const video = clip("talk", "video", { track: 0 }); + const title = clip("title", "div", { track: 2 }); + expect( + labels(audio, [video, audio, title], ["talk-audio", "title"]).some((l) => + l.startsWith("Link"), + ), + ).toBe(false); + }); + it("names the audio partner when unlinking from the video side", () => { const video = clip("talk", "video", { muted: true, link: "lk-1" }); const audio = clip("talk-audio", "audio", { link: "lk-1" }); @@ -87,4 +132,43 @@ describe("resolveLinkMenuItems", () => { })[0]?.run(); expect(onLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk }); }); + + it("offers Link for a trimmed audio and its video (timing does not matter)", () => { + const video = clip("talk", "video", { muted: true }); + const audio = clip("talk-audio", "audio", { start: 1, duration: 2, playbackStart: 1 }); + expect(labels(video, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to audio"); + }); + + it("offers Link for a pair of different files", () => { + const video = clip("talk", "video", { muted: true }); + const music = clip("bgm", "audio", { src: "bgm.mp3" }); + expect(labels(music, [video, music], ["talk", "bgm"])).toEqual(["Link to video"]); + }); + + it("offers no Link when one selected clip is already linked elsewhere", () => { + const video = clip("talk", "video", { muted: true, link: "lk-9" }); + const audio = clip("bgm", "audio", { src: "bgm.mp3" }); + const partner = clip("talk-audio", "audio", { link: "lk-9" }); + expect(labels(audio, [video, audio, partner], ["talk", "bgm"])).toEqual([]); + }); + + it("disables Merge back with a tooltip while the pair is out of sync", () => { + const video = clip("talk", "video", { muted: true, link: "lk-1" }); + const audio = clip("talk-audio", "audio", { link: "lk-1", start: 0.5 }); + const merge = resolveLinkMenuItems({ + element: audio, + elements: [video, audio], + selectedKeys: new Set(), + onLinkEdit: vi.fn(), + }).find((item) => item.label === "Merge audio back into video"); + expect(merge?.disabledReason).toBe("Move into Sync first"); + const synced = clip("talk-audio", "audio", { link: "lk-1" }); + const enabled = resolveLinkMenuItems({ + element: synced, + elements: [video, synced], + selectedKeys: new Set(), + onLinkEdit: vi.fn(), + }).find((item) => item.label === "Merge audio back into video"); + expect(enabled?.disabledReason).toBeUndefined(); + }); }); diff --git a/packages/studio/src/player/components/clipMenuLinkItems.tsx b/packages/studio/src/player/components/clipMenuLinkItems.tsx index 4931b09d7e..3c46a314d8 100644 --- a/packages/studio/src/player/components/clipMenuLinkItems.tsx +++ b/packages/studio/src/player/components/clipMenuLinkItems.tsx @@ -2,12 +2,19 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import type { TimelineLinkEdit } from "./timelineCallbacks"; import { linkedMembersOf } from "./audioClipLink"; -import { canDetachAudio, canLinkPair, findMergePair } from "../../components/editor/mediaLinkEdits"; +import { + canDetachAudio, + canLinkPair, + findMergePair, + isPairInSync, + sharesSourceFile, +} from "../../components/editor/mediaLinkEdits"; interface LinkMenuItem { label: string; shortcut?: string; destructive?: boolean; + disabledReason?: string; run: () => void; } @@ -15,6 +22,22 @@ const keyOf = (el: TimelineElement) => el.key ?? el.id; const tagOf = (el: TimelineElement) => el.tag.trim().toLowerCase(); +function loneSameFilePartner( + element: TimelineElement, + elements: readonly TimelineElement[], +): TimelineElement | null { + const opposite = { video: "audio", audio: "video" }[tagOf(element)]; + if (!opposite || element.link) return null; + const candidates = elements.filter( + (el) => + !el.link && + tagOf(el) === opposite && + el.track !== element.track && + sharesSourceFile([element, el]), + ); + return candidates.length === 1 ? (candidates[0] ?? null) : null; +} + function partnerSuffix(element: TimelineElement, others: readonly TimelineElement[]): string { const partners = others.filter((el) => keyOf(el) !== keyOf(element)); const [partner] = partners; @@ -23,6 +46,26 @@ function partnerSuffix(element: TimelineElement, others: readonly TimelineElemen return pair === "audio+video" ? ` ${tagOf(partner)}` : ""; } +function linkItem( + element: TimelineElement, + elements: readonly TimelineElement[], + selected: TimelineElement[], + selectedKeys: ReadonlySet, + onLinkEdit: (edit: TimelineLinkEdit) => unknown, +): LinkMenuItem | null { + const selectedPair = canLinkPair(selected) && selectedKeys.has(keyOf(element)); + const soleSelection = selected.every((el) => keyOf(el) === keyOf(element)); + const partner = !selectedPair && soleSelection ? loneSameFilePartner(element, elements) : null; + const linkPair = selectedPair ? selected : partner ? [element, partner] : null; + if (!linkPair) return null; + const suffix = partnerSuffix(element, linkPair); + return { + label: `Link${suffix && ` to${suffix}`}`, + ...(selectedPair ? { shortcut: "⌘L" } : {}), + run: () => onLinkEdit({ kind: "link", elements: linkPair }), + }; +} + /** The link-model items for a clip, in wireframe order (detach · unlink/link · merge · delete-one). */ export function resolveLinkMenuItems(input: { element: TimelineElement; @@ -50,17 +93,15 @@ export function resolveLinkMenuItems(input: { shortcut: "⌘L", run: () => onLinkEdit({ kind: "unlink", elements: members }), }); - } else if (canLinkPair(selected) && selectedKeys.has(keyOf(element))) { - items.push({ - label: `Link${partnerSuffix(element, selected) && ` to${partnerSuffix(element, selected)}`}`, - shortcut: "⌘L", - run: () => onLinkEdit({ kind: "link", elements: selected }), - }); + } else { + const link = linkItem(element, elements, selected, selectedKeys, onLinkEdit); + if (link) items.push(link); } const pair = findMergePair(element, elements); if (pair) { items.push({ label: "Merge audio back into video", + disabledReason: isPairInSync(pair.video, pair.audio) ? undefined : "Move into Sync first", run: () => onLinkEdit({ kind: "merge", ...pair }), }); } @@ -102,8 +143,12 @@ export function ClipMenuLinkItems({ key={item.label} type="button" role="menuitem" - className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden cursor-pointer hover:bg-neutral-800 focus-visible:bg-neutral-800 ${ - item.destructive ? "text-red-400" : "text-neutral-300" + disabled={item.disabledReason !== undefined} + title={item.disabledReason} + className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden ${ + item.disabledReason !== undefined + ? "text-neutral-600 cursor-not-allowed" + : `cursor-pointer hover:bg-neutral-800 focus-visible:bg-neutral-800 ${item.destructive ? "text-red-400" : "text-neutral-300"}` }`} onClick={() => { item.run(); diff --git a/packages/studio/src/player/components/clipMenuToolItems.test.tsx b/packages/studio/src/player/components/clipMenuToolItems.test.tsx index 72230c4b3c..b1219588a6 100644 --- a/packages/studio/src/player/components/clipMenuToolItems.test.tsx +++ b/packages/studio/src/player/components/clipMenuToolItems.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; import { useCropPresetBarStore } from "../../components/editor/cropPresetStore"; @@ -15,7 +15,10 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { let root: Root | null = null; +beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); act(() => root?.unmount()); root = null; document.body.innerHTML = ""; @@ -31,7 +34,14 @@ const video: TimelineElement = { hasAudio: true, }; -function renderItems(group: ClipMenuToolGroup, element: TimelineElement, currentTime = 2) { +function renderItems( + group: ClipMenuToolGroup, + element: TimelineElement, + currentTime = 2, + withLive = true, +) { + const setLive = vi.fn((_el: TimelineElement, _attr: string, _value: string | null) => undefined); + const revertLive = vi.fn((_el: TimelineElement, _attr: string) => undefined); const setQuiet = vi.fn( async (_el: TimelineElement, _attr: string, _value: string | null, _label: string) => undefined, ); @@ -42,7 +52,15 @@ function renderItems(group: ClipMenuToolGroup, element: TimelineElement, current root = createRoot(host); act(() => { root?.render( - + , ); }); - return { setQuiet, onClose, freeze }; + return { setQuiet, onClose, freeze, setLive, revertLive }; } function openSubmenu(label: string) { @@ -136,4 +154,112 @@ describe("ClipMenuToolItems", () => { renderItems("time", { ...video, tag: "img" }); expect(document.body.textContent).toBe(""); }); + + describe("Look hover preview", () => { + function option(text: string) { + const found = Array.from( + document.querySelectorAll('[role="menuitemradio"]'), + ).find((button) => button.textContent?.endsWith(text)); + if (!found) throw new Error(`no option ${text}`); + return found; + } + const enter = (text: string) => + option(text).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + const hover = (text: string) => { + act(() => { + enter(text); + vi.advanceTimersByTime(100); + }); + }; + const leave = () => { + const wrapper = document.querySelector('[role="menu"]')?.parentElement; + act(() => { + wrapper?.dispatchEvent( + new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }), + ); + }); + }; + + it("hover applies the look live after the debounce, without saving", () => { + const { setLive, setQuiet } = renderItems("picture", video); + openSubmenu("Look"); + act(() => { + enter("Warm daylight"); + vi.advanceTimersByTime(40); + }); + expect(setLive).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(60)); + expect(setLive).toHaveBeenCalledWith( + video, + "data-color-grading", + '{"preset":"warm-daylight","intensity":1}', + ); + expect(setQuiet).not.toHaveBeenCalled(); + }); + + it("opening the submenu by click does not preview the auto-focused first option", () => { + const { setLive } = renderItems("picture", video); + openSubmenu("Look"); + act(() => vi.advanceTimersByTime(200)); + expect(setLive).not.toHaveBeenCalled(); + }); + + it("keyboard focus previews and None previews no look", () => { + const { setLive } = renderItems("picture", video); + openSubmenu("Look"); + act(() => { + option("None").dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + vi.advanceTimersByTime(100); + }); + expect(setLive).toHaveBeenCalledWith(video, "data-color-grading", null); + }); + + it("leaving the submenu reverts to the saved look", () => { + const { revertLive } = renderItems("picture", video); + openSubmenu("Look"); + hover("Warm daylight"); + leave(); + expect(revertLive).toHaveBeenCalledWith(video, "data-color-grading"); + }); + + it("leaving before the debounce fires never previews or reverts", () => { + const { setLive, revertLive } = renderItems("picture", video); + openSubmenu("Look"); + act(() => enter("Warm daylight")); + leave(); + act(() => vi.advanceTimersByTime(200)); + expect(setLive).not.toHaveBeenCalled(); + expect(revertLive).not.toHaveBeenCalled(); + }); + + it("clicking commits once and does not revert afterwards", () => { + const { setQuiet, revertLive } = renderItems("picture", video); + openSubmenu("Look"); + hover("Warm daylight"); + act(() => option("Warm daylight").click()); + act(() => root?.unmount()); + root = null; + expect(setQuiet).toHaveBeenCalledTimes(1); + expect(revertLive).not.toHaveBeenCalled(); + }); + + it("closing the menu while previewing reverts", () => { + const { revertLive } = renderItems("picture", video); + openSubmenu("Look"); + hover("Warm daylight"); + act(() => root?.unmount()); + root = null; + expect(revertLive).toHaveBeenCalledWith(video, "data-color-grading"); + }); + + it("does nothing when the host has no live callbacks", () => { + const { setQuiet } = renderItems("picture", video, 2, false); + openSubmenu("Look"); + expect(() => { + hover("Warm daylight"); + leave(); + }).not.toThrow(); + expect(setQuiet).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/studio/src/player/components/clipMenuToolItems.tsx b/packages/studio/src/player/components/clipMenuToolItems.tsx index 4acffdca5a..0348514a66 100644 --- a/packages/studio/src/player/components/clipMenuToolItems.tsx +++ b/packages/studio/src/player/components/clipMenuToolItems.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx"; import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading"; import type { TimelineElement } from "../store/playerStore"; @@ -30,6 +30,47 @@ const ROW_CLASS = const DISABLED_ROW_CLASS = "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed"; const SUBMENU_WIDTH = 170; +const HOVER_PREVIEW_DELAY_MS = 80; + +interface HoverPreview { + apply: (id: string | null) => void; + clear: () => void; +} + +function useHoverPreview(preview: HoverPreview | undefined) { + const previewRef = useRef(preview); + previewRef.current = preview; + const timerRef = useRef | null>(null); + const activeRef = useRef(false); + + const cancelTimer = () => { + if (timerRef.current === null) return; + clearTimeout(timerRef.current); + timerRef.current = null; + }; + const end = () => { + cancelTimer(); + if (!activeRef.current) return; + activeRef.current = false; + previewRef.current?.clear(); + }; + const settle = () => { + cancelTimer(); + activeRef.current = false; + }; + const start = (id: string | null) => { + if (!previewRef.current) return; + cancelTimer(); + timerRef.current = setTimeout(() => { + timerRef.current = null; + activeRef.current = true; + previewRef.current?.apply(id); + }, HOVER_PREVIEW_DELAY_MS); + }; + + useEffect(() => end, []); + return { start, end, settle }; +} interface ChoiceSection { heading?: string; @@ -48,22 +89,36 @@ function ChoiceSubmenu({ sections, activeId, onPick, + preview, }: { label: string; sections: readonly ChoiceSection[]; activeId: string | null; onPick: (id: string | null) => void; + preview?: HoverPreview; }) { const [open, setOpen] = useState(false); const [flipLeft, setFlipLeft] = useState(false); const rowRef = useRef(null); const submenuRef = useRef(null); + const hover = useHoverPreview(preview); + const openingFocusRef = useRef(false); + + const hideSubmenu = () => { + hover.end(); + setOpen(false); + }; const show = (focusFirst: boolean) => { const rect = rowRef.current?.getBoundingClientRect(); setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false); setOpen(true); - if (focusFirst) requestAnimationFrame(() => focusSibling(submenuRef.current, 1)); + if (!focusFirst) return; + openingFocusRef.current = true; + requestAnimationFrame(() => { + focusSibling(submenuRef.current, 1); + openingFocusRef.current = false; + }); }; const onSubmenuKeyDown = (event: KeyboardEvent) => { @@ -74,7 +129,7 @@ function ChoiceSubmenu({ } else if (event.key === "ArrowLeft") { event.preventDefault(); event.stopPropagation(); - setOpen(false); + hideSubmenu(); rowRef.current?.focus(); } }; @@ -86,7 +141,15 @@ function ChoiceSubmenu({ role="menuitemradio" aria-checked={activeId === id} className={ROW_CLASS} - onClick={() => onPick(id)} + onClick={() => { + hover.settle(); + onPick(id); + }} + onMouseEnter={() => hover.start(id)} + onFocus={() => { + if (openingFocusRef.current) openingFocusRef.current = false; + else hover.start(id); + }} >