From a1a6fb6b3cdca7c2c81f2ac21f95c82ff5d4b4ad Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 10:14:40 -0700 Subject: [PATCH 01/20] feat(studio): show clip speed in the clip name as [150%] or [ramp] Co-Authored-By: Claude Opus 5.5 --- .../player/components/TimelineClip.test.tsx | 41 +++++++++++++++++++ .../src/player/components/TimelineClip.tsx | 3 +- .../player/components/clipToolAttrs.test.ts | 15 +++++++ .../src/player/components/clipToolAttrs.ts | 11 +++++ 4 files changed, 69 insertions(+), 1 deletion(-) 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..2558e269cb 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"; diff --git a/packages/studio/src/player/components/clipToolAttrs.test.ts b/packages/studio/src/player/components/clipToolAttrs.test.ts index d54d061df8..143bda89c2 100644 --- a/packages/studio/src/player/components/clipToolAttrs.test.ts +++ b/packages/studio/src/player/components/clipToolAttrs.test.ts @@ -5,6 +5,7 @@ import { activeLook, activeVoicePreset, chainWithVoicePreset, + clipSpeedSuffix, hasCrop, isDucked, lookAttrValue, @@ -159,3 +160,17 @@ describe("readClipBadges", () => { expect(hidden.map((b) => b.label)).toEqual(["60%"]); }); }); + +describe("clipSpeedSuffix", () => { + it("shows a constant speed as a percentage and hides 100%", () => { + expect(clipSpeedSuffix(1.5, null)).toBe(" [150%]"); + expect(clipSpeedSuffix(0.35, null)).toBe(" [35%]"); + expect(clipSpeedSuffix(1, null)).toBe(""); + expect(clipSpeedSuffix(undefined, null)).toBe(""); + }); + + it("shows a rate lane as a ramp, whatever the base rate", () => { + expect(clipSpeedSuffix(2, rampAutomation)).toBe(" [ramp]"); + expect(clipSpeedSuffix(1, rampAutomation)).toBe(" [ramp]"); + }); +}); diff --git a/packages/studio/src/player/components/clipToolAttrs.ts b/packages/studio/src/player/components/clipToolAttrs.ts index e7418b7073..c9e05d576b 100644 --- a/packages/studio/src/player/components/clipToolAttrs.ts +++ b/packages/studio/src/player/components/clipToolAttrs.ts @@ -117,6 +117,17 @@ function hasRateRamp(rawAutomation: string | null | undefined): boolean { return parseRateLane(rawAutomation) !== null; } +/** Premiere's speed cue on the clip name: ` [ramp]` under a rate lane, else ` [150%]` off 100%. */ +export function clipSpeedSuffix( + playbackRate: number | null | undefined, + rawAutomation: string | null | undefined, +): string { + if (hasRateRamp(rawAutomation)) return " [ramp]"; + const rate = playbackRate ?? 1; + if (!Number.isFinite(rate) || Math.abs(rate - 1) < 0.005) return ""; + return ` [${Math.round(rate * 100)}%]`; +} + const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i; export function hasCrop(clipPath: string | null | undefined): boolean { From b1fac418dbfc0b7b26781d45b6ed3e7721ba7668 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 10:35:31 -0700 Subject: [PATCH 02/20] feat(studio): one fx badge per clip replaces the per-tool and link badges Grey with no effects, white with any. The tooltip lists look, voice, other fx, crop and ducking; a click opens the clip menu. The volume badge stays when volume is off 100%. Co-Authored-By: Claude Opus 5.5 --- .../src/player/components/ClipBadges.test.tsx | 40 +++++++-- .../src/player/components/ClipBadges.tsx | 68 ++++++++------- .../src/player/components/TimelineClip.tsx | 2 +- .../player/components/clipToolAttrs.test.ts | 85 ++++++++++--------- .../src/player/components/clipToolAttrs.ts | 83 +++++++++--------- .../src/player/components/useClipToolState.ts | 5 -- 6 files changed, 151 insertions(+), 132 deletions(-) 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/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 2558e269cb..c5d959c883 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -211,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/clipToolAttrs.test.ts b/packages/studio/src/player/components/clipToolAttrs.test.ts index 143bda89c2..52a2a0da63 100644 --- a/packages/studio/src/player/components/clipToolAttrs.test.ts +++ b/packages/studio/src/player/components/clipToolAttrs.test.ts @@ -8,9 +8,9 @@ import { clipSpeedSuffix, hasCrop, isDucked, + clipVolumeBadge, lookAttrValue, - readClipBadges, - splitVisibleBadges, + readClipEffects, type ClipToolState, } from "./clipToolAttrs"; @@ -99,7 +99,6 @@ const baseState: ClipToolState = { colorGrading: null, clipPath: null, carve: null, - link: null, }; const rampAutomation = JSON.stringify({ @@ -115,49 +114,51 @@ const rampAutomation = JSON.stringify({ ], }); -describe("readClipBadges", () => { - it("shows nothing for a plain clip at 100%", () => { - expect(readClipBadges(baseState)).toEqual([]); - }); - - it("lists every applied tool in the wireframe's order", () => { - const labels = readClipBadges({ - ...baseState, - volume: 1.8, - fxChain: chainWithVoicePreset(null, "voice-clean"), - automation: rampAutomation, - colorGrading: lookAttrValue("warm-daylight"), - clipPath: "inset(0px 20px)", - carve: "{}", - link: "talk", - }).map((b) => b.label); - expect(labels).toEqual([ - "Linked", - "Warm daylight", - "Voice: Clean", - "Ramp", - "Crop", - "Ducked", - "180%", - ]); +describe("readClipEffects", () => { + it("lists nothing for a plain clip, so the fx badge stays grey", () => { + expect(readClipEffects(baseState)).toEqual([]); + expect(readClipEffects({ ...baseState, automation: rampAutomation, volume: 1.8 })).toEqual([]); + }); + + it("lists every applied effect in the tooltip's order", () => { + expect( + readClipEffects({ + ...baseState, + fxChain: chainWithVoicePreset(null, "voice-clean"), + colorGrading: lookAttrValue("warm-daylight"), + clipPath: "inset(0px 20px)", + carve: "{}", + }), + ).toEqual(["Look: Warm daylight", "Voice: Clean", "Crop", "Ducked"]); + }); + + it("names a hand-added effect and skips disabled and carve-generated nodes", () => { + const chain = serializeAudioFxChain({ + version: 1, + nodes: [ + { id: "a", type: "highpass", enabled: true, params: { frequency: 80 } }, + { id: "b", type: "gain", enabled: false, params: {} }, + { id: "c", type: "gain", enabled: true, fromCarve: true, params: {} }, + ], + }); + const effects = readClipEffects({ ...baseState, fxChain: chain }); + expect(effects).toHaveLength(1); + expect(effects[0]).not.toBe("highpass"); }); - it("badges a muted audio clip but not muted b-roll", () => { - expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted"); - expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]); + it("reads a hand-made grading without a menu preset as a Look", () => { + expect(readClipEffects({ ...baseState, colorGrading: '{"adjust":{"contrast":0.2}}' })).toEqual([ + "Look", + ]); }); +}); - it("caps at two visible badges plus the link badge", () => { - const badges = readClipBadges({ - ...baseState, - link: "talk", - colorGrading: lookAttrValue("mono-clean"), - clipPath: "inset(5px)", - volume: 0.6, - }); - const { visible, hidden } = splitVisibleBadges(badges); - expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]); - expect(hidden.map((b) => b.label)).toEqual(["60%"]); +describe("clipVolumeBadge", () => { + it("shows volume off 100% and Muted only on a clip with sound", () => { + expect(clipVolumeBadge({ ...baseState, volume: 1.8 })).toBe("180%"); + expect(clipVolumeBadge(baseState)).toBeNull(); + expect(clipVolumeBadge({ ...baseState, tag: "audio", muted: true })).toBe("Muted"); + expect(clipVolumeBadge({ ...baseState, hasSound: false, muted: true })).toBeNull(); }); }); diff --git a/packages/studio/src/player/components/clipToolAttrs.ts b/packages/studio/src/player/components/clipToolAttrs.ts index c9e05d576b..088412bbce 100644 --- a/packages/studio/src/player/components/clipToolAttrs.ts +++ b/packages/studio/src/player/components/clipToolAttrs.ts @@ -4,11 +4,13 @@ */ import { + getAudioFxDef, parseAudioFxChain, serializeAudioFxChain, type HfAudioFxChain, + type HfAudioFxNode, } from "@hyperframes/core/audio-fx"; -import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets"; +import { activeAudioFxPresetIds, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets"; import { normalizeHfColorGrading } from "@hyperframes/core/color-grading"; import { parseRateLane } from "@hyperframes/core/speed-ramp"; import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset"; @@ -135,13 +137,6 @@ export function hasCrop(clipPath: string | null | undefined): boolean { return value !== "" && value !== "none" && !ZERO_INSET.test(value); } -export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume"; - -export interface ClipBadge { - kind: ClipBadgeKind; - label: string; -} - /** What a clip's attributes say is applied; the element's own attributes, as read off its node. */ export interface ClipToolState { tag: string; @@ -153,46 +148,44 @@ export interface ClipToolState { colorGrading: string | null; clipPath: string | null; carve: string | null; - link: string | null; } -function volumeBadge(state: ClipToolState): ClipBadge | null { - const isAudio = state.tag === "audio"; - if (!isAudio && !state.hasSound) return null; - if (state.muted) return { kind: "volume", label: "Muted" }; +/** The volume badge text, or null at 100% and on clips with no sound. */ +export function clipVolumeBadge(state: ClipToolState): string | null { + if (state.tag !== "audio" && !state.hasSound) return null; + if (state.muted) return "Muted"; const volume = state.volume ?? 1; if (Math.abs(volume - 1) < 0.005) return null; - return { kind: "volume", label: `${Math.round(volume * 100)}%` }; -} - -/** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */ -export function readClipBadges(state: ClipToolState): ClipBadge[] { - const look = lookLabel(activeLook(state.colorGrading)); - const voice = voicePresetLabel(activeVoicePreset(state.fxChain)); - const volume = volumeBadge(state); - const badges: Array = [ - state.link ? { kind: "link", label: "Linked" } : null, - look ? { kind: "look", label: look } : null, - voice ? { kind: "voice", label: `Voice: ${voice}` } : null, - hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null, - hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null, - isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null, - volume, + return `${Math.round(volume * 100)}%`; +} + +function lookEffect(rawGrading: string | null): string | null { + const grading = normalizeHfColorGrading(parseJson(rawGrading)); + if (!grading) return null; + const label = lookLabel(grading.preset ?? null); + return label ? `Look: ${label}` : "Look"; +} + +function presetEffect(presetId: string): string { + const voice = voicePresetLabel(presetId); + return voice ? `Voice: ${voice}` : (getAudioFxPreset(presetId)?.label ?? presetId); +} + +function chainNodeEffect(node: HfAudioFxNode): string | null { + if (node.enabled === false || node.fromCarve) return null; + if (node.fromPreset) return presetEffect(node.fromPreset); + if (node.fromEq) return "EQ"; + if (node.fromLeveller) return "Leveller"; + return node.label ?? getAudioFxDef(node.type)?.label ?? node.type; +} + +/** Every effect on the clip, in the fx badge tooltip's order; empty means a grey badge. */ +export function readClipEffects(state: ClipToolState): string[] { + const effects: Array = [ + lookEffect(state.colorGrading), + ...parseChainOrEmpty(state.fxChain).nodes.map(chainNodeEffect), + hasCrop(state.clipPath) ? "Crop" : null, + isDucked(state.carve) ? "Ducked" : null, ]; - return badges.filter((badge): badge is ClipBadge => badge !== null); -} - -const MAX_VISIBLE_BADGES = 2; - -/** The link badge is always shown; the rest cap at two, then `+N`. */ -export function splitVisibleBadges(badges: readonly ClipBadge[]): { - visible: ClipBadge[]; - hidden: ClipBadge[]; -} { - const link = badges.filter((badge) => badge.kind === "link"); - const rest = badges.filter((badge) => badge.kind !== "link"); - return { - visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)], - hidden: rest.slice(MAX_VISIBLE_BADGES), - }; + return [...new Set(effects.filter((effect): effect is string => effect !== null))]; } diff --git a/packages/studio/src/player/components/useClipToolState.ts b/packages/studio/src/player/components/useClipToolState.ts index 364ce9585e..b99c91fcc7 100644 --- a/packages/studio/src/player/components/useClipToolState.ts +++ b/packages/studio/src/player/components/useClipToolState.ts @@ -11,14 +11,11 @@ import { committedClipPath } from "../../components/editor/cropPresetStore"; import { isAudibleVideoNode } from "../lib/timelineElementHelpers"; import type { ClipToolState } from "./clipToolAttrs"; -const LINK_ATTR = "data-link"; - const WATCHED_ATTRS = [ HF_AUDIO_FX_ATTR, HF_AUDIO_AUTOMATION_ATTR, HF_AUDIO_CARVE_ATTR, HF_COLOR_GRADING_ATTR, - LINK_ATTR, "data-volume", "data-has-audio", "muted", @@ -42,7 +39,6 @@ function toolStateFromNode(node: Element): ClipToolState { colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR), clipPath: committedClipPath(node), carve: node.getAttribute(HF_AUDIO_CARVE_ATTR), - link: node.getAttribute(LINK_ATTR), }; } @@ -58,7 +54,6 @@ function toolStateFromElement(el: TimelineElement): ClipToolState { colorGrading: null, clipPath: null, carve: null, - link: null, }; } From 89440bdadcd50d012bc9b7d8663de48ba64f80ac Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 11:03:03 -0700 Subject: [PATCH 03/20] feat(studio): premiere-style audio gain dialog on G Set Gain to, Adjust Gain by, Normalize Max Peak to, Normalize All Peaks to and Normalize loudness to -16 LUFS, with the selection's peak amplitude read from the peak map. Writes data-volume on every selected clip with sound as one undo step. The clip menu's Normalize loudness becomes Audio Gain... (G). Co-Authored-By: Claude Opus 5.5 --- .../nle/useTimelineEditCallbacks.ts | 2 + .../src/contexts/TimelineEditContext.tsx | 1 + .../studio/src/hooks/appHotkeysDispatch.ts | 9 + .../hooks/timelineElementsAttribute.test.ts | 70 ++++++ .../src/hooks/timelineElementsAttribute.ts | 98 ++++++++ .../studio/src/hooks/useAppHotkeys.test.ts | 34 +++ .../studio/src/hooks/useRenderClipContent.ts | 8 +- .../studio/src/hooks/useTimelineEditing.ts | 12 + packages/studio/src/index.ts | 4 + .../components/AudioGainDialog.test.tsx | 133 +++++++++++ .../src/player/components/AudioGainDialog.tsx | 226 ++++++++++++++++++ .../ClipContextMenu.groups.test.tsx | 2 +- .../src/player/components/ClipContextMenu.tsx | 2 +- .../src/player/components/ClipPeakMarks.tsx | 16 +- .../player/components/TimelineOverlays.tsx | 2 + .../src/player/components/audioGainApply.ts | 45 ++++ .../player/components/audioGainDialogStore.ts | 36 +++ .../player/components/audioGainPlan.test.ts | 82 +++++++ .../src/player/components/audioGainPlan.ts | 53 ++++ .../clipMenuAudioItems.noShell.test.tsx | 24 +- .../components/clipMenuAudioItems.test.tsx | 20 +- .../player/components/clipMenuAudioItems.tsx | 53 ++-- .../src/player/components/clipPeakMap.ts | 46 ++++ .../player/components/clipPeakRuns.test.ts | 15 +- .../src/player/components/clipPeakRuns.ts | 12 + .../src/player/components/studioShortcuts.ts | 2 + .../player/components/timelineCallbacks.ts | 6 + 27 files changed, 934 insertions(+), 79 deletions(-) create mode 100644 packages/studio/src/hooks/timelineElementsAttribute.test.ts create mode 100644 packages/studio/src/hooks/timelineElementsAttribute.ts create mode 100644 packages/studio/src/player/components/AudioGainDialog.test.tsx create mode 100644 packages/studio/src/player/components/AudioGainDialog.tsx create mode 100644 packages/studio/src/player/components/audioGainApply.ts create mode 100644 packages/studio/src/player/components/audioGainDialogStore.ts create mode 100644 packages/studio/src/player/components/audioGainPlan.test.ts create mode 100644 packages/studio/src/player/components/audioGainPlan.ts create mode 100644 packages/studio/src/player/components/clipPeakMap.ts 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..8dfcf0362a --- /dev/null +++ b/packages/studio/src/hooks/timelineElementsAttribute.test.ts @@ -0,0 +1,70 @@ +// @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"/); + }); +}); diff --git a/packages/studio/src/hooks/timelineElementsAttribute.ts b/packages/studio/src/hooks/timelineElementsAttribute.ts new file mode 100644 index 0000000000..7141d53647 --- /dev/null +++ b/packages/studio/src/hooks/timelineElementsAttribute.ts @@ -0,0 +1,98 @@ +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"; + +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, +}: UseTimelineElementVisibilityEditingInput) { + return useCallback( + async (edits: ElementAttributeEdits, attr: string, label: string): Promise => { + const projectId = projectIdRef.current; + if (!projectId || edits.length === 0) return; + 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); + } catch (error) { + showToast(error instanceof Error ? error.message : "Could not save the clips", "error"); + } + }, + [ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + previewIframeRef, + pendingTimelineEditPathRef, + ], + ); +} 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..e2a7fd403d 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,16 @@ export function useTimelineEditing({ isRecordingRef, }); + const setElementsAttribute = useSetElementsAttribute({ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + previewIframeRef, + pendingTimelineEditPathRef, + }); + const setAudioGroupAttribute = useSetAudioGroupAttribute({ projectIdRef, activeCompPath, @@ -532,6 +543,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/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..e4ace82162 --- /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/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/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/audioGainApply.ts b/packages/studio/src/player/components/audioGainApply.ts new file mode 100644 index 0000000000..796facca7a --- /dev/null +++ b/packages/studio/src/player/components/audioGainApply.ts @@ -0,0 +1,45 @@ +import { formatAudioGain } from "@hyperframes/core/audio-gain"; +import type { TimelineElement } from "../store/timelineElement"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import { normalizeToastText, requestNormalizePlan } 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 { + const values = edits.map(({ element, gain }) => ({ element, value: formatAudioGain(gain) })); + if (values.length > 1 && writers.onSetElementsAttributeQuiet) { + await writers.onSetElementsAttributeQuiet(values, "data-volume", label); + return; + } + for (const { element, value } of values) { + 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 { + 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..39fe3cbbda 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(() => @@ -95,12 +96,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,13 +107,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(); + expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["a-roll"]); + expect(host.textContent).not.toContain("Normalize loudness"); }); it("refuses to normalize a clip whose volume lane owns its gain", 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/clipPeakMap.ts b/packages/studio/src/player/components/clipPeakMap.ts new file mode 100644 index 0000000000..e6eeaee6bb --- /dev/null +++ b/packages/studio/src/player/components/clipPeakMap.ts @@ -0,0 +1,46 @@ +import { buildProjectApiPath } from "../../utils/projectRouting"; +import { resolvePreviewRelative } from "../../utils/previewRelativePath"; +import type { TimelineElement } from "../store/timelineElement"; +import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns"; +import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils"; + +const peakMapRequests = new Map>(); + +/** One fetch per peak-map URL per session; a failed or malformed answer reads as null. */ +export 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; +} + +/** The studio-server `/peaks/*` URL for a clip's project media, or undefined for outside media. */ +export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined { + const origin = window.location.origin; + const relative = resolvePreviewRelative( + resolveMediaPreviewUrl(src ?? "", projectId, origin), + projectId, + origin, + ); + return relative + ? buildProjectApiPath(projectId, `/peaks/${encodePreviewPath(relative)}`) + : undefined; +} + +export function clipSourceWindow(el: TimelineElement): ClipSourceWindow { + return { mediaStart: el.playbackStart ?? 0, sourceSpan: el.duration * (el.playbackRate ?? 1) }; +} + +/** The clip's source peak, linear, over the part it plays; null when it cannot be measured. */ +export async function measureClipSourcePeak( + el: TimelineElement, + projectId: string, +): Promise { + const url = clipPeaksUrl(el.src, projectId); + const map = url ? await loadPeakMap(url) : null; + return map ? clipSourcePeak(map, clipSourceWindow(el)) : null; +} diff --git a/packages/studio/src/player/components/clipPeakRuns.test.ts b/packages/studio/src/player/components/clipPeakRuns.test.ts index 5aed064218..5a7140e817 100644 --- a/packages/studio/src/player/components/clipPeakRuns.test.ts +++ b/packages/studio/src/player/components/clipPeakRuns.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { clipPeakRuns } from "./clipPeakRuns"; +import { clipPeakRuns, clipSourcePeak } from "./clipPeakRuns"; const map = (bins: number[]) => ({ binSeconds: 1, bins }); @@ -36,3 +36,16 @@ describe("clipPeakRuns", () => { expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]); }); }); + +describe("clipSourcePeak", () => { + it("is the loudest bin inside the played source window only", () => { + const bins = map([0.9, 0.2, 0.4, 0.3, 1]); + expect(clipSourcePeak(bins, { mediaStart: 1, sourceSpan: 3 })).toBe(0.4); + expect(clipSourcePeak(bins, { mediaStart: 0, sourceSpan: 5 })).toBe(1); + }); + + it("is null for an empty window or one past the file's end", () => { + expect(clipSourcePeak(map([0.5]), { mediaStart: 0, sourceSpan: 0 })).toBeNull(); + expect(clipSourcePeak(map([0.5]), { mediaStart: 4, sourceSpan: 2 })).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/clipPeakRuns.ts b/packages/studio/src/player/components/clipPeakRuns.ts index 5478d231fc..814e3eec8b 100644 --- a/packages/studio/src/player/components/clipPeakRuns.ts +++ b/packages/studio/src/player/components/clipPeakRuns.ts @@ -48,6 +48,18 @@ export function clipPeakRuns( return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null }; } +/** The loudest source sample, linear, in the part of the file the clip plays; null outside it. */ +export function clipSourcePeak(map: PeakMap, window: ClipSourceWindow): number | null { + if (!(window.sourceSpan > 0) || !(map.binSeconds > 0)) return null; + const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds)); + const last = Math.min( + map.bins.length, + Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds), + ); + if (last <= first) return null; + return map.bins.slice(first, last).reduce((loudest, bin) => Math.max(loudest, bin), 0); +} + export function isPeakMap(value: unknown): value is PeakMap { if (typeof value !== "object" || value === null) return false; const bins: unknown = Reflect.get(value, "bins"); diff --git a/packages/studio/src/player/components/studioShortcuts.ts b/packages/studio/src/player/components/studioShortcuts.ts index 94334f302d..ea8216c0cb 100644 --- a/packages/studio/src/player/components/studioShortcuts.ts +++ b/packages/studio/src/player/components/studioShortcuts.ts @@ -4,6 +4,7 @@ export const STUDIO_PLAIN_KEYS = { split: "s", record: "r", addKeyframe: "k", + audioGain: "g", } as const; export interface ShortcutHint { @@ -51,6 +52,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [ { key: "⌘V", label: "Paste element" }, { key: "⌘X", label: "Cut element" }, { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" }, + { key: hintKey(STUDIO_PLAIN_KEYS.audioGain), label: "Audio Gain for clips with sound" }, { key: "⇧Click", label: "Razor tool: split all tracks" }, { key: "[", label: "Select clips starting before the playhead" }, { key: "]", label: "Select clips running at or after the playhead" }, diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 56b96dacc8..f546125e43 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -122,6 +122,12 @@ export interface TimelineEditCallbacks { value: string | null, label: string, ) => Promise; + /** One attribute on several clips, saved as one undo step. */ + onSetElementsAttributeQuiet?: ( + edits: ReadonlyArray<{ element: TimelineElement; value: string | null }>, + attr: string, + label: string, + ) => Promise; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onLinkEdit?: (edit: TimelineLinkEdit) => Promise | void; onDeleteElementOnly?: (element: TimelineElement) => Promise | void; From 0ab3e8816528745a5e7f8a8ce765bf18c390f7c8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 16:24:45 -0700 Subject: [PATCH 04/20] fix(studio): use a theme blue for the audio gain OK button Co-Authored-By: Claude Opus 5.5 --- packages/studio/src/player/components/AudioGainDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/AudioGainDialog.tsx b/packages/studio/src/player/components/AudioGainDialog.tsx index e4ace82162..3dceed07de 100644 --- a/packages/studio/src/player/components/AudioGainDialog.tsx +++ b/packages/studio/src/player/components/AudioGainDialog.tsx @@ -200,7 +200,7 @@ export function AudioGainDialog({ type="button" disabled={!canApply} autoFocus - className="rounded bg-blue-600 px-3 py-1 text-white disabled:opacity-40" + className="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-40" onClick={() => void apply()} > OK From 99273829458011bbdcfcb28f6c0f290cf9780f76 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 11:25:53 -0700 Subject: [PATCH 05/20] fix(studio): keep shortcuts out of the audio gain modal; refuse while recording Keys pressed inside an aria-modal dialog no longer reach the global hotkeys, so Delete on the focused OK button cannot delete the clips the dialog edits. The multi-clip write refuses during a recording like the single-clip one. Co-Authored-By: Claude Opus 5.5 --- .../hooks/timelineElementsAttribute.test.ts | 25 +++++++++++++++++++ .../src/hooks/timelineElementsAttribute.ts | 11 ++++++-- .../studio/src/hooks/useTimelineEditing.ts | 1 + .../studio/src/utils/typingTarget.test.ts | 8 ++++++ packages/studio/src/utils/typingTarget.ts | 1 + 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/hooks/timelineElementsAttribute.test.ts b/packages/studio/src/hooks/timelineElementsAttribute.test.ts index 8dfcf0362a..781dd10566 100644 --- a/packages/studio/src/hooks/timelineElementsAttribute.test.ts +++ b/packages/studio/src/hooks/timelineElementsAttribute.test.ts @@ -67,4 +67,29 @@ describe("useSetElementsAttribute", () => { 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 index 7141d53647..cf77d43f41 100644 --- a/packages/studio/src/hooks/timelineElementsAttribute.ts +++ b/packages/studio/src/hooks/timelineElementsAttribute.ts @@ -9,6 +9,7 @@ import { readFileContent, } from "./timelineEditingHelpers"; import type { UseTimelineElementVisibilityEditingInput } from "./timelineTrackVisibility"; +import { projectForTimelineSave } from "./timelineEditPermission"; type ElementAttributeEdits = Parameters< NonNullable @@ -53,11 +54,16 @@ export function useSetElementsAttribute({ recordEdit, previewIframeRef, pendingTimelineEditPathRef, + isRecordingRef, }: UseTimelineElementVisibilityEditingInput) { return useCallback( async (edits: ElementAttributeEdits, attr: string, label: string): Promise => { - const projectId = projectIdRef.current; - if (!projectId || edits.length === 0) return; + const projectId = projectForTimelineSave( + isRecordingRef?.current, + projectIdRef.current, + showToast, + ); + if (typeof projectId !== "string" || edits.length === 0) return; try { const byFile = editsByFile(edits, activeCompPath); const files = Object.fromEntries( @@ -93,6 +99,7 @@ export function useSetElementsAttribute({ recordEdit, previewIframeRef, pendingTimelineEditPathRef, + isRecordingRef, ], ); } diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index e2a7fd403d..3471a11e3d 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -418,6 +418,7 @@ export function useTimelineEditing({ recordEdit, previewIframeRef, pendingTimelineEditPathRef, + isRecordingRef, }); const setAudioGroupAttribute = useSetAudioGroupAttribute({ diff --git a/packages/studio/src/utils/typingTarget.test.ts b/packages/studio/src/utils/typingTarget.test.ts index d39528a5d6..86812349a9 100644 --- a/packages/studio/src/utils/typingTarget.test.ts +++ b/packages/studio/src/utils/typingTarget.test.ts @@ -54,6 +54,14 @@ describe("isTypingTarget", () => { expect(isTypingTarget(mount('

