From 5d50f6232d4186f9fb2633b1a99a0642245f21ac Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 00:19:58 -0700 Subject: [PATCH 01/25] feat(studio): order the clip menu into time, sound, picture and delete groups Split and Freeze frame; Normalize, Voice, link items and Duck; Look and Crop; then Delete beside Delete this clip only. Each item file renders its pieces separately, empty groups collapse, and the viewport clamp uses the measured menu height instead of a row-count estimate. Co-Authored-By: Claude Opus 5.5 --- .../ClipContextMenu.groups.test.tsx | 102 +++++++++ .../components/ClipContextMenu.test.tsx | 14 +- .../src/player/components/ClipContextMenu.tsx | 199 ++++++++++-------- .../components/clipMenuAudioItems.test.tsx | 6 +- .../player/components/clipMenuAudioItems.tsx | 38 ++-- .../player/components/clipMenuLinkItems.tsx | 6 +- .../player/components/clipMenuToolItems.tsx | 4 +- 7 files changed, 249 insertions(+), 120 deletions(-) create mode 100644 packages/studio/src/player/components/ClipContextMenu.groups.test.tsx diff --git a/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx b/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx new file mode 100644 index 0000000000..bf9bdbd389 --- /dev/null +++ b/packages/studio/src/player/components/ClipContextMenu.groups.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createHappyDomRootHarness } from "./testRootHarness"; +import { ClipContextMenu } from "./ClipContextMenu"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; + +const iframe = document.createElement("iframe"); +vi.mock("../../contexts/StudioContext", () => ({ + useStudioShellContextOptional: () => ({ + projectId: "p1", + showToast: vi.fn(), + previewIframeRef: { current: iframe }, + }), +})); + +const harness = createHappyDomRootHarness(); + +const talk: TimelineElement = { + id: "talk", + domId: "talk", + tag: "video", + src: "talk.mp4", + start: 0, + duration: 6, + track: 0, + hasAudio: true, + link: "lk-1", +}; +const talkAudio: TimelineElement = { + ...talk, + id: "talk-a", + domId: "talk-a", + tag: "audio", + track: 1, +}; + +function renderMenu(element: TimelineElement) { + usePlayerStore.getState().setElements([talk, talkAudio]); + document.body.appendChild(iframe); + const node = iframe.contentDocument?.createElement("video"); + if (node) { + node.id = "talk"; + node.setAttribute("data-has-audio", "true"); + iframe.contentDocument?.body.appendChild(node); + } + const host = document.createElement("div"); + document.body.appendChild(host); + const noop = vi.fn(); + act(() => + harness.mount(host).render( + undefined), + onFreezeFrame: noop, + onLinkEdit: noop, + onDeleteElementOnly: noop, + }} + > + + , + ), + ); +} + +function menuLabels(): string[] { + return Array.from(document.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')).map( + (item) => item.querySelector("span")?.textContent ?? "", + ); +} + +describe("ClipContextMenu order", () => { + it("groups time, sound, picture, then delete for a linked video with sound", () => { + renderMenu(talk); + const expected = [ + "Split at 2.00s", + "Freeze frame", + "Normalize loudness", + "Voice", + "Detach audio", + "Unlink", + "Look", + "Crop", + "Delete", + "Delete this clip only", + ]; + expect(menuLabels().filter((label) => expected.includes(label))).toEqual(expected); + const groups = Array.from(document.querySelectorAll('[role="group"]')).map((group) => + group.getAttribute("aria-label"), + ); + expect(groups).toEqual(["Time", "Sound", "Picture", "Clipboard", "Delete"]); + }); +}); diff --git a/packages/studio/src/player/components/ClipContextMenu.test.tsx b/packages/studio/src/player/components/ClipContextMenu.test.tsx index b72291631c..4d28e77835 100644 --- a/packages/studio/src/player/components/ClipContextMenu.test.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.test.tsx @@ -60,9 +60,14 @@ describe("ClipContextMenu host items", () => { unmount(); }); - it("counts host rows and their divider when keeping the menu inside the window", () => { + it("keeps the menu, host rows included, inside the window by its measured height", () => { const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight"); Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 }); + const offsetHeight = vi + .spyOn(HTMLElement.prototype, "offsetHeight", "get") + .mockImplementation(function (this: HTMLElement) { + return this.getAttribute("role") === "menu" ? 150 : 0; + }); try { const host = document.createElement("div"); document.body.appendChild(host); @@ -86,10 +91,11 @@ describe("ClipContextMenu host items", () => { />, ), ); - // Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window. - expect(document.body.querySelector("[role=menu]")!.style.top).toBe("446px"); + // 150 px measured from y 550 ends 100 px below the window, kept 8 px off its edge. + expect(document.body.querySelector("[role=menu]")!.style.top).toBe("442px"); act(() => root.unmount()); } finally { + offsetHeight.mockRestore(); if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight); } }); @@ -106,7 +112,7 @@ describe("ClipContextMenu host items", () => { it("shows only Studio's items when the host adds none", () => { const { items, unmount } = renderMenu([]); expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]); - expect(document.body.querySelectorAll(".border-t")).toHaveLength(1); + expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull(); unmount(); }); }); diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 7aef774d9c..683a03ee43 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useLayoutEffect, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { canSplitElement } from "../../utils/timelineElementSplit"; @@ -9,6 +9,18 @@ import { ClipMenuToolItems } from "./clipMenuToolItems"; import { ClipMenuAudioItems } from "./clipMenuAudioItems"; import { ClipMenuLinkItems } from "./clipMenuLinkItems"; +const MENU_MARGIN = 8; +// Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider. +const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60"; + +function useMeasuredHeight(ref: RefObject, anchorKey: string): number { + const [height, setHeight] = useState(0); + useLayoutEffect(() => { + setHeight(ref.current?.offsetHeight ?? 0); + }, [ref, anchorKey]); + return height; +} + interface ClipContextMenuProps { x: number; y: number; @@ -63,7 +75,6 @@ function HostItems({ )} ))} -
); } @@ -106,16 +117,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({ ? `Split at ${currentTime.toFixed(2)}s` : "Split (move playhead inside clip)"; - const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length; - const hostRows = hostItems.length; - const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present - const dividerCount = - (hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0); const menuWidth = 200; - const menuHeight = rowCount * 30 + dividerCount * 9 + 8; - const overflowY = y + menuHeight - window.innerHeight; const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x; - const adjustedY = overflowY > 0 ? y - overflowY - 8 : y; + const menuHeight = useMeasuredHeight(menuRef, `${x},${y},${element.key ?? element.id}`); + const overflowY = y + menuHeight - window.innerHeight; + const adjustedY = overflowY > 0 ? Math.max(MENU_MARGIN, y - overflowY - MENU_MARGIN) : y; return createPortal(
- {hostRows > 0 && } - {splitLabel && ( - <> + {hostItems.length > 0 && ( +
+ +
+ )} +
+ {splitLabel && ( + )} + {splitLabel && ( -
- - )} + )} +
- - +
+ + + + +
- {(onCopy || onPaste || onDuplicate) && ( - <> - {onCopy && ( - - )} - {onPaste && ( - - )} - {onDuplicate && ( - - )} -
- - )} +
+ +
+ +
+ {onCopy && ( + + )} + {onPaste && ( + + )} + {onDuplicate && ( + + )} +
- - - +
+ + +
, document.body, ); diff --git a/packages/studio/src/player/components/clipMenuAudioItems.test.tsx b/packages/studio/src/player/components/clipMenuAudioItems.test.tsx index 7b1a35820e..62d97d68c1 100644 --- a/packages/studio/src/player/components/clipMenuAudioItems.test.tsx +++ b/packages/studio/src/player/components/clipMenuAudioItems.test.tsx @@ -22,11 +22,13 @@ vi.mock("../../contexts/TimelineEditContext", () => ({ const harness = createHappyDomRootHarness(); -function render(element: TimelineElement) { +function render(element: TimelineElement, part: "normalize" | "duck" = "normalize") { const host = document.createElement("div"); document.body.appendChild(host); act(() => - harness.mount(host).render( {}} />), + harness + .mount(host) + .render( {}} />), ); return host; } diff --git a/packages/studio/src/player/components/clipMenuAudioItems.tsx b/packages/studio/src/player/components/clipMenuAudioItems.tsx index 0df27dd0f2..8ecaf9ecdf 100644 --- a/packages/studio/src/player/components/clipMenuAudioItems.tsx +++ b/packages/studio/src/player/components/clipMenuAudioItems.tsx @@ -23,11 +23,13 @@ const DUCK_TOAST: Record = { aborted: "Could not group the voices to duck under.", }; -/** Sound group of the clip menu: Normalize loudness (one-shot) and Duck under voice (toggle). */ +/** Normalize loudness (one-shot) or Duck under voice (toggle), placed separately in the sound group. */ export function ClipMenuAudioItems({ + part, element, onClose, }: { + part: "normalize" | "duck"; element: TimelineElement; onClose: () => void; }) { @@ -84,25 +86,25 @@ export function ClipMenuAudioItems({ showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info"); }; - return ( - <> + if (part === "normalize") { + return ( - {isDuckableBed(bed) && ( - - )} -
- + ); + } + if (!isDuckableBed(bed)) return null; + return ( + ); } diff --git a/packages/studio/src/player/components/clipMenuLinkItems.tsx b/packages/studio/src/player/components/clipMenuLinkItems.tsx index a5ca0d0daa..a7da089ee8 100644 --- a/packages/studio/src/player/components/clipMenuLinkItems.tsx +++ b/packages/studio/src/player/components/clipMenuLinkItems.tsx @@ -65,10 +65,13 @@ export function resolveLinkMenuItems(input: { return items; } +/** `link` = the sound-group items; `delete` = "Delete this clip only", placed beside Delete. */ export function ClipMenuLinkItems({ + part, element, onClose, }: { + part: "link" | "delete"; element: TimelineElement; onClose: () => void; }) { @@ -81,7 +84,7 @@ export function ClipMenuLinkItems({ selectedKeys, onLinkEdit, onDeleteElementOnly, - }); + }).filter((item) => (item.destructive === true) === (part === "delete")); if (items.length === 0) return null; return ( <> @@ -104,7 +107,6 @@ export function ClipMenuLinkItems({ )} ))} -
); } diff --git a/packages/studio/src/player/components/clipMenuToolItems.tsx b/packages/studio/src/player/components/clipMenuToolItems.tsx index bb372f3251..4acffdca5a 100644 --- a/packages/studio/src/player/components/clipMenuToolItems.tsx +++ b/packages/studio/src/player/components/clipMenuToolItems.tsx @@ -175,7 +175,7 @@ function FreezeFrameItem({ element, currentTime, onClose }: Omit; return ; @@ -204,7 +204,6 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset") } /> -
); } @@ -229,7 +228,6 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr > Crop -
); } From 8656a427757ccb2b4462f2112b7e7bf58e1129e7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 00:22:21 -0700 Subject: [PATCH 02/25] fix(studio-server): mint freeze-frame right-half links with the shared lk-N helper Freeze frame now calls core relinkSplitHalves, so split halves and freeze halves share one link-id convention instead of L -> L-2. Co-Authored-By: Claude Opus 5.5 --- .../src/helpers/freezeFrame.test.ts | 4 ++-- .../studio-server/src/helpers/freezeFrame.ts | 19 ++----------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/packages/studio-server/src/helpers/freezeFrame.test.ts b/packages/studio-server/src/helpers/freezeFrame.test.ts index 8767d1bc85..e7a70ce3ec 100644 --- a/packages/studio-server/src/helpers/freezeFrame.test.ts +++ b/packages/studio-server/src/helpers/freezeFrame.test.ts @@ -142,8 +142,8 @@ describe("applyFreezeFrameToHtml", () => { it("splits the linked audio, leaving a silent gap, and links the right halves together", () => { expect(timing(html, "talk-audio")).toEqual(["1", "2.2"]); expect(timing(html, "talk-audio-split")).toEqual(["5.2", "3.8"]); - expect(at(html, "talk-split")?.getAttribute("data-link")).toBe("L-2"); - expect(at(html, "talk-audio-split")?.getAttribute("data-link")).toBe("L-2"); + expect(at(html, "talk-split")?.getAttribute("data-link")).toBe("lk-1"); + expect(at(html, "talk-audio-split")?.getAttribute("data-link")).toBe("lk-1"); expect(at(html, "talk-audio")?.getAttribute("data-link")).toBe("L"); }); diff --git a/packages/studio-server/src/helpers/freezeFrame.ts b/packages/studio-server/src/helpers/freezeFrame.ts index 4630f5551e..583e964af1 100644 --- a/packages/studio-server/src/helpers/freezeFrame.ts +++ b/packages/studio-server/src/helpers/freezeFrame.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; import { resolveRateSpec, sourceTimeAt } from "@hyperframes/core/speed-ramp"; +import { MEDIA_LINK_ATTR as LINK_ATTR, relinkSplitHalves } from "@hyperframes/core/media-link"; import { findTargetElement, isHTMLElement, @@ -11,7 +12,6 @@ import { } from "./sourceMutation.js"; const FREEZE_HOLD_SECONDS = 2; -const LINK_ATTR = "data-link"; const EPSILON = 1e-3; const round3 = (value: number) => Math.round(value * 1000) / 1000; @@ -195,12 +195,6 @@ function splitAll( return { html: next, rightHalfIds }; } -function relinkRightHalves(document: Document, link: string | null, ids: readonly string[]): void { - if (!link) return; - const rightLink = uniqueLink(document, link); - for (const id of ids) document.getElementById(id)?.setAttribute(LINK_ATTR, rightLink); -} - /** * Split the video at the playhead, put a still of that frame in the gap and push the rest of its * track (and a linked partner's, which is split too and left silent for the hold) right by the hold. @@ -228,7 +222,7 @@ export function applyFreezeFrameToHtml( if (!cut) return null; const { document, wrappedFragment } = parseSourceDocument(cut.html); - relinkRightHalves(document, video.getAttribute(LINK_ATTR), cut.rightHalfIds); + relinkSplitHalves(document, cut.rightHalfIds); shiftTracks(document, tracks, input.playhead, hold); const leftHalf = findTargetElement(document, input.target); if (!leftHalf?.parentElement) return null; @@ -246,12 +240,3 @@ export function applyFreezeFrameToHtml( freezeId, }; } - -function uniqueLink(document: Document, base: string): string { - const taken = new Set( - Array.from(document.querySelectorAll(`[${LINK_ATTR}]`)).map((el) => el.getAttribute(LINK_ATTR)), - ); - let candidate = `${base}-2`; - for (let n = 3; taken.has(candidate); n++) candidate = `${base}-${n}`; - return candidate; -} From 278ccb478891b9634c606e970e8243cd3c528952 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 00:30:15 -0700 Subject: [PATCH 03/25] feat(studio): apply speed edits to every data-link member in one undo step The inspector speed slider, rate lane edits and ramp presets on a linked clip now commit once, writing the selection and, via expandToLinkedMembers, each partner from its own current value. Unlinked clips keep their usual path. Co-Authored-By: Claude Opus 5.5 --- .../components/editor/PropertyPanelFlat.tsx | 5 +- .../editor/linkedSpeedEdits.test.tsx | 154 ++++++++++++++++++ .../src/components/editor/linkedSpeedEdits.ts | 95 +++++++++++ .../editor/propertyPanelMediaSection.tsx | 7 +- .../components/editor/useVolumeAutomation.ts | 36 +++- 5 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 packages/studio/src/components/editor/linkedSpeedEdits.test.tsx create mode 100644 packages/studio/src/components/editor/linkedSpeedEdits.ts diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index a33deaacd8..035a4a3dc4 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -5,6 +5,7 @@ import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext import { slugifyDesignInput } from "../../utils/designInputTracking"; import { isTextEditableSelection } from "./domEditing"; import type { PropertyPanelFlatProps } from "./propertyPanelFlatProps"; +import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits"; import { formatPxMetricValue } from "./propertyPanelHelpers"; import { audioFxSummary } from "./audioFxSummary"; import { resolveAudioGroups } from "@hyperframes/core/audio-groups"; @@ -289,10 +290,12 @@ export function PropertyPanelFlat({ const showMotionEffects = gsapEffectHandlers !== null && !audioSelection; const showMotionGroup = showMotionTiming || showMotionEffects; + const linkedSpeed = useLinkedSpeedCommit(element, forwardedProps.onSetAttributeBatch); const volumeAutomation = useVolumeAutomation( element, currentTime, onSetAttributeQuiet ?? onSetAttributeLive, + linkedSpeed, ); // The group this clip belongs to, if any — the Audio FX summary reads @@ -505,7 +508,7 @@ export function PropertyPanelFlat({ element={element} styles={styles} onSetStyle={onSetStyle} - onSetAttribute={onSetAttribute} + onSetAttribute={withLinkedPlaybackRate(onSetAttribute, linkedSpeed)} {...volumeAutomation} /> ), diff --git a/packages/studio/src/components/editor/linkedSpeedEdits.test.tsx b/packages/studio/src/components/editor/linkedSpeedEdits.test.tsx new file mode 100644 index 0000000000..e75ca15369 --- /dev/null +++ b/packages/studio/src/components/editor/linkedSpeedEdits.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createHappyDomRootHarness } from "../../player/components/testRootHarness"; +import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore"; +import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes"; +import type { DomEditSelection } from "./domEditingTypes"; +import { + fanOutAttributeInSource, + linkedPartnerTargets, + useLinkedSpeedCommit, + withLinkedPlaybackRate, + type LinkedSpeedCommit, +} from "./linkedSpeedEdits"; +import { useVolumeAutomation } from "./useVolumeAutomation"; + +const clip = (id: string, tag: string, link?: string): TimelineElement => ({ + id, + domId: id, + tag, + start: 0, + duration: 4, + track: 0, + ...(link ? { link } : {}), +}); + +const elements = [ + clip("talk", "video", "lk-1"), + clip("talk-audio", "audio", "lk-1"), + clip("music", "audio"), +]; + +const source = `
+ + + +
`; + +function selection(id: string, dataAttributes: Record = {}): DomEditSelection { + const element = document.createElement("video"); + element.id = id; + return { + id, + element, + label: id, + tagName: "video", + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 0, y: 0, width: 0, height: 0 }, + textContent: null, + dataAttributes: { start: "0", duration: "4", ...dataAttributes }, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + }; +} + +describe("linkedPartnerTargets", () => { + it("returns the other data-link members and nothing for an unlinked clip", () => { + expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([{ id: "talk-audio" }]); + expect(linkedPartnerTargets({ id: "music" }, elements)).toEqual([]); + }); +}); + +describe("fanOutAttributeInSource", () => { + it("writes each partner from its own current value", () => { + const seen: Array = []; + const next = fanOutAttributeInSource( + source, + [{ id: "talk-audio" }], + "data-automation", + (raw) => { + seen.push(raw); + return "rate-lane"; + }, + ); + expect(seen).toEqual(['{"version":1,"lanes":[]}']); + expect(next).toContain( + 'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="rate-lane"', + ); + expect(next).not.toContain('id="music" data-start="0" data-duration="4" data-automation'); + }); +}); + +const harness = createHappyDomRootHarness(); + +function renderLinked(sel: DomEditSelection, commit: CommitDomAttributeBatch) { + let linked: LinkedSpeedCommit | null = null; + let rate: ReturnType["rate"] | null = null; + function Probe() { + linked = useLinkedSpeedCommit(sel, commit); + rate = useVolumeAutomation(sel, 0, vi.fn(), linked).rate; + return null; + } + const host = document.createElement("div"); + document.body.appendChild(host); + act(() => harness.mount(host).render()); + return { linked: () => linked, rate: () => rate }; +} + +describe("linked speed edits", () => { + it("fans a playback-rate edit out to the partner in one batch commit", async () => { + usePlayerStore.getState().setElements(elements); + const commit = vi.fn(async () => true); + const onSetAttribute = vi.fn(); + const probe = renderLinked(selection("talk"), commit); + await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2"); + expect(onSetAttribute).not.toHaveBeenCalled(); + expect(commit).toHaveBeenCalledTimes(1); + const [, ops, options] = commit.mock.calls[0] ?? []; + expect(ops).toEqual([{ type: "attribute", property: "playback-rate", value: "2" }]); + expect(options?.prepareContent?.(source)).toContain( + 'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{"version":1,"lanes":[]}" data-playback-rate="2"', + ); + }); + + it("fans a ramp preset out as one commit carrying each member's rate lane", () => { + usePlayerStore.getState().setElements(elements); + const commit = vi.fn(async () => true); + const probe = renderLinked(selection("talk"), commit); + act(() => probe.rate()?.onApplyPreset("ramp-in")); + expect(commit).toHaveBeenCalledTimes(1); + const [, ops, options] = commit.mock.calls[0] ?? []; + expect(ops?.[0]?.property).toBe("automation"); + expect(String(ops?.[0]?.value)).toContain('"target":"rate"'); + const partner = /id="talk-audio"[^>]*data-automation="([^"]*)"/.exec( + options?.prepareContent?.(source) ?? "", + )?.[1]; + expect(partner).toContain("rate"); + }); + + it("leaves an unlinked clip on its usual write path", async () => { + usePlayerStore.getState().setElements(elements); + const commit = vi.fn(async () => true); + const onSetAttribute = vi.fn(); + const probe = renderLinked(selection("music"), commit); + expect(probe.linked()).toBeNull(); + await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2"); + expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "2"); + expect(commit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/components/editor/linkedSpeedEdits.ts b/packages/studio/src/components/editor/linkedSpeedEdits.ts new file mode 100644 index 0000000000..5a3c7a1263 --- /dev/null +++ b/packages/studio/src/components/editor/linkedSpeedEdits.ts @@ -0,0 +1,95 @@ +import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore"; +import { expandToLinkedMembers } from "../../player/components/audioClipLink"; +import { + applyPatchByTarget, + readAttributeByTarget, + type PatchTarget, +} from "../../utils/sourcePatcher"; +import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes"; +import type { DomEditSelection } from "./domEditingTypes"; + +type AttributeValueFor = (current: string | undefined) => string | null; + +/** Writes a speed attribute on the selection and every `data-link` partner as one undo step. */ +export interface LinkedSpeedCommit { + commitAttribute: (attr: string, valueFor: AttributeValueFor, label: string) => Promise; +} + +const keyOf = (el: TimelineElement) => el.key ?? el.id; +const dataProperty = (attr: string) => (attr.startsWith("data-") ? attr.slice(5) : attr); + +function targetOf(el: TimelineElement): PatchTarget { + const target: PatchTarget = { id: el.domId ?? el.id }; + if (el.hfId) target.hfId = el.hfId; + return target; +} + +function isSelected(el: TimelineElement, selection: PatchTarget): boolean { + if (selection.hfId && el.hfId) return el.hfId === selection.hfId; + return selection.id != null && (el.domId ?? el.id) === selection.id; +} + +export function linkedPartnerTargets( + selection: PatchTarget, + elements: readonly TimelineElement[], +): PatchTarget[] { + const self = elements.find((el) => isSelected(el, selection)); + if (!self?.link) return []; + const members = expandToLinkedMembers([keyOf(self)], elements); + members.delete(keyOf(self)); + return elements.filter((el) => members.has(keyOf(el))).map(targetOf); +} + +export function fanOutAttributeInSource( + source: string, + targets: readonly PatchTarget[], + attr: string, + valueFor: AttributeValueFor, +): string { + const property = dataProperty(attr); + return targets.reduce( + (html, target) => + applyPatchByTarget(html, target, { + type: "attribute", + property, + value: valueFor(readAttributeByTarget(html, target, attr)), + }), + source, + ); +} + +/** Null when the selection has no link partner, so unlinked clips keep their usual write path. */ +export function useLinkedSpeedCommit( + selection: DomEditSelection, + commit: CommitDomAttributeBatch | undefined, +): LinkedSpeedCommit | null { + const elements = usePlayerStore((s) => s.elements); + if (!commit) return null; + const partners = linkedPartnerTargets(selection, elements); + if (partners.length === 0) return null; + return { + commitAttribute: (attr, valueFor, label) => { + const property = dataProperty(attr); + return commit( + selection, + [{ type: "attribute", property, value: valueFor(selection.dataAttributes[property]) }], + { + label, + prepareContent: (html) => fanOutAttributeInSource(html, partners, attr, valueFor), + }, + ); + }, + }; +} + +/** Routes `playback-rate` through the linked commit; every other attribute keeps `onSetAttribute`. */ +export function withLinkedPlaybackRate( + onSetAttribute: (attr: string, value: string) => void | Promise, + linked: LinkedSpeedCommit | null, +): (attr: string, value: string) => void | Promise { + if (!linked) return onSetAttribute; + return async (attr, value) => { + if (attr !== "playback-rate") return onSetAttribute(attr, value); + await linked.commitAttribute("data-playback-rate", () => value, "Edit speed"); + }; +} diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index c86db6461d..a8b57892d7 100644 --- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx @@ -24,6 +24,7 @@ import { audioGainToText, } from "@hyperframes/core/audio-gain"; import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes"; +import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits"; import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits"; // fallow-ignore-next-line complexity @@ -56,6 +57,10 @@ export function MediaSection({ ) => Promise; }) { const track = useTrackDesignInput(); + const setRate = withLinkedPlaybackRate( + onSetAttribute, + useLinkedSpeedCommit(element, onSetAttributeBatch), + ); const isVideo = element.tagName === "video"; const isAudio = element.tagName === "audio"; const isImage = element.tagName === "img"; @@ -279,7 +284,7 @@ export function MediaSection({ displayValue={`${formatNumericValue(playbackRate)}x`} formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`} onCommit={(next) => { - void onSetAttribute("playback-rate", formatNumericValue(next / 100)); + void setRate("playback-rate", formatNumericValue(next / 100)); }} />
diff --git a/packages/studio/src/components/editor/useVolumeAutomation.ts b/packages/studio/src/components/editor/useVolumeAutomation.ts index f0fe065dc4..f45e2617bf 100644 --- a/packages/studio/src/components/editor/useVolumeAutomation.ts +++ b/packages/studio/src/components/editor/useVolumeAutomation.ts @@ -26,6 +26,8 @@ import { import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp"; import { clampNumber } from "../../utils/studioHelpers"; +import type { HfAutomation } from "@hyperframes/core/audio-automation"; +import type { LinkedSpeedCommit } from "./linkedSpeedEdits"; export interface LaneBinding { automated: boolean; @@ -59,6 +61,7 @@ export function useVolumeAutomation( element: DomEditSelection, currentTime: number, onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise, + linkedSpeed: LinkedSpeedCommit | null = null, ): VolumeAutomationBinding { // The chain is not needed to resolve a volume or rate lane: both are always valid // targets, so this deliberately does not parse it. @@ -77,7 +80,22 @@ export function useVolumeAutomation( // playing track, while the same click on an effect parameter did not. void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null); }; - const laneBinding = (target: string, seed: number): LaneBinding => { + type Edit = (current: HfAutomation) => HfAutomation; + const writeOwn = (edit: Edit) => write(edit(automation)); + // Speed moves every data-link member together, so a linked clip's rate lane edits fan out. + const writeRate = (edit: Edit) => { + if (!linkedSpeed) return writeOwn(edit); + void linkedSpeed.commitAttribute( + HF_AUDIO_AUTOMATION_ATTR, + (raw) => automationAttrValue(edit(readPanelAutomation(raw, undefined))) || null, + "Edit speed", + ); + }; + const laneBinding = ( + target: string, + seed: number, + writeLane: (edit: Edit) => void, + ): LaneBinding => { const lane = automation.lanes.find((l) => l.target === target); return { automated: lane !== undefined, @@ -85,9 +103,9 @@ export function useVolumeAutomation( ? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale) : undefined, // Seeded at the level the control already shows, so automating does not change it. - onAutomate: () => write(withSeededLane(automation, target, seed)), - onRemoveAutomation: () => write(withoutLane(automation, target)), - onCommitAt: (v: number) => write(withPointAt(automation, target, clipTimeSec, v)), + onAutomate: () => writeLane((a) => withSeededLane(a, target, seed)), + onRemoveAutomation: () => writeLane((a) => withoutLane(a, target)), + onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)), }; }; // `??` alone would let an empty `data-volume` through as Number("") === 0, so @@ -96,9 +114,13 @@ export function useVolumeAutomation( const raw = element.dataAttributes?.["volume"]; const parsed = raw ? Number(raw) : 1; const current = Number.isFinite(parsed) ? parsed : 1; - const volume = laneBinding(VOLUME_TARGET, current); + const volume = laneBinding(VOLUME_TARGET, current, writeOwn); const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? ""); - const rate = laneBinding(RATE_TARGET, Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1); + const rate = laneBinding( + RATE_TARGET, + Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1, + writeRate, + ); return { volumeAutomated: volume.automated, automatedVolumeValue: volume.automatedValue, @@ -109,7 +131,7 @@ export function useVolumeAutomation( ...rate, canApplyPreset: elDuration > 0, onApplyPreset: (id) => { - if (elDuration > 0) write(withLane(automation, speedPresetLane(id, elDuration))); + if (elDuration > 0) writeRate((a) => withLane(a, speedPresetLane(id, elDuration))); }, }, }; From 4934189af0371884a937d3e36d8657688786783b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 00:33:23 -0700 Subject: [PATCH 04/25] refactor(studio): drop the audio parent tick in favour of the data-link chain badge A linked clip now shows one link indicator: the chain badge driven by data-link. The waveform no longer draws its 1px parent tick. Co-Authored-By: Claude Opus 5.5 --- .../studio/src/hooks/useRenderClipContent.test.ts | 8 ++++---- .../src/player/components/AudioWaveform.test.tsx | 4 +--- .../studio/src/player/components/AudioWaveform.tsx | 4 ---- .../src/player/components/audioClipLink.test.ts | 5 ----- .../studio/src/player/components/audioClipLink.ts | 9 +++------ packages/studio/src/styles/components.css | 11 ----------- packages/studio/src/styles/theme.css | 1 - 7 files changed, 8 insertions(+), 34 deletions(-) diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 0edcc303c0..28912ad715 100644 --- a/packages/studio/src/hooks/useRenderClipContent.test.ts +++ b/packages/studio/src/hooks/useRenderClipContent.test.ts @@ -196,7 +196,7 @@ describe("useRenderClipContent", () => { } }); - it("marks audio linked by data-link, and muted when hidden", () => { + it("marks hidden audio muted and leaves the link to the chain badge", () => { usePlayerStore.setState({ thumbnailMode: "hidden", elements: [ @@ -220,9 +220,9 @@ describe("useRenderClipContent", () => { link: "lk-1", hidden: true, }); - expect(isValidElement<{ linked: boolean; muted: boolean }>(linked)).toBe(true); - if (isValidElement<{ linked: boolean; muted: boolean }>(linked)) { - expect(linked.props.linked).toBe(true); + expect(isValidElement<{ linked?: boolean; muted: boolean }>(linked)).toBe(true); + if (isValidElement<{ linked?: boolean; muted: boolean }>(linked)) { + expect(linked.props.linked).toBeUndefined(); expect(linked.props.muted).toBe(true); } }); diff --git a/packages/studio/src/player/components/AudioWaveform.test.tsx b/packages/studio/src/player/components/AudioWaveform.test.tsx index f03c11df92..05fe63d980 100644 --- a/packages/studio/src/player/components/AudioWaveform.test.tsx +++ b/packages/studio/src/player/components/AudioWaveform.test.tsx @@ -96,7 +96,7 @@ describe("AudioWaveform", () => { act(() => root.unmount()); }); - it("greys the clip in place when muted and draws the parent tick when linked", () => { + it("greys the clip in place when muted", () => { const host = document.createElement("div"); host.className = "timeline-clip is-audio"; document.body.append(host); @@ -112,13 +112,11 @@ describe("AudioWaveform", () => { sessionEpoch={1} priority="visible" muted - linked />, ); }); expect(host.getAttribute("data-audio-muted")).toBe("true"); - expect(host.querySelector(".timeline-audio-link")).not.toBeNull(); act(() => root.unmount()); expect(host.hasAttribute("data-audio-muted")).toBe(false); diff --git a/packages/studio/src/player/components/AudioWaveform.tsx b/packages/studio/src/player/components/AudioWaveform.tsx index 2d8eef38f9..67084a7331 100644 --- a/packages/studio/src/player/components/AudioWaveform.tsx +++ b/packages/studio/src/player/components/AudioWaveform.tsx @@ -18,8 +18,6 @@ export interface AudioWaveformProps { priority: ThumbnailPriority; /** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */ muted?: boolean; - /** Same media file as a video clip. Draws the 1px parent tick. */ - linked?: boolean; } const BAR_STEP = 3; @@ -167,7 +165,6 @@ export const AudioWaveform = memo(function AudioWaveform({ sessionEpoch, priority, muted = false, - linked = false, }: AudioWaveformProps) { const rootRef = useRef(null); const canvasRef = useRef(null); @@ -236,7 +233,6 @@ export const AudioWaveform = memo(function AudioWaveform({ return (
- {linked ?