({
+ 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 from audio",
+ "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..438b09def5 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,24 @@ 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(() => {
+ const node = ref.current;
+ if (!node) return;
+ setHeight(node.offsetHeight);
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [ref, anchorKey]);
+ return height;
+}
+
interface ClipContextMenuProps {
x: number;
y: number;
@@ -63,7 +81,6 @@ function HostItems({
)}
))}
-
>
);
}
@@ -106,16 +123,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/OutOfSyncBadge.test.tsx b/packages/studio/src/player/components/OutOfSyncBadge.test.tsx
new file mode 100644
index 0000000000..40021f35e8
--- /dev/null
+++ b/packages/studio/src/player/components/OutOfSyncBadge.test.tsx
@@ -0,0 +1,91 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
+import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+import { OutOfSyncBadge } from "./OutOfSyncBadge";
+import { createHappyDomRootHarness } from "./testRootHarness";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const harness = createHappyDomRootHarness();
+const clip = (id: string, tag: string, start: number): TimelineElement => ({
+ id,
+ domId: id,
+ tag,
+ start,
+ duration: 4,
+ track: 0,
+ playbackStart: 0,
+ syncOrigin: "lk-1",
+});
+const video = clip("talk", "video", 1);
+const lateAudio = clip("talk-audio", "audio", 1 + 51 / 30);
+
+afterEach(() =>
+ useLinkedClipPreferences.setState({ syncIndicatorsVisible: true, compositionFps: 30 }),
+);
+
+function render(el: TimelineElement, onLinkEdit = vi.fn()) {
+ usePlayerStore.getState().setElements([video, lateAudio]);
+ const host = document.body.appendChild(document.createElement("div"));
+ act(() =>
+ harness.mount(host).render(
+
+
+ ,
+ ),
+ );
+ const badge = () => document.querySelector('[data-testid="out-of-sync-badge"]');
+ return { badge, onLinkEdit };
+}
+
+describe("OutOfSyncBadge", () => {
+ it("shows the signed offset on both halves", () => {
+ useLinkedClipPreferences.setState({ compositionFps: 30 });
+ expect(render(lateAudio).badge()?.textContent).toBe("+1:21");
+ document.body.innerHTML = "";
+ expect(render(video).badge()?.textContent).toBe("-1:21");
+ });
+
+ it("hides when the preference is off", () => {
+ useLinkedClipPreferences.setState({ syncIndicatorsVisible: false });
+ expect(render(lateAudio).badge()).toBeNull();
+ });
+
+ it("opens Move / Slip into Sync from a click anywhere on the badge", () => {
+ const { badge, onLinkEdit } = render(lateAudio);
+ act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+ const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
+ expect(items.map((item) => item.textContent)).toEqual(["Move into Sync", "Slip into Sync"]);
+ act(() => items[0]?.click());
+ expect(onLinkEdit).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: "move-into-sync", element: lateAudio, start: 1 }),
+ );
+ });
+
+ it("slips from a right-click, and the click never reaches the clip", () => {
+ const clipClick = vi.fn();
+ document.body.addEventListener("contextmenu", clipClick);
+ const { badge, onLinkEdit } = render(lateAudio);
+ act(() => badge()?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
+ expect(clipClick).not.toHaveBeenCalled();
+ const slip = document.querySelectorAll('[role="menuitem"]')[1];
+ act(() => slip?.click());
+ const edit = onLinkEdit.mock.calls[0]?.[0];
+ expect(edit.kind).toBe("slip-into-sync");
+ expect(edit.mediaStart).toBeCloseTo(51 / 30);
+ document.body.removeEventListener("contextmenu", clipClick);
+ });
+
+ it("flips the menu above the pointer near the bottom edge", () => {
+ const { badge } = render(lateAudio);
+ const y = window.innerHeight - 10;
+ act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientY: y })));
+ const menu = document.querySelector('[role="menu"]');
+ const top = Number.parseFloat(menu?.style.top ?? "");
+ expect(top).toBeLessThan(y);
+ expect(top).toBeGreaterThanOrEqual(0);
+ });
+});
diff --git a/packages/studio/src/player/components/OutOfSyncBadge.tsx b/packages/studio/src/player/components/OutOfSyncBadge.tsx
new file mode 100644
index 0000000000..49fc8c4339
--- /dev/null
+++ b/packages/studio/src/player/components/OutOfSyncBadge.tsx
@@ -0,0 +1,119 @@
+import { memo, useCallback, useState, type SyntheticEvent } from "react";
+import { createPortal } from "react-dom";
+import { formatSyncOffset } from "@hyperframes/core/media-link";
+import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
+import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
+import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+import { clipSyncState, type ClipSyncState } from "./clipSync";
+import { useMenuKeyboardNav } from "./menuKeyboardNav";
+
+const stop = (e: SyntheticEvent) => e.stopPropagation();
+
+const MENU_ROW =
+ "block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
+
+function SyncMenu({
+ x,
+ y,
+ el,
+ sync,
+ onClose,
+}: {
+ x: number;
+ y: number;
+ el: TimelineElement;
+ sync: ClipSyncState;
+ onClose: () => void;
+}) {
+ const menuRef = useContextMenuDismiss(onClose);
+ useMenuKeyboardNav(menuRef);
+ const { onLinkEdit } = useTimelineEditContextOptional();
+ const { moveStart, slipMediaStart } = sync;
+ const menuWidth = 170;
+ const menuHeight = 66;
+ const left = x + menuWidth > window.innerWidth ? x - menuWidth : x;
+ const top = y + menuHeight > window.innerHeight ? Math.max(0, y - menuHeight) : y;
+ return createPortal(
+
+
+
+
,
+ document.body,
+ );
+}
+
+/**
+ * Premiere's red out-of-sync number, at the start of each half of a source pair
+ * that drifted. The whole badge opens Move / Slip into Sync (Premiere's own hit
+ * target is a sliver at the badge edge, a known complaint).
+ */
+export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: TimelineElement }) {
+ const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
+ const fps = useLinkedClipPreferences((s) => s.compositionFps);
+ const elements = usePlayerStore((s) => s.elements);
+ const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
+ const closeMenu = useCallback(() => setMenuAt(null), []);
+ const sync = visible ? clipSyncState(el, elements, fps) : null;
+ if (!sync) return null;
+ const label = formatSyncOffset(sync.frames, fps);
+ const partnerName = sync.partner.label || sync.partner.id;
+ const open = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setMenuAt({ x: e.clientX, y: e.clientY });
+ };
+ return (
+ <>
+
+ {label}
+
+ {menuAt && }
+ >
+ );
+});
diff --git a/packages/studio/src/player/components/TimelineClip.test.tsx b/packages/studio/src/player/components/TimelineClip.test.tsx
index 0e2de43a3c..3c4786be67 100644
--- a/packages/studio/src/player/components/TimelineClip.test.tsx
+++ b/packages/studio/src/player/components/TimelineClip.test.tsx
@@ -105,6 +105,23 @@ describe("TimelineClip", () => {
expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
});
+ it("gives both halves of a link group the same label colour, and unlinked clips none", () => {
+ const colorOf = (element: TimelineElement) => {
+ const { host, root } = renderClip({ element });
+ const clip = host.querySelector(".timeline-clip");
+ const color = clip?.getAttribute("data-link-color") ?? null;
+ expect(clip?.style.getPropertyValue("--clip-link-color") || null).toBe(color);
+ act(() => root.unmount());
+ return color;
+ };
+ const base = { start: 0, duration: 2, track: 0 };
+ const video = colorOf({ id: "v", tag: "video", link: "lk-3", ...base });
+ expect(video).not.toBeNull();
+ expect(colorOf({ id: "a", tag: "audio", link: "lk-3", ...base })).toBe(video);
+ expect(colorOf({ id: "b", tag: "audio", link: "lk-4", ...base })).not.toBe(video);
+ expect(colorOf({ id: "c", tag: "audio", ...base })).toBeNull();
+ });
+
it("renders the clip label above custom content without showing default timecode", () => {
const { host, root } = renderClip({
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx
index 5030bfdce2..2b63755c05 100644
--- a/packages/studio/src/player/components/TimelineClip.tsx
+++ b/packages/studio/src/player/components/TimelineClip.tsx
@@ -12,6 +12,8 @@ import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
import { rendersWaveform } from "./AudioWaveform";
import { ClipBadges } from "./ClipBadges";
+import { linkLabelColor } from "./linkLabelColor";
+import { OutOfSyncBadge } from "./OutOfSyncBadge";
interface TimelineClipProps {
el: TimelineElement;
@@ -83,6 +85,8 @@ export const TimelineClip = memo(function TimelineClip({
"--clip-border-active": theme.clipBorderActive,
"--clip-handle": theme.handleColor,
} as CSSProperties;
+ const linkColor = linkLabelColor(el.link);
+ if (linkColor) Object.assign(themeVariables, { "--clip-link-color": linkColor });
const isAudioClip = isAudioTimelineElement(el);
const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
const fade = useClipFadeDraft(el);
@@ -124,6 +128,7 @@ export const TimelineClip = memo(function TimelineClip({
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
+ data-link-color={linkColor ?? undefined}
data-ladder={ladder}
data-active={isActive ? "" : undefined}
aria-hidden={isGestureActor ? "true" : undefined}
@@ -206,6 +211,7 @@ export const TimelineClip = memo(function TimelineClip({
)}
{showLabel && {displayLabel}}
{showLabel && !isGestureActor && }
+ {!isGestureActor && el.syncOrigin && }
{showDefaultText && (
{startLabel}-{endLabel}s
diff --git a/packages/studio/src/player/components/audioClipLink.test.ts b/packages/studio/src/player/components/audioClipLink.test.ts
index 42f5743dde..c4e81bb248 100644
--- a/packages/studio/src/player/components/audioClipLink.test.ts
+++ b/packages/studio/src/player/components/audioClipLink.test.ts
@@ -17,11 +17,6 @@ const plain = { id: "title" };
const elements = [video, audio, other, otherAudio, plain];
describe("audioPillFlags", () => {
- it("is linked only by data-link, never by a shared file name", () => {
- expect(audioPillFlags({ link: "lk-1" }).linked).toBe(true);
- expect(audioPillFlags({}).linked).toBe(false);
- });
-
it("greys a hidden clip and a muted group", () => {
expect(audioPillFlags({ hidden: true }).muted).toBe(true);
expect(audioPillFlags({ audioGroupHidden: true }).muted).toBe(true);
@@ -163,6 +158,12 @@ describe("linkedGestureKeys", () => {
);
});
+ it("Linked Selection off edits the grabbed clip alone without Alt", () => {
+ expect(linkedGestureKeys(new Set(["talk"]), video, elements, false, false)).toEqual(
+ new Set(["talk"]),
+ );
+ });
+
it("Alt edits the grabbed clip alone", () => {
expect(linkedGestureKeys(new Set(["talk", "talk-audio"]), video, elements, true)).toEqual(
new Set(["talk"]),
@@ -202,3 +203,29 @@ describe("selectClipWithLinks", () => {
expect(usePlayerStore.getState().selectedElementId).toBe("talk");
});
});
+
+describe("Linked Selection off", () => {
+ const linkedPair = [
+ { id: "talk", tag: "video", start: 0, duration: 4, track: 0, link: "lk-1" },
+ { id: "talk-audio", tag: "audio", start: 0, duration: 4, track: 1, link: "lk-1" },
+ ];
+
+ it("expands and lists nothing past the clip itself", () => {
+ expect(expandToLinkedMembers(["talk"], linkedPair, false)).toEqual(new Set(["talk"]));
+ expect(linkedMembersOf(linkedPair[0], linkedPair, false)).toEqual([linkedPair[0]]);
+ });
+
+ it("a click selects only the clicked clip", async () => {
+ const { usePlayerStore } = await import("../store/playerStore");
+ const { selectClipWithLinks } = await import("./timelineLinkSelection");
+ const { useLinkedClipPreferences } = await import("../../utils/linkedClipPreferences");
+ usePlayerStore.getState().setElements(linkedPair);
+ useLinkedClipPreferences.getState().setLinkedSelection(false);
+ try {
+ selectClipWithLinks("talk", false, usePlayerStore.getState().setSelectedElementId);
+ expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["talk"]));
+ } finally {
+ useLinkedClipPreferences.getState().setLinkedSelection(true);
+ }
+ });
+});
diff --git a/packages/studio/src/player/components/audioClipLink.ts b/packages/studio/src/player/components/audioClipLink.ts
index ee6cdc338f..355659d0b6 100644
--- a/packages/studio/src/player/components/audioClipLink.ts
+++ b/packages/studio/src/player/components/audioClipLink.ts
@@ -34,17 +34,18 @@ export function sharesLinkGroup(
}
export function audioPillFlags(
- audio: Pick,
+ audio: Pick,
_elements?: readonly unknown[],
-): { muted: boolean; linked: boolean } {
- return {
- muted: audio.hidden === true || audio.audioGroupHidden === true,
- linked: isLinked(audio),
- };
+): { muted: boolean } {
+ return { muted: audio.hidden === true || audio.audioGroupHidden === true };
}
-export function linkedMembersOf(element: T, elements: readonly T[]): T[] {
- if (!isLinked(element)) return [element];
+export function linkedMembersOf(
+ element: T,
+ elements: readonly T[],
+ linked = true,
+): T[] {
+ if (!linked || !isLinked(element)) return [element];
const members = elements.filter((candidate) => sharesLinkGroup(candidate, element));
return members.some((member) => keyOf(member) === keyOf(element))
? members
@@ -54,8 +55,10 @@ export function linkedMembersOf(element: T, elements: r
export function expandToLinkedMembers(
keys: Iterable,
elements: readonly LinkedElement[],
+ linked = true,
): Set {
const expanded = new Set(keys);
+ if (!linked) return expanded;
const seeds = elements.filter((el) => expanded.has(keyOf(el)) && isLinked(el));
for (const element of elements) {
if (seeds.some((seed) => sharesLinkGroup(seed, element))) expanded.add(keyOf(element));
@@ -68,9 +71,10 @@ export function linkedGestureKeys(
grabbed: LinkedElement,
elements: readonly LinkedElement[],
altKey: boolean,
+ linked = true,
): Set {
const grabbedKey = keyOf(grabbed);
if (altKey) return new Set([grabbedKey]);
const base = selected.has(grabbedKey) ? selected : [grabbedKey];
- return expandToLinkedMembers(base, elements);
+ return expandToLinkedMembers(base, elements, linked);
}
diff --git a/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx b/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx
new file mode 100644
index 0000000000..a91d2e4768
--- /dev/null
+++ b/packages/studio/src/player/components/clipMenuAudioItems.noShell.test.tsx
@@ -0,0 +1,78 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createHappyDomRootHarness } from "./testRootHarness";
+import { ClipMenuAudioItems } from "./clipMenuAudioItems";
+import { usePlayerStore } from "../store/playerStore";
+import { usePreviewIframeStore } from "../store/previewIframeStore";
+import type { TimelineElement } from "../store/timelineElement";
+
+const onNotice = vi.fn();
+const setQuiet = vi.fn(async () => {});
+
+vi.mock("../../contexts/StudioContext", () => ({
+ useStudioShellContextOptional: () => null,
+}));
+vi.mock("../../contexts/TimelineEditContext", () => ({
+ useTimelineEditContextOptional: () => ({ onSetElementAttributeQuiet: setQuiet, onNotice }),
+}));
+
+const harness = createHappyDomRootHarness();
+const tour: TimelineElement = {
+ id: "tour",
+ tag: "video",
+ start: 0,
+ duration: 4,
+ track: 0,
+ hasAudio: true,
+ src: "tour.mp4",
+};
+
+function mountPreview(): void {
+ const iframe = document.createElement("iframe");
+ document.body.appendChild(iframe);
+ const doc = iframe.contentDocument;
+ if (!doc) throw new Error("iframe has no document");
+ doc.body.innerHTML =
+ '';
+ usePreviewIframeStore.getState().setIframe(iframe);
+}
+
+function render(part: "normalize" | "duck") {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ act(() =>
+ harness
+ .mount(host)
+ .render( {}} />),
+ );
+ return host;
+}
+
+afterEach(() => {
+ usePreviewIframeStore.getState().setIframe(null);
+ vi.unstubAllGlobals();
+});
+
+describe("ClipMenuAudioItems in a host without Studio's shell", () => {
+ it("offers Normalize and Duck from the timeline session and the live preview", () => {
+ usePlayerStore.getState().beginTimelineSession("p1");
+ mountPreview();
+ expect(render("normalize").textContent).toContain("Normalize loudness");
+ expect(render("duck").textContent).toContain("Duck under voice");
+ });
+
+ it("normalizes through the session's project and reports through 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());
+ await vi.waitFor(() => expect(onNotice).toHaveBeenCalled());
+ expect(String(fetchSpy.mock.calls[0]?.[0])).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 7b1a35820e..a73119a647 100644
--- a/packages/studio/src/player/components/clipMenuAudioItems.test.tsx
+++ b/packages/studio/src/player/components/clipMenuAudioItems.test.tsx
@@ -7,7 +7,12 @@ import type { TimelineElement } from "../store/timelineElement";
const showToast = vi.fn();
const setQuiet = vi.fn<(...args: unknown[]) => Promise>(async () => {});
-const iframe = document.createElement("iframe");
+let previewDoc: Document | null = null;
+const iframe = {
+ get contentDocument() {
+ return previewDoc;
+ },
+};
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContextOptional: () => ({
@@ -22,11 +27,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;
}
@@ -50,6 +57,44 @@ describe("ClipMenuAudioItems", () => {
expect(host.textContent).toBe("");
});
+ describe("Duck under voice", () => {
+ const duckLabel = (host: HTMLElement) =>
+ [...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Duck under voice"));
+ const compose = (body: string) => {
+ previewDoc = document.implementation.createHTMLDocument("c");
+ previewDoc.body.innerHTML = body;
+ };
+ const music: TimelineElement = { ...base, id: "music", tag: "audio", src: "music.mp3" };
+
+ it("is hidden on a lone video with sound", () => {
+ compose(
+ ``,
+ );
+ const host = render(
+ { ...base, id: "a-roll", tag: "video", hasAudio: true, src: "a.mp4" },
+ "duck",
+ );
+ expect(duckLabel(host)).toBeUndefined();
+ });
+
+ it("shows for a music bed with an overlapping voice", () => {
+ compose(`
+ `);
+ const host = render(music, "duck");
+ expect(duckLabel(host)?.getAttribute("aria-checked")).toBe("false");
+ });
+
+ it("stays available, checked, once ducked even with the voice gone", () => {
+ compose(
+ ``,
+ );
+ const host = render(music, "duck");
+ const item = duckLabel(host);
+ expect(item?.getAttribute("aria-checked")).toBe("true");
+ expect(item?.textContent).toBe("Duck under voice✓");
+ });
+ });
+
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(
@@ -104,22 +149,20 @@ describe("ClipMenuAudioItems", () => {
});
it("stops ducking at the first refused save and says why", async () => {
- document.body.appendChild(iframe);
- const doc = iframe.contentDocument;
- if (!doc) throw new Error("fixture");
- doc.body.innerHTML = `
+ previewDoc = document.implementation.createHTMLDocument("c");
+ previewDoc.body.innerHTML = `
`;
setQuiet.mockResolvedValue({
status: "refused",
reason: "Cannot edit timeline while recording",
});
- const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" });
+ const host = render({ ...base, id: "music", tag: "audio", src: "music.mp3" }, "duck");
await clickItem(host, "Duck under voice");
await vi.waitFor(() =>
expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error"),
);
expect(setQuiet).toHaveBeenCalledTimes(1);
expect(showToast).not.toHaveBeenCalledWith(expect.stringContaining("Ducks under"), "info");
- iframe.remove();
+ previewDoc = null;
});
});
diff --git a/packages/studio/src/player/components/clipMenuAudioItems.tsx b/packages/studio/src/player/components/clipMenuAudioItems.tsx
index 0df27dd0f2..5488d84301 100644
--- a/packages/studio/src/player/components/clipMenuAudioItems.tsx
+++ b/packages/studio/src/player/components/clipMenuAudioItems.tsx
@@ -2,6 +2,8 @@ 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,
@@ -11,7 +13,7 @@ import {
VOLUME_LANE_REFUSAL,
volumeLaneOwnsGain,
} from "./clipMenuNormalize";
-import { isDuckableBed, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
+import { offersDuck, readBedCarve, setDuckUnderVoice, type DuckOutcome } from "./clipMenuDuck";
const ITEM_CLASS =
"w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer";
@@ -23,21 +25,28 @@ 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;
}) {
const shell = useStudioShellContextOptional();
- const { onSetElementAttributeQuiet, onGroupClips } = useTimelineEditContextOptional();
- const doc = shell?.previewIframeRef.current?.contentDocument ?? null;
+ const { onSetElementAttributeQuiet, onGroupClips, onNotice } = useTimelineEditContextOptional();
+ const liveIframe = useLivePreviewIframe();
+ const sessionProjectId = usePlayerStore((s) => s.timelineProjectId);
+ const projectId = shell?.projectId ?? sessionProjectId;
+ const showToast = shell?.showToast ?? onNotice;
+ const doc = (shell?.previewIframeRef.current ?? liveIframe)?.contentDocument ?? null;
const bed = doc?.getElementById(element.domId ?? element.id) ?? null;
const ducked = bed ? readBedCarve(bed)?.enabled === true : false;
- if (!clipHasSound(element) || !shell || !onSetElementAttributeQuiet) return null;
- const { showToast, projectId } = shell;
+ if (!clipHasSound(element) || !projectId || !showToast || !onSetElementAttributeQuiet) {
+ return null;
+ }
const normalize = async () => {
onClose();
@@ -84,25 +93,24 @@ export function ClipMenuAudioItems({
showToast(DUCK_TOAST[outcome], outcome === "aborted" ? "error" : "info");
};
- return (
- <>
+ if (part === "normalize") {
+ return (
- {isDuckableBed(bed) && (
-
- )}
-
- >
+ );
+ }
+ if (!offersDuck(doc, bed)) return null;
+ return (
+
);
}
diff --git a/packages/studio/src/player/components/clipMenuDuck.ts b/packages/studio/src/player/components/clipMenuDuck.ts
index 6b8b258ed0..4af9a1ccac 100644
--- a/packages/studio/src/player/components/clipMenuDuck.ts
+++ b/packages/studio/src/player/components/clipMenuDuck.ts
@@ -83,6 +83,11 @@ export function duckVoiceSources(doc: Document, bed: Element): string[] {
.map((candidate) => candidate.id);
}
+export function offersDuck(doc: Document | null, bed: Element | null): boolean {
+ if (!doc || !isDuckableBed(bed)) return false;
+ return readBedCarve(bed)?.enabled === true || duckVoiceSources(doc, bed).length > 0;
+}
+
async function writeMeasuredCarve(
doc: Document,
bed: Element,
diff --git a/packages/studio/src/player/components/clipMenuLinkItems.test.ts b/packages/studio/src/player/components/clipMenuLinkItems.test.ts
index 98ef1c1971..2c94db56b1 100644
--- a/packages/studio/src/player/components/clipMenuLinkItems.test.ts
+++ b/packages/studio/src/player/components/clipMenuLinkItems.test.ts
@@ -32,7 +32,7 @@ describe("resolveLinkMenuItems", () => {
const video = clip("talk", "video", { muted: true, link: "lk-1" });
const audio = clip("talk-audio", "audio", { link: "lk-1" });
expect(labels(audio, [video, audio])).toEqual([
- "Unlink",
+ "Unlink from video",
"Merge audio back into video",
"Delete this clip only",
]);
@@ -42,9 +42,26 @@ describe("resolveLinkMenuItems", () => {
const video = clip("talk", "video", { muted: true });
const audio = clip("talk-audio", "audio");
expect(labels(video, [video, audio], ["talk", "talk-audio"])).toEqual([
- "Link",
+ "Link to audio",
"Merge audio back into video",
]);
+ expect(labels(audio, [video, audio], ["talk", "talk-audio"])[0]).toBe("Link to video");
+ });
+
+ it("names the audio partner when unlinking from the video side", () => {
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
+ expect(labels(video, [video, audio])[0]).toBe("Unlink from audio");
+ });
+
+ it("falls back to plain Unlink for groups of three or same-kind partners", () => {
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
+ const second = clip("talk-audio2", "audio", { link: "lk-1" });
+ expect(labels(video, [video, audio, second])[0]).toBe("Unlink");
+ const twin = clip("talk2", "video", { muted: true, link: "lk-2" });
+ const twin2 = clip("talk3", "video", { muted: true, link: "lk-2" });
+ expect(labels(twin, [twin, twin2])[0]).toBe("Unlink");
});
it("offers nothing for a silent video or without a link handler", () => {
diff --git a/packages/studio/src/player/components/clipMenuLinkItems.tsx b/packages/studio/src/player/components/clipMenuLinkItems.tsx
index a5ca0d0daa..4931b09d7e 100644
--- a/packages/studio/src/player/components/clipMenuLinkItems.tsx
+++ b/packages/studio/src/player/components/clipMenuLinkItems.tsx
@@ -13,6 +13,16 @@ interface LinkMenuItem {
const keyOf = (el: TimelineElement) => el.key ?? el.id;
+const tagOf = (el: TimelineElement) => el.tag.trim().toLowerCase();
+
+function partnerSuffix(element: TimelineElement, others: readonly TimelineElement[]): string {
+ const partners = others.filter((el) => keyOf(el) !== keyOf(element));
+ const [partner] = partners;
+ if (partners.length !== 1 || !partner) return "";
+ const pair = [tagOf(element), tagOf(partner)].sort().join("+");
+ return pair === "audio+video" ? ` ${tagOf(partner)}` : "";
+}
+
/** The link-model items for a clip, in wireframe order (detach · unlink/link · merge · delete-one). */
export function resolveLinkMenuItems(input: {
element: TimelineElement;
@@ -36,13 +46,13 @@ export function resolveLinkMenuItems(input: {
const selected = elements.filter((el) => selectedKeys.has(keyOf(el)));
if (linked) {
items.push({
- label: "Unlink",
+ label: `Unlink${partnerSuffix(element, members) && ` from${partnerSuffix(element, members)}`}`,
shortcut: "⌘L",
run: () => onLinkEdit({ kind: "unlink", elements: members }),
});
} else if (canLinkPair(selected) && selectedKeys.has(keyOf(element))) {
items.push({
- label: "Link",
+ label: `Link${partnerSuffix(element, selected) && ` to${partnerSuffix(element, selected)}`}`,
shortcut: "⌘L",
run: () => onLinkEdit({ kind: "link", elements: selected }),
});
@@ -66,9 +76,11 @@ export function resolveLinkMenuItems(input: {
}
export function ClipMenuLinkItems({
+ part,
element,
onClose,
}: {
+ part: "link" | "delete";
element: TimelineElement;
onClose: () => void;
}) {
@@ -81,7 +93,7 @@ export function ClipMenuLinkItems({
selectedKeys,
onLinkEdit,
onDeleteElementOnly,
- });
+ }).filter((item) => (item.destructive === true) === (part === "delete"));
if (items.length === 0) return null;
return (
<>
@@ -104,7 +116,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
-
>
);
}
diff --git a/packages/studio/src/player/components/clipSync.test.ts b/packages/studio/src/player/components/clipSync.test.ts
new file mode 100644
index 0000000000..9d9fe557e3
--- /dev/null
+++ b/packages/studio/src/player/components/clipSync.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { clipSyncState, syncPartnerOf } from "./clipSync";
+
+const clip = (id: string, tag: string, start: number, extra: Record = {}) => ({
+ id,
+ tag,
+ start,
+ duration: 4,
+ track: 0,
+ syncOrigin: "lk-1",
+ ...extra,
+});
+
+describe("syncPartnerOf", () => {
+ it("pairs a video with the audio sharing its sync origin, not another video", () => {
+ const v = clip("v", "video", 0);
+ const a = clip("a", "audio", 0);
+ const other = clip("v2", "video", 0);
+ expect(syncPartnerOf(a, [v, a, other])?.id).toBe("v");
+ expect(syncPartnerOf(v, [v, a, other])?.id).toBe("a");
+ });
+
+ it("ignores clips with no or another origin", () => {
+ const v = clip("v", "video", 0);
+ expect(syncPartnerOf(v, [v, clip("a", "audio", 0, { syncOrigin: "lk-2" })])).toBeNull();
+ expect(syncPartnerOf({ ...v, syncOrigin: undefined }, [v])).toBeNull();
+ });
+
+ it.each([{ sourceFile: "child.html" }, { compositionScope: "child" }])(
+ "never pairs across compositions reusing an origin (%o)",
+ (scope) => {
+ const v = clip("v", "video", 0);
+ expect(syncPartnerOf(v, [v, clip("a", "audio", 0, scope)])).toBeNull();
+ },
+ );
+
+ it("after a split, picks the partner sharing the most timeline", () => {
+ const a = clip("a", "audio", 4.2);
+ const left = clip("v", "video", 0);
+ const right = clip("v-split", "video", 4);
+ expect(syncPartnerOf(a, [left, right, a])?.id).toBe("v-split");
+ });
+});
+
+describe("clipSyncState", () => {
+ it("is null in sync and signed per side when drifted", () => {
+ const v = clip("v", "video", 1);
+ expect(clipSyncState(v, [v, clip("a", "audio", 1)], 30)).toBeNull();
+ const late = clip("a", "audio", 1 + 10 / 30);
+ expect(clipSyncState(late, [v, late], 30)?.frames).toBe(10);
+ expect(clipSyncState(v, [v, late], 30)?.frames).toBe(-10);
+ });
+
+ it("offers move and slip targets that each restore sync", () => {
+ const v = clip("v", "video", 1, { playbackStart: 0 });
+ const a = clip("a", "audio", 1.5, { playbackStart: 0 });
+ const state = clipSyncState(a, [v, a], 30);
+ expect(state?.moveStart).toBeCloseTo(1);
+ expect(state?.slipMediaStart).toBeCloseTo(0.5);
+ });
+
+ it("has no badge when the halves play at different rates", () => {
+ const v = clip("v", "video", 1, { playbackRate: 2 });
+ const a = clip("a", "audio", 2);
+ expect(clipSyncState(a, [v, a], 30)).toBeNull();
+ });
+});
diff --git a/packages/studio/src/player/components/clipSync.ts b/packages/studio/src/player/components/clipSync.ts
new file mode 100644
index 0000000000..f36705ce9d
--- /dev/null
+++ b/packages/studio/src/player/components/clipSync.ts
@@ -0,0 +1,80 @@
+import {
+ moveIntoSyncStart,
+ slipIntoSyncMediaStart,
+ syncOffsetFrames,
+ type SyncTiming,
+} from "@hyperframes/core/media-link";
+import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
+
+type SyncElement = Pick<
+ TimelineElement,
+ "id" | "key" | "tag" | "start" | "duration" | "playbackStart" | "playbackRate" | "syncOrigin"
+> &
+ Pick;
+
+const keyOf = (el: Pick) => el.key ?? el.id;
+const kindOf = (el: Pick) => el.tag.trim().toLowerCase();
+
+function syncTimingOf(el: SyncElement): SyncTiming {
+ return { start: el.start, mediaStart: el.playbackStart ?? 0, playbackRate: el.playbackRate ?? 1 };
+}
+
+function overlap(a: SyncElement, b: SyncElement): number {
+ return Math.min(a.start + a.duration, b.start + b.duration) - Math.max(a.start, b.start);
+}
+
+/**
+ * The other half of `el`'s source pair: a video for an audio and the reverse,
+ * sharing its sync origin. After splits several may qualify; the one sharing the
+ * most timeline wins, then the nearest start.
+ */
+export function syncPartnerOf(el: T, elements: readonly T[]): T | null {
+ const kind = kindOf(el);
+ if (!el.syncOrigin || (kind !== "video" && kind !== "audio")) return null;
+ const partnerKind = kind === "video" ? "audio" : "video";
+ const candidates = elements.filter(
+ (other) =>
+ keyOf(other) !== keyOf(el) &&
+ other.syncOrigin === el.syncOrigin &&
+ sameCompositionScope(other, el) &&
+ kindOf(other) === partnerKind,
+ );
+ const rank = (other: T) => [overlap(el, other), -Math.abs(other.start - el.start)] as const;
+ let best: T | null = null;
+ for (const candidate of candidates) {
+ if (!best) best = candidate;
+ else {
+ const [o1, d1] = rank(candidate);
+ const [o2, d2] = rank(best);
+ if (o1 > o2 || (o1 === o2 && d1 > d2)) best = candidate;
+ }
+ }
+ return best;
+}
+
+export interface ClipSyncState {
+ partner: T;
+ frames: number;
+ moveStart: number | null;
+ slipMediaStart: number | null;
+}
+
+/** `el`'s drift from its source partner, or null when in sync, unpaired or at a different rate. */
+export function clipSyncState(
+ el: T,
+ elements: readonly T[],
+ fps: number,
+): ClipSyncState | null {
+ const partner = syncPartnerOf(el, elements);
+ if (!partner) return null;
+ const self = syncTimingOf(el);
+ const other = syncTimingOf(partner);
+ const frames = syncOffsetFrames(self, other, fps);
+ if (frames === null || frames === 0) return null;
+ return {
+ partner,
+ frames,
+ moveStart: moveIntoSyncStart(self, other),
+ slipMediaStart: slipIntoSyncMediaStart(self, other),
+ };
+}
diff --git a/packages/studio/src/player/components/linkLabelColor.test.ts b/packages/studio/src/player/components/linkLabelColor.test.ts
new file mode 100644
index 0000000000..e94d49e44f
--- /dev/null
+++ b/packages/studio/src/player/components/linkLabelColor.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { LINK_LABEL_COLORS, linkLabelColor } from "./linkLabelColor";
+
+describe("linkLabelColor", () => {
+ it("has no colour for an unlinked clip", () => {
+ expect(linkLabelColor(undefined)).toBeNull();
+ expect(linkLabelColor("")).toBeNull();
+ });
+
+ it("walks the palette in lk-N order, wrapping", () => {
+ expect(linkLabelColor("lk-1")).toBe(LINK_LABEL_COLORS[0]);
+ expect(linkLabelColor("lk-2")).toBe(LINK_LABEL_COLORS[1]);
+ expect(linkLabelColor(`lk-${LINK_LABEL_COLORS.length + 1}`)).toBe(LINK_LABEL_COLORS[0]);
+ });
+
+ it("is deterministic for hand-written ids", () => {
+ expect(linkLabelColor("talk-pair")).toBe(linkLabelColor("talk-pair"));
+ expect(LINK_LABEL_COLORS).toContain(linkLabelColor("talk-pair"));
+ });
+});
diff --git a/packages/studio/src/player/components/linkLabelColor.ts b/packages/studio/src/player/components/linkLabelColor.ts
new file mode 100644
index 0000000000..58131814e9
--- /dev/null
+++ b/packages/studio/src/player/components/linkLabelColor.ts
@@ -0,0 +1,24 @@
+/** Link-group label colours; teal, violet, white and red belong to clips, selection and the sync badge. */
+export const LINK_LABEL_COLORS = [
+ "var(--color-amber-500)",
+ "var(--color-sky-400)",
+ "var(--color-green-400)",
+ "var(--color-pink-400)",
+ "var(--color-orange-400)",
+ "var(--color-amber-300)",
+] as const;
+
+function stableIndex(link: string): number {
+ const numbered = /^lk-(\d+)$/.exec(link);
+ if (numbered) return Number(numbered[1]) - 1;
+ let hash = 0;
+ for (const char of link) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
+ return hash;
+}
+
+/** The same colour for every member of a link group, from its id alone. */
+export function linkLabelColor(link: string | undefined): string | null {
+ if (!link) return null;
+ const count = LINK_LABEL_COLORS.length;
+ return LINK_LABEL_COLORS[((stableIndex(link) % count) + count) % count] ?? null;
+}
diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts
index 812e6ca022..56b96dacc8 100644
--- a/packages/studio/src/player/components/timelineCallbacks.ts
+++ b/packages/studio/src/player/components/timelineCallbacks.ts
@@ -43,7 +43,9 @@ export type TimelineLinkEdit =
| { kind: "unlink"; elements: readonly TimelineElement[] }
| { kind: "link"; elements: readonly TimelineElement[] }
| { kind: "detach"; element: TimelineElement }
- | { kind: "merge"; video: TimelineElement; audio: TimelineElement };
+ | { kind: "merge"; video: TimelineElement; audio: TimelineElement }
+ | { kind: "move-into-sync"; element: TimelineElement; start: number }
+ | { kind: "slip-into-sync"; element: TimelineElement; mediaStart: number };
export interface TimelineEditCallbacks {
onMoveElement?: (
@@ -127,6 +129,7 @@ export interface TimelineEditCallbacks {
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise | void;
onRazorSplitAll?: (splitTime: number) => Promise | void;
onFreezeFrame?: (element: TimelineElement, time: number) => Promise | void;
+ onNotice?: (message: string, tone?: "error" | "info") => void;
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
diff --git a/packages/studio/src/player/components/timelineLinkSelection.ts b/packages/studio/src/player/components/timelineLinkSelection.ts
index 1ed00acc8c..231ee2df94 100644
--- a/packages/studio/src/player/components/timelineLinkSelection.ts
+++ b/packages/studio/src/player/components/timelineLinkSelection.ts
@@ -1,9 +1,10 @@
import { usePlayerStore } from "../store/playerStore";
import { expandToLinkedMembers } from "./audioClipLink";
+import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
/**
* Select `key` the way a clip click does, then widen to link partners; Alt
- * selects `key` alone even when it sat inside a larger selection.
+ * (or Linked Selection off) selects `key` alone, even inside a larger selection.
*/
export function selectClipWithLinks(
key: string,
@@ -11,7 +12,7 @@ export function selectClipWithLinks(
setSelectedElementId: (id: string) => void,
): void {
const state = usePlayerStore.getState();
- if (altKey) {
+ if (altKey || !isLinkedSelectionOn()) {
state.setSelection([key], key);
return;
}
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts
index 41a21dd815..327e1ca247 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.ts
+++ b/packages/studio/src/player/components/useTimelineClipDrag.ts
@@ -28,6 +28,7 @@ import type {
} from "./timelineClipDragTypes";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { linkedGestureKeys } from "./audioClipLink";
+import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
import type { TimelineRowGeometry } from "./timelineLayout";
import {
mountTimelineClipDragGestureLifecycle,
@@ -228,6 +229,7 @@ export function useTimelineClipDrag({
next.element,
elementsRef.current,
next.altKey === true,
+ isLinkedSelectionOn(),
);
publishDraggedClip(next);
},
@@ -245,6 +247,7 @@ export function useTimelineClipDrag({
next.element,
elementsRef.current,
next.altKey === true,
+ isLinkedSelectionOn(),
);
publishResizingClip(next);
},
diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.test.ts b/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
index 1ced1ee283..1f0df8de61 100644
--- a/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
+++ b/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
@@ -13,9 +13,15 @@ describe("timelineElementsChanged", () => {
["audio group hidden", { audioGroupHidden: true }],
["fade in", { fadeIn: 1 }],
["fade out", { fadeOut: 1 }],
+ ["link", { link: "lk-1" }],
["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
])("sees a clip whose %s changed with no timing change", (_name, change) => {
expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
});
+
+ it("sees an unlink: the same clips re-derived without their link", () => {
+ const linked = { ...clip, link: "lk-1" };
+ expect(timelineElementsChanged([linked], [clip])).toBe(true);
+ });
});
diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.ts b/packages/studio/src/player/hooks/timelinePlayerSync.ts
index 6991979acc..4bba7f1572 100644
--- a/packages/studio/src/player/hooks/timelinePlayerSync.ts
+++ b/packages/studio/src/player/hooks/timelinePlayerSync.ts
@@ -16,6 +16,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
"fadeIn",
"fadeOut",
"src",
+ "link",
];
/** Whether a derived timeline changes any field that affects rendering. */
diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts
index 089312cf4c..e0231bf32b 100644
--- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts
+++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts
@@ -15,6 +15,7 @@ import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
+import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
import {
buildTimelineElementsFromClips,
syncManifestTimeline,
@@ -196,8 +197,10 @@ export function useTimelineSyncCallbacks({
// at the authored root `data-duration` so a runtime that measures only the
// furthest clip end (shorter than the authored window) can't leave a stale,
// too-short total in the transport (the "0:44/0:40" bug).
+ const fps = acceptedRuntimeMessageFps(data);
+ useLinkedClipPreferences.getState().setCompositionFps(fps);
const newDuration = resolveTimelineTotalDuration({
- manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
+ manifestDurationSeconds: data.durationInFrames / fps,
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
});
syncManifestTimeline(
diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts
index f0b03876ef..3b5790b1e0 100644
--- a/packages/studio/src/player/lib/timelineDOM.test.ts
+++ b/packages/studio/src/player/lib/timelineDOM.test.ts
@@ -282,13 +282,15 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
expect(element.hidden).toBe(true);
});
- it("reads data-link on both the DOM and manifest paths", () => {
+ it("reads data-link and data-sync-origin on both the DOM and manifest paths", () => {
const doc = makeDoc(`
-
+
`);
- expect(parseTimelineFromDOM(doc, 10).find((el) => el.domId === "talk")?.link).toBe("lk-1");
+ const fromDom = parseTimelineFromDOM(doc, 10).find((el) => el.domId === "talk");
+ expect(fromDom?.link).toBe("lk-1");
+ expect(fromDom?.syncOrigin).toBe("lk-1");
const element = createTimelineElementFromManifestClip({
clip: {
id: "talk",
@@ -308,6 +310,7 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
hostEl: doc.getElementById("talk"),
});
expect(element.link).toBe("lk-1");
+ expect(element.syncOrigin).toBe("lk-1");
});
});
diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts
index 1efa59e438..23b8b68ff9 100644
--- a/packages/studio/src/player/lib/timelineDOM.ts
+++ b/packages/studio/src/player/lib/timelineDOM.ts
@@ -155,6 +155,8 @@ export function createTimelineElementFromManifestClip(params: {
if (link) entry.link = link;
const compositionScope = linkScopeOf(hostEl)?.getAttribute("data-composition-id");
if (compositionScope) entry.compositionScope = compositionScope;
+ const syncOrigin = hostEl.getAttribute("data-sync-origin");
+ if (syncOrigin) entry.syncOrigin = syncOrigin;
const audioGroup = hostEl.getAttribute("data-audio-group");
if (audioGroup) {
entry.audioGroup = audioGroup;
@@ -346,6 +348,8 @@ export function parseTimelineFromDOM(
if (domLink) entry.link = domLink;
const domCompositionScope = linkScopeOf(el)?.getAttribute("data-composition-id");
if (domCompositionScope) entry.compositionScope = domCompositionScope;
+ const domSyncOrigin = el.getAttribute("data-sync-origin");
+ if (domSyncOrigin) entry.syncOrigin = domSyncOrigin;
const domAudioGroup = el.getAttribute("data-audio-group");
if (domAudioGroup) {
diff --git a/packages/studio/src/player/store/timelineElement.ts b/packages/studio/src/player/store/timelineElement.ts
index 90f7a85206..33b612c145 100644
--- a/packages/studio/src/player/store/timelineElement.ts
+++ b/packages/studio/src/player/store/timelineElement.ts
@@ -85,6 +85,7 @@ export interface TimelineElement {
audioGroupAutomation?: string;
link?: string;
compositionScope?: string;
+ syncOrigin?: string;
/**
* Master start of the composition this row runs in, which its tweens and its
* `data-start` are local to; 0 at the root. Writes go through toAuthoredStart.
diff --git a/packages/studio/src/styles/components.css b/packages/studio/src/styles/components.css
index 72af960356..f08933159c 100644
--- a/packages/studio/src/styles/components.css
+++ b/packages/studio/src/styles/components.css
@@ -296,17 +296,6 @@
transition: none;
}
-.timeline-audio-link {
- position: absolute;
- z-index: 4;
- top: -9px;
- left: 50%;
- width: 1px;
- height: 10px;
- background: var(--timeline-audio-link);
- pointer-events: none;
-}
-
.timeline-clip.is-hovered {
background-color: var(--clip-bg-hover);
}
@@ -362,6 +351,16 @@
white-space: nowrap;
}
+/* Link groups share a label colour on the border and the name chip. */
+.timeline-clip[data-link-color] {
+ border-color: var(--clip-link-color);
+}
+
+.timeline-clip[data-link-color] .timeline-clip__label {
+ box-shadow: inset 3px 0 0 var(--clip-link-color);
+ padding-left: 8px;
+}
+
.timeline-clip.is-audio .timeline-clip__label {
top: 11px;
left: 9px;
diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css
index 6486f033fa..bb5a48cfd6 100644
--- a/packages/studio/src/styles/theme.css
+++ b/packages/studio/src/styles/theme.css
@@ -117,7 +117,6 @@
--timeline-clip-audio-bg-hover: oklch(0.49 0.12 300);
--timeline-clip-audio-bg-dragging: oklch(0.42 0.12 300 / 0.96);
--timeline-clip-audio-muted-bg: rgb(72, 74, 78);
- --timeline-audio-link: light-dark(#626d66, #a0aaa4);
--timeline-clip-selection: light-dark(#0f766e, rgba(255, 255, 255, 0.85));
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
--timeline-clip-radius: 8px;
diff --git a/packages/studio/src/utils/linkedClipPreferences.ts b/packages/studio/src/utils/linkedClipPreferences.ts
new file mode 100644
index 0000000000..acb8b99a7c
--- /dev/null
+++ b/packages/studio/src/utils/linkedClipPreferences.ts
@@ -0,0 +1,34 @@
+import { create } from "zustand";
+import { STUDIO_PREVIEW_FPS } from "../player/lib/time";
+import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
+
+interface LinkedClipPreferences {
+ /** Premiere's Linked Selection: clicks and edits reach every link partner. */
+ linkedSelection: boolean;
+ setLinkedSelection: (enabled: boolean) => void;
+ /** Red offset badges on a video and audio from one source that drifted apart. */
+ syncIndicatorsVisible: boolean;
+ setSyncIndicatorsVisible: (visible: boolean) => void;
+ compositionFps: number;
+ setCompositionFps: (fps: number) => void;
+}
+
+export const useLinkedClipPreferences = create((set) => ({
+ linkedSelection: readStudioUiPreferences().linkedSelectionEnabled ?? true,
+ setLinkedSelection: (enabled) => {
+ writeStudioUiPreferences({ linkedSelectionEnabled: enabled });
+ set({ linkedSelection: enabled });
+ },
+ syncIndicatorsVisible: readStudioUiPreferences().syncIndicatorsVisible ?? true,
+ setSyncIndicatorsVisible: (visible) => {
+ writeStudioUiPreferences({ syncIndicatorsVisible: visible });
+ set({ syncIndicatorsVisible: visible });
+ },
+ compositionFps: STUDIO_PREVIEW_FPS,
+ setCompositionFps: (fps) => {
+ if (Number.isFinite(fps) && fps > 0) set({ compositionFps: fps });
+ },
+}));
+
+export const isLinkedSelectionOn = (): boolean =>
+ useLinkedClipPreferences.getState().linkedSelection;
diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts
index 4e1fdc9f99..4c72902705 100644
--- a/packages/studio/src/utils/studioUiPreferences.ts
+++ b/packages/studio/src/utils/studioUiPreferences.ts
@@ -45,6 +45,8 @@ export interface StudioUiPreferences {
theme?: StudioTheme;
/** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
dockLayout?: SerializedDockview;
+ linkedSelectionEnabled?: boolean;
+ syncIndicatorsVisible?: boolean;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -146,6 +148,12 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
if (typeof parsed.agentToolsEnabled === "boolean") {
preferences.agentToolsEnabled = parsed.agentToolsEnabled;
}
+ if (typeof parsed.linkedSelectionEnabled === "boolean") {
+ preferences.linkedSelectionEnabled = parsed.linkedSelectionEnabled;
+ }
+ if (typeof parsed.syncIndicatorsVisible === "boolean") {
+ preferences.syncIndicatorsVisible = parsed.syncIndicatorsVisible;
+ }
const dockLayout = parseDockLayout(parsed.dockLayout);
if (dockLayout) preferences.dockLayout = dockLayout;
return preferences;
diff --git a/skills-manifest.json b/skills-manifest.json
index f380caf3df..bfe3e58016 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
- "hash": "3bc49ffe4697122b",
+ "hash": "539de2110ccdaa43",
"files": 11
},
"hyperframes-creative": {
diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md
index 7949c27dd8..83d9943707 100644
--- a/skills/hyperframes-core/references/data-attributes.md
+++ b/skills/hyperframes-core/references/data-attributes.md
@@ -36,6 +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 `