Hi

'))).toBe(false); }); + it("keeps a modal dialog's buttons out of the global shortcuts", () => { + const host = mount('
'); + expect(isTypingTarget(host.querySelector("button"))).toBe(true); + expect( + isTypingTarget(mount('
').querySelector("button")), + ).toBe(false); + }); + it("says no to nothing at all", () => { expect(isTypingTarget(null)).toBe(false); expect(isTypingTarget({} as EventTarget)).toBe(false); diff --git a/packages/studio/src/utils/typingTarget.ts b/packages/studio/src/utils/typingTarget.ts index 23d6c27008..a21094b69c 100644 --- a/packages/studio/src/utils/typingTarget.ts +++ b/packages/studio/src/utils/typingTarget.ts @@ -45,6 +45,7 @@ const TYPING_SELECTOR = [ // `input` and matched above. Without this row the shared Toggle would let a // global shortcut claim Space instead of flipping the control (KTD13). "[role='switch']", + "[role='dialog'][aria-modal='true']", ".cm-editor", ].join(","); From 5f523395bdbce8dbbdabfbce2724f7f3c7ccf5ac Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 12:24:49 -0700 Subject: [PATCH 06/20] fix(studio): offer link for any unlinked video and audio pair Link no longer requires the same file and identical timing, matching Premiere. Same-file pairs get a sync origin so a trimmed or moved audio shows its offset; different files link without one. Merge back is disabled until the pair is in sync. Linked moves keep the offset, and a trim only carries to a partner whose edge is at the same time. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/engine/mutate.ts | 45 ++++++++++++++-- packages/sdk/src/session.linked.test.ts | 12 +++++ .../components/editor/mediaLinkEdits.test.ts | 16 +++++- .../src/components/editor/mediaLinkEdits.ts | 52 ++++++++++++++----- .../src/hooks/timelineLinkEditPlan.test.ts | 28 ++++++++++ .../studio/src/hooks/timelineLinkEditPlan.ts | 7 ++- .../player/components/audioClipLink.test.ts | 13 +++++ .../src/player/components/audioClipLink.ts | 21 ++++++++ .../components/clipMenuLinkItems.test.ts | 39 ++++++++++++++ .../player/components/clipMenuLinkItems.tsx | 17 ++++-- .../player/components/useTimelineClipDrag.ts | 15 ++++-- skills-manifest.json | 2 +- .../references/data-attributes.md | 2 +- .../references/tracks-and-clips.md | 3 +- 14 files changed, 240 insertions(+), 32 deletions(-) diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 4d5cc7f74d..4f2ac89bbc 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -298,16 +298,51 @@ function applySetTiming( const own = handleSetTiming(parsed, ids, timing); return concatResults(own, handleSetAttribute(parsed, unlink, MEDIA_LINK_ATTR, null)); } + const before = new Map( + ids.map((id) => { + const el = resolveScoped(parsed.document, id); + return [el?.getAttribute(MEDIA_LINK_ATTR) ?? "", el ? readClipTiming(el) : null] as const; + }), + ); 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 }), - ); + let result = own; + for (const partner of partners) { + const el = resolveScoped(parsed.document, partner); + const grabbed = before.get(el?.getAttribute(MEDIA_LINK_ATTR) ?? ""); + if (!el || !grabbed) continue; + result = concatResults( + result, + handleSetTiming(parsed, [partner], partnerTiming(grabbed, readClipTiming(el), timing)), + ); + } + return result; +} + +type ClipWindow = { start: number | null; duration: number | null }; +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 endsTogether = + Math.abs(pStart + (partner.duration ?? 0) - (gStart + (grabbed.duration ?? 0))) < + ALIGN_EPSILON_S; + if (edit.duration !== undefined && endsTogether) timing.duration = edit.duration; + return timing; } export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { diff --git a/packages/sdk/src/session.linked.test.ts b/packages/sdk/src/session.linked.test.ts index 4baac66e70..e15b6ec76c 100644 --- a/packages/sdk/src/session.linked.test.ts +++ b/packages/sdk/src/session.linked.test.ts @@ -36,6 +36,18 @@ 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("{ linked: false } edits one member and unlinks the pair", async () => { const comp = await openComposition(LINKED_HTML); comp.setTiming("hf-talk", { start: 5 }, { linked: false }); diff --git a/packages/studio/src/components/editor/mediaLinkEdits.test.ts b/packages/studio/src/components/editor/mediaLinkEdits.test.ts index 5fbc15e534..5f171919e5 100644 --- a/packages/studio/src/components/editor/mediaLinkEdits.test.ts +++ b/packages/studio/src/components/editor/mediaLinkEdits.test.ts @@ -192,6 +192,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 +257,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, diff --git a/packages/studio/src/components/editor/mediaLinkEdits.ts b/packages/studio/src/components/editor/mediaLinkEdits.ts index 999895e721..e9538ce02c 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,20 @@ 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 freshly minted id. Only a pair from one source file + * also gets that id as its sync origin, so a drifted pair shows its offset. + */ +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 +253,30 @@ 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); +} + +/** Whether the pair comes from one source file, so the link can carry a sync origin. */ +export function sharesSourceFile(selected: readonly TimedElement[]): boolean { + const [a, b] = selected; + return selected.length === 2 && !!a && !!b && sameAssetInScope(a, b); +} + +/** Both halves put source time zero at the same timeline moment, at the same rate. */ +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/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/player/components/audioClipLink.test.ts b/packages/studio/src/player/components/audioClipLink.test.ts index c4e81bb248..d2fea52f60 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, @@ -229,3 +230,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..87623d5804 100644 --- a/packages/studio/src/player/components/audioClipLink.ts +++ b/packages/studio/src/player/components/audioClipLink.ts @@ -78,3 +78,24 @@ 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; + +/** A trim drags a link partner along only when its edge sits at the grabbed clip's edge time. */ +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 (el.link !== grabbed.link || keyOf(el) === keyOf(grabbed)) continue; + if (Math.abs(edgeTime(el, edge) - edgeTime(grabbed, edge)) > 1e-3) kept.delete(keyOf(el)); + } + return kept; +} diff --git a/packages/studio/src/player/components/clipMenuLinkItems.test.ts b/packages/studio/src/player/components/clipMenuLinkItems.test.ts index 2c94db56b1..e76c6daa51 100644 --- a/packages/studio/src/player/components/clipMenuLinkItems.test.ts +++ b/packages/studio/src/player/components/clipMenuLinkItems.test.ts @@ -87,4 +87,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..43ef07e656 100644 --- a/packages/studio/src/player/components/clipMenuLinkItems.tsx +++ b/packages/studio/src/player/components/clipMenuLinkItems.tsx @@ -2,12 +2,18 @@ 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, +} from "../../components/editor/mediaLinkEdits"; interface LinkMenuItem { label: string; shortcut?: string; destructive?: boolean; + disabledReason?: string; run: () => void; } @@ -61,6 +67,7 @@ export function resolveLinkMenuItems(input: { 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 +109,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/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 327e1ca247..b8d50f1e3b 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -27,7 +27,7 @@ import type { BlockedClipState, } from "./timelineClipDragTypes"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; -import { linkedGestureKeys } from "./audioClipLink"; +import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink"; import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences"; import type { TimelineRowGeometry } from "./timelineLayout"; import { @@ -242,12 +242,17 @@ export function useTimelineClipDrag({ return; } beginGesture("resize", next.pointerId); - gestureSelectedKeysRef.current = linkedGestureKeys( - gestureSelectedKeysRef.current, + gestureSelectedKeysRef.current = dropMisalignedTrimPartners( + linkedGestureKeys( + gestureSelectedKeysRef.current, + next.element, + elementsRef.current, + next.altKey === true, + isLinkedSelectionOn(), + ), next.element, elementsRef.current, - next.altKey === true, - isLinkedSelectionOn(), + next.edge, ); publishResizingClip(next); }, diff --git a/skills-manifest.json b/skills-manifest.json index bfe3e58016..27cc9fe3c6 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -34,7 +34,7 @@ "files": 11 }, "hyperframes-core": { - "hash": "539de2110ccdaa43", + "hash": "96756e9138ed43d9", "files": 11 }, "hyperframes-creative": { diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md index 83d9943707..eb1b485f94 100644 --- a/skills/hyperframes-core/references/data-attributes.md +++ b/skills/hyperframes-core/references/data-attributes.md @@ -36,7 +36,7 @@ The root should be `position: relative`, have explicit pixel dimensions, and hid | `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) β€” Studio's fader writes this. For fades and ducking, use the `data-automation` volume lane (see `creator-editing-recipes.md`). | | `data-has-audio` | Required on a timed `