diff --git a/packages/cli/src/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts index ec0e22e15d..94538fd178 100644 --- a/packages/cli/src/timeline/describeProject.test.ts +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -116,6 +116,20 @@ describe("describeProject", () => { ]); }); + it("reports each half's out-of-sync offset from its source pair, at the project fps", async () => { + const { rows, text } = await rowsOf(` +
+ + + +
`); + const offset = (id: string) => rows.find((r) => r.id === id)?.syncOffsetFrames; + expect(offset("a")).toBe(12); + expect(offset("v")).toBe(-12); + expect(offset("m")).toBeNull(); + expect(text).toContain("out-of-sync=+12f"); + }); + it("reports unreadable automation instead of showing no lanes", async () => { const bad = (await describeProject(project())).tracks .flatMap((t) => t.rows) diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index 988138f027..1475a6e7b9 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -8,7 +8,9 @@ import { import { HF_AUDIO_FX_ATTR, parseAudioFxChain } from "@hyperframes/core/audio-fx"; import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups"; import { byStart, type ClipFact, type ClipLane } from "@hyperframes/core/clip-facts"; -import { parseNumeric } from "@hyperframes/core"; +import { fpsToNumber, parseFpsWithDefault, parseNumeric } from "@hyperframes/core"; +import { findSyncPartner, readLinkTiming, syncOffsetFrames } from "@hyperframes/core/media-link"; +import { readCompositionFps } from "../utils/compositionFps.js"; import { readMediaOffsetSeconds, readPlaybackRate, @@ -60,6 +62,8 @@ export interface TimelineRow extends ClipFact { hostRow: RowPointer | null; /** Where this row's sub-composition clips are (one level only); each is a full row in its kind's `rows`. */ children: RowPointer[]; + /** Signed frames from its `data-sync-origin` partner (+ = late); `null` unpaired or rates differ. */ + syncOffsetFrames: number | null; } export interface RowPointer { @@ -137,6 +141,7 @@ interface DocScope { withProbeSlot: (fn: () => Promise) => Promise; measure: MeasureMedia; sourceOverrides: ReadonlyMap; + fps: number; } /** Source length in seconds of a media file. ffprobe in production; tests pass a recorded fake. */ @@ -312,9 +317,15 @@ function describeRowFields(scope: DocScope, node: DomNode, timing: RowTiming): C warnings: [], elementId: el.id || null, hfId: el.getAttribute("data-hf-id"), + syncOffsetFrames: syncOffsetOf(el, scope.fps), }; } +function syncOffsetOf(el: Element, fps: number): number | null { + const partner = findSyncPartner(el); + return partner ? syncOffsetFrames(readLinkTiming(el), readLinkTiming(partner), fps) : null; +} + async function describeRow(scope: DocScope, node: DomNode, depth: number): Promise { return describeRowFields(scope, node, await resolveRowTiming(scope, node, depth)); } @@ -343,6 +354,7 @@ async function readSubComposition( withProbeSlot: parent.withProbeSlot, measure: parent.measure, sourceOverrides: parent.sourceOverrides, + fps: parent.fps, }; const rows = await Promise.all( topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)), @@ -416,6 +428,11 @@ function flatten(top: ClipDraft[]): TimelineTrack[] { })); } +function projectFps(source: string): number { + const parsed = parseFpsWithDefault(readCompositionFps(source) ?? undefined); + return fpsToNumber(parsed.ok ? parsed.value : { num: 30, den: 1 }); +} + /** Needs a global DOMParser (`ensureDOMParser`). Reads `index.html` and one level of sub-compositions. */ export async function describeProject( indexPath: string, @@ -437,6 +454,7 @@ export async function describeProject( withProbeSlot: createProbeGate(PROBE_CONCURRENCY), measure, sourceOverrides, + fps: projectFps(source), }; const rows = ( await Promise.all(topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 0))) diff --git a/packages/cli/src/timeline/formatTimeline.ts b/packages/cli/src/timeline/formatTimeline.ts index c412feb6fd..2b8b0862c4 100644 --- a/packages/cli/src/timeline/formatTimeline.ts +++ b/packages/cli/src/timeline/formatTimeline.ts @@ -29,6 +29,11 @@ function durationNote(row: TimelineRow): string | false { } } +function syncNote(frames: number | null): string | false { + if (!frames) return false; + return `out-of-sync=${frames > 0 ? "+" : ""}${frames}f`; +} + function details(row: TimelineRow): string { const lanes = row.lanes.map( (l) => `${l.target}[${l.points.map((p) => `${n(p.t)}:${n(p.v)}`).join(" ")}]`, @@ -38,6 +43,7 @@ function details(row: TimelineRow): string { row.volume !== null && `vol=${row.volume}`, row.playbackRate !== null && `rate=${n(row.playbackRate)}`, row.audioGroup && `group=${row.audioGroup}`, + syncNote(row.syncOffsetFrames), durationNote(row), row.sourceFile && !row.children.length && "children=unread", row.laneError && `lanes unreadable: ${row.laneError}`, diff --git a/packages/cli/src/timeline/timeExpr.test.ts b/packages/cli/src/timeline/timeExpr.test.ts index 99c51e634e..31b7730742 100644 --- a/packages/cli/src/timeline/timeExpr.test.ts +++ b/packages/cli/src/timeline/timeExpr.test.ts @@ -32,6 +32,7 @@ const row: TimelineRow = { durationSource: "authored", pendingReason: null, laneError: null, + syncOffsetFrames: null, }; describe("parseTimeExpression", () => { diff --git a/packages/core/src/mediaLink.test.ts b/packages/core/src/mediaLink.test.ts index 568c7f930d..c8465b6f97 100644 --- a/packages/core/src/mediaLink.test.ts +++ b/packages/core/src/mediaLink.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { MEDIA_LINK_ATTR, + SYNC_ORIGIN_ATTR, + findSyncPartner, + formatSyncOffset, linkTimingMismatches, + moveIntoSyncStart, + slipIntoSyncMediaStart, + sourceZeroTime, + syncOffsetFrames, mintLinkId, readLinkTiming, relinkSplitHalves, @@ -24,6 +31,11 @@ describe("readLinkTiming", () => { it("reads data-playback-start as the media-start alias", () => { expect(readLinkTiming(attrs({ "data-playback-start": "1.5" })).mediaStart).toBe(1.5); }); + + it("lets data-playback-start win over data-media-start, as playback does", () => { + const both = attrs({ "data-playback-start": "0", "data-media-start": "0.5" }); + expect(readLinkTiming(both).mediaStart).toBe(0); + }); }); describe("linkTimingMismatches", () => { @@ -86,6 +98,17 @@ describe("relinkSplitHalves", () => { expect(link("i-split")).toBeNull(); }); + it("keeps the source origin when only one member of an unlinked pair is cut", () => { + const d = doc(` + + + `); + relinkSplitHalves(d, ["a-split"]); + const right = d.getElementById("a-split"); + expect(right?.getAttribute(SYNC_ORIGIN_ATTR)).toBe("lk-1"); + expect(right && findSyncPartner(right)?.id).toBe("v"); + }); + it("mints distinct ids for distinct groups", () => { const d = doc(` @@ -97,3 +120,100 @@ describe("relinkSplitHalves", () => { expect(ids).not.toContain("lk-7"); }); }); + +describe("relinkSplitHalves sync origin", () => { + it("gives right halves their own shared sync origin, even when unlinked", () => { + document.body.innerHTML = ` + + `; + relinkSplitHalves(document, ["v2", "a2"]); + const origin = (id: string) => document.getElementById(id)?.getAttribute(SYNC_ORIGIN_ATTR); + expect(origin("v")).toBe("lk-1"); + expect(origin("a")).toBe("lk-1"); + expect(origin("v2")).not.toBe("lk-1"); + expect(origin("v2")).toBe(origin("a2")); + }); +}); + +describe("sync offset", () => { + const clip = (start: number, mediaStart = 0, playbackRate = 1) => ({ + start, + mediaStart, + playbackRate, + }); + + it("places source zero at start minus media-start over rate", () => { + expect(sourceZeroTime(clip(4, 2, 2))).toBe(3); + }); + + it("is zero for a pair in sync, whatever their shared media start", () => { + expect(syncOffsetFrames(clip(2, 1), clip(2, 1), 30)).toBe(0); + expect(syncOffsetFrames(clip(3, 2), clip(2, 1), 30)).toBe(0); + }); + + it("is signed from the clip's side: late is positive, and the partner reads the negation", () => { + expect(syncOffsetFrames(clip(2 + 10 / 30), clip(2), 30)).toBe(10); + expect(syncOffsetFrames(clip(2), clip(2 + 10 / 30), 30)).toBe(-10); + }); + + it("counts media start: a slipped clip is out of sync in place", () => { + expect(syncOffsetFrames(clip(2, 0.5), clip(2, 0), 30)).toBe(-15); + }); + + it("divides media start by the shared rate", () => { + expect(syncOffsetFrames(clip(2, 1, 2), clip(2, 0, 2), 30)).toBe(-15); + }); + + it("has no offset when the rates differ", () => { + expect(syncOffsetFrames(clip(2, 0, 1.5), clip(2, 0, 1), 30)).toBeNull(); + }); + + it("formats frames, then seconds:frames past one second", () => { + expect(formatSyncOffset(10, 30)).toBe("+10"); + expect(formatSyncOffset(-51, 30)).toBe("-1:21"); + expect(formatSyncOffset(30, 30)).toBe("+1:00"); + expect(formatSyncOffset(-3, 24)).toBe("-3"); + }); + + it("moves the clip onto its partner, or refuses before zero", () => { + expect(moveIntoSyncStart(clip(2.5, 0.5), clip(1, 0))).toBeCloseTo(1.5); + expect(syncOffsetFrames(clip(1.5, 0.5), clip(1, 0), 30)).toBe(0); + expect(moveIntoSyncStart(clip(0, 2), clip(0, 0))).toBeCloseTo(2); + expect(moveIntoSyncStart(clip(1, 0), clip(0, 2))).toBeNull(); + }); + + it("slips the clip's media in place, scaled by rate, or refuses before the file start", () => { + expect(slipIntoSyncMediaStart(clip(3, 0), clip(2, 0))).toBeCloseTo(1); + expect(slipIntoSyncMediaStart(clip(3, 0, 2), clip(2, 0, 2))).toBeCloseTo(2); + expect(syncOffsetFrames(clip(3, 2, 2), clip(2, 0, 2), 30)).toBe(0); + expect(slipIntoSyncMediaStart(clip(1, 0), clip(2, 0))).toBeNull(); + }); +}); + +describe("findSyncPartner", () => { + it("finds the opposite kind sharing the origin, preferring the most shared timeline", () => { + document.body.innerHTML = `
+ + + +
`; + const byId = (id: string) => document.getElementById(id); + const a = byId("a"); + const x = byId("x"); + expect(a && findSyncPartner(a)?.id).toBe("v2"); + expect(x && findSyncPartner(x)).toBeNull(); + }); + + it.each(['data-composition-id="child"', 'data-composition-file="child.html"'])( + "never takes a partner from a nested composition (%s) reusing the origin", + (host) => { + document.body.innerHTML = `
+ +
+ +
`; + const v = document.getElementById("v"); + expect(v && findSyncPartner(v)).toBeNull(); + }, + ); +}); diff --git a/packages/core/src/mediaLink.ts b/packages/core/src/mediaLink.ts index a74e24b224..7cdf736e5d 100644 --- a/packages/core/src/mediaLink.ts +++ b/packages/core/src/mediaLink.ts @@ -1,3 +1,5 @@ +import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; + /** * Linked clips: members sharing a `data-link` id are edited as one (a detached * video + its audio). An editing contract only; playback and render ignore it. @@ -38,7 +40,7 @@ export function readLinkTiming(el: AttributeReader): LinkTiming { return { start: readNumber(el, ["data-start"], 0), duration: readNumber(el, ["data-duration"], 0), - mediaStart: readNumber(el, ["data-media-start", "data-playback-start"], 0), + mediaStart: readMediaOffsetSeconds((name) => el.getAttribute(name)), playbackRate: readNumber(el, ["data-playback-rate"], 1), }; } @@ -53,9 +55,11 @@ export function linkTimingMismatches(members: readonly AttributeReader[]): strin ).map(([, field]) => field); } +const LINK_SCOPE_SELECTOR = "[data-composition-id], [data-composition-file]"; + /** The composition a link group stays inside: the nearest inline composition or file host. */ export function linkScopeOf(el: Element): Element | null { - return el.parentElement?.closest("[data-composition-id], [data-composition-file]") ?? null; + return el.parentElement?.closest(LINK_SCOPE_SELECTOR) ?? null; } export function mintLinkId(taken: Iterable): string { @@ -66,30 +70,138 @@ export function mintLinkId(taken: Iterable): string { } function takenLinkIds(doc: Document): string[] { - return Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`)).flatMap((el) => [ + return Array.from( + doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}], [${SYNC_ORIGIN_ATTR}]`), + ).flatMap((el) => [ el.id, el.getAttribute(MEDIA_LINK_ATTR) ?? "", + el.getAttribute(SYNC_ORIGIN_ATTR) ?? "", ]); } -/** - * After a split, the right halves are clones still carrying the left halves' - * link id. Give each group's right halves one fresh shared id, so each half of - * a linked pair is its own pair. - */ -export function relinkSplitHalves(doc: Document, rightHalfIds: readonly string[]): void { - const taken = new Set(takenLinkIds(doc)); +function renameShared( + doc: Document, + ids: readonly string[], + attr: string, + taken: Set, +): void { const renamed = new Map(); - for (const id of rightHalfIds) { + for (const id of ids) { const el = doc.getElementById(id); - const oldLink = el?.getAttribute(MEDIA_LINK_ATTR); - if (!el || !oldLink) continue; - let fresh = renamed.get(oldLink); + const old = el?.getAttribute(attr); + if (!el || !old) continue; + let fresh = renamed.get(old); if (!fresh) { fresh = mintLinkId(taken); taken.add(fresh); - renamed.set(oldLink, fresh); + renamed.set(old, fresh); + } + el.setAttribute(attr, fresh); + } +} + +/** Split right halves get a fresh link id; a sync origin is reminted only when video and audio were both cut. */ +export function relinkSplitHalves(doc: Document, rightHalfIds: readonly string[]): void { + const taken = new Set(takenLinkIds(doc)); + renameShared(doc, rightHalfIds, MEDIA_LINK_ATTR, taken); + renameShared(doc, idsWithBothSidesCut(doc, rightHalfIds), SYNC_ORIGIN_ATTR, taken); +} + +function idsWithBothSidesCut(doc: Document, rightHalfIds: readonly string[]): string[] { + const tagsByOrigin = new Map>(); + const originOf = (id: string) => doc.getElementById(id)?.getAttribute(SYNC_ORIGIN_ATTR) ?? null; + for (const id of rightHalfIds) { + const origin = originOf(id); + const tag = doc.getElementById(id)?.tagName.toLowerCase(); + if (!origin || !tag) continue; + tagsByOrigin.set(origin, (tagsByOrigin.get(origin) ?? new Set()).add(tag)); + } + return rightHalfIds.filter((id) => { + const tags = tagsByOrigin.get(originOf(id) ?? ""); + return tags?.has("video") === true && tags.has("audio"); + }); +} + +/** Shared by a detached or linked video + audio of one file; it survives Unlink so a drifted pair can resync. */ +export const SYNC_ORIGIN_ATTR = "data-sync-origin"; + +export type SyncTiming = Pick; + +const rateOf = (timing: SyncTiming) => (timing.playbackRate > 0 ? timing.playbackRate : 1); + +/** Where source time zero lands on the timeline: `start − mediaStart / rate`. */ +export function sourceZeroTime(timing: SyncTiming): number { + return timing.start - timing.mediaStart / rateOf(timing); +} + +/** Frames `clip` sits from `partner`, positive when it plays late; null when their rates differ. */ +export function syncOffsetFrames( + clip: SyncTiming, + partner: SyncTiming, + fps: number, +): number | null { + if (Math.abs(rateOf(clip) - rateOf(partner)) > 1e-6) return null; + const frames = Math.round((sourceZeroTime(clip) - sourceZeroTime(partner)) * fps); + return Object.is(frames, -0) ? 0 : frames; +} + +/** `+10`, `-1:21` (seconds:frames once past one second). */ +export function formatSyncOffset(frames: number, fps: number): string { + const sign = frames < 0 ? "-" : "+"; + const whole = Math.abs(frames); + const perSecond = Math.max(1, Math.round(fps)); + if (whole < perSecond) return `${sign}${whole}`; + const seconds = Math.floor(whole / perSecond); + const rest = String(whole % perSecond).padStart(2, "0"); + return `${sign}${seconds}:${rest}`; +} + +/** Move into Sync: the start that puts `clip` back on `partner`, or null before zero. */ +export function moveIntoSyncStart(clip: SyncTiming, partner: SyncTiming): number | null { + const start = clip.start - (sourceZeroTime(clip) - sourceZeroTime(partner)); + return start >= -SYNC_TOLERANCE_S ? Math.max(0, start) : null; +} + +/** Slip into Sync: the media start that syncs `clip` in place, or null before the file's start. */ +export function slipIntoSyncMediaStart(clip: SyncTiming, partner: SyncTiming): number | null { + const mediaStart = (clip.start - sourceZeroTime(partner)) * rateOf(clip); + return mediaStart >= -SYNC_TOLERANCE_S ? Math.max(0, mediaStart) : null; +} + +interface PairableElement extends AttributeReader { + tagName: string; + closest(selectors: string): Element | null; + ownerDocument: Document; +} + +function sharedSeconds(a: LinkTiming, b: LinkTiming): number { + return Math.min(a.start + a.duration, b.start + b.duration) - Math.max(a.start, b.start); +} + +/** `el`'s source partner (same sync origin, other tag): most shared timeline, then nearest start. */ +function sameOriginInScope(el: PairableElement, tag: string, origin: string): Element[] { + const scopeOf = (node: PairableElement) => node.closest(LINK_SCOPE_SELECTOR) ?? el.ownerDocument; + const scope = scopeOf(el); + return Array.from(scope.querySelectorAll(tag)).filter( + (candidate) => + candidate.getAttribute(SYNC_ORIGIN_ATTR) === origin && scopeOf(candidate) === scope, + ); +} + +export function findSyncPartner(el: PairableElement): Element | null { + const origin = el.getAttribute(SYNC_ORIGIN_ATTR); + const tag = el.tagName.toLowerCase(); + if (!origin || (tag !== "video" && tag !== "audio")) return null; + const partnerTag = tag === "video" ? "audio" : "video"; + const own = readLinkTiming(el); + let best: { el: Element; shared: number; distance: number } | null = null; + for (const candidate of sameOriginInScope(el, partnerTag, origin)) { + const timing = readLinkTiming(candidate); + const shared = sharedSeconds(own, timing); + const distance = Math.abs(timing.start - own.start); + if (!best || shared > best.shared || (shared === best.shared && distance < best.distance)) { + best = { el: candidate, shared, distance }; } - el.setAttribute(MEDIA_LINK_ATTR, fresh); } + return best?.el ?? null; } diff --git a/packages/lint/src/rules/linkedClips.ts b/packages/lint/src/rules/linkedClips.ts index 90c5a57c23..49ff9cdb4c 100644 --- a/packages/lint/src/rules/linkedClips.ts +++ b/packages/lint/src/rules/linkedClips.ts @@ -6,7 +6,7 @@ const SYNC_TOLERANCE_S = 1e-3; const LINKED_TIMING_FIELDS: ReadonlyArray<{ field: string; attrs: string[]; fallback: number }> = [ { field: "start", attrs: ["data-start"], fallback: 0 }, { field: "duration", attrs: ["data-duration"], fallback: 0 }, - { field: "media-start", attrs: ["data-media-start", "data-playback-start"], fallback: 0 }, + { field: "media-start", attrs: ["data-playback-start", "data-media-start"], fallback: 0 }, { field: "playback-rate", attrs: ["data-playback-rate"], fallback: 1 }, ]; diff --git a/packages/sdk/src/engine/syncTiming.ts b/packages/sdk/src/engine/syncTiming.ts new file mode 100644 index 0000000000..cb7ef6a314 --- /dev/null +++ b/packages/sdk/src/engine/syncTiming.ts @@ -0,0 +1,50 @@ +import { + findSyncPartner, + moveIntoSyncStart, + readLinkTiming, + slipIntoSyncMediaStart, + syncOffsetFrames, +} from "@hyperframes/core/media-link"; +import type { HfId } from "../types.js"; +import { resolveScoped } from "./model.js"; + +export type SyncFix = + | { kind: "move"; start: number } + | { kind: "slip"; name: "data-media-start" | "data-playback-start"; value: string }; + +function pairOf(document: Document, id: HfId): { own: Element; partner: Element } | null { + const own = resolveScoped(document, id); + const partner = own ? findSyncPartner(own) : null; + return own && partner ? { own, partner } : null; +} + +/** Frames `id` sits from its source partner; null when unpaired or at another rate. */ +export function syncOffsetOf(document: Document, id: HfId, fps: number): number | null { + const pair = pairOf(document, id); + if (!pair) return null; + return syncOffsetFrames(readLinkTiming(pair.own), readLinkTiming(pair.partner), fps); +} + +const formatSeconds = (value: number) => String(Number(value.toFixed(6))); + +/** The one attribute write that brings `id` back into sync, by moving or slipping it. */ +export function syncFixFor(document: Document, id: HfId, mode: "move" | "slip"): SyncFix { + const pair = pairOf(document, id); + if (!pair) throw new Error(`${id} has no source partner (data-sync-origin)`); + const own = readLinkTiming(pair.own); + const partner = readLinkTiming(pair.partner); + if (syncOffsetFrames(own, partner, 1000) === null) { + throw new Error(`${id} and its partner play at different rates; no single sync exists`); + } + if (mode === "move") { + const start = moveIntoSyncStart(own, partner); + if (start === null) throw new Error(`moving ${id} into sync would put it before 0`); + return { kind: "move", start: Number(start.toFixed(6)) }; + } + const mediaStart = slipIntoSyncMediaStart(own, partner); + if (mediaStart === null) throw new Error(`slipping ${id} into sync would start before its file`); + const name = pair.own.hasAttribute("data-playback-start") + ? "data-playback-start" + : "data-media-start"; + return { kind: "slip", name, value: formatSeconds(mediaStart) }; +} diff --git a/packages/sdk/src/session.sync.test.ts b/packages/sdk/src/session.sync.test.ts new file mode 100644 index 0000000000..b9121c77fe --- /dev/null +++ b/packages/sdk/src/session.sync.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; +import { openComposition } from "./session.js"; + +const DRIFTED = ` +
+ + +
+`.trim(); + +function attr(html: string, hfId: string, name: string): string | null { + const tag = new RegExp(`<[^>]*data-hf-id="${hfId}"[^>]*>`).exec(html)?.[0] ?? ""; + return new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1] ?? null; +} + +describe("sync origin on the SDK", () => { + it("reports each half's signed offset in frames", async () => { + const comp = await openComposition(DRIFTED); + expect(comp.syncOffset("hf-talk-audio")).toBe(15); + expect(comp.syncOffset("hf-talk", 24)).toBe(-12); + }); + + it("moveIntoSync moves that clip alone, and undo restores it", async () => { + const comp = await openComposition(DRIFTED); + comp.moveIntoSync("hf-talk-audio"); + expect(attr(comp.serialize(), "hf-talk-audio", "data-start")).toBe("2"); + expect(attr(comp.serialize(), "hf-talk", "data-start")).toBe("2"); + expect(comp.syncOffset("hf-talk-audio")).toBe(0); + comp.undo(); + expect(attr(comp.serialize(), "hf-talk-audio", "data-start")).toBe("2.5"); + }); + + it("slipIntoSync keeps the start and slips the media", async () => { + const comp = await openComposition(DRIFTED); + comp.slipIntoSync("hf-talk-audio"); + const html = comp.serialize(); + expect(attr(html, "hf-talk-audio", "data-start")).toBe("2.5"); + expect(attr(html, "hf-talk-audio", "data-media-start")).toBe("0.5"); + expect(comp.syncOffset("hf-talk-audio")).toBe(0); + }); + + it("slips the attribute playback reads when both in-point attributes are authored", async () => { + const both = DRIFTED.replace( + 'data-media-start="0"', + 'data-playback-start="0" data-media-start="0"', + ); + const comp = await openComposition(both); + comp.slipIntoSync("hf-talk-audio"); + const html = comp.serialize(); + const played = readMediaOffsetSeconds((name) => attr(html, "hf-talk-audio", name)); + expect(played).toBeCloseTo(0.5, 6); + expect(comp.syncOffset("hf-talk-audio")).toBe(0); + }); + + it("refuses a slip that would start before the file", async () => { + const comp = await openComposition(DRIFTED); + expect(() => comp.slipIntoSync("hf-talk")).toThrow(/before its file/); + }); +}); diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 1a77c7cc7e..86d096126d 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -31,6 +31,7 @@ import type { } from "./types.js"; import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js"; import { buildRoots, flatElements, parsedAnimationIds } from "./document.js"; +import { syncFixFor, syncOffsetOf } from "./engine/syncTiming.js"; import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js"; import { parseMutable } from "./engine/model.js"; import type { ParsedDocument } from "./engine/model.js"; @@ -169,6 +170,23 @@ class CompositionImpl implements Composition { this.dispatch({ type: "setTiming", target: id, ...timing, ...opts }); } + syncOffset(id: HfId, fps = 30): number | null { + return syncOffsetOf(this.parsed.document, id, fps); + } + + /** Moves `id` alone; a link group it belongs to is left (as `setTiming` `linked: false`). */ + moveIntoSync(id: HfId): void { + const fix = syncFixFor(this.parsed.document, id, "move"); + if (fix.kind === "move") + this.dispatch({ type: "setTiming", target: id, start: fix.start, linked: false }); + } + + slipIntoSync(id: HfId): void { + const fix = syncFixFor(this.parsed.document, id, "slip"); + if (fix.kind === "slip") + this.dispatch({ type: "setAttribute", target: id, name: fix.name, value: fix.value }); + } + removeElement(id: HfId): void { this.dispatch({ type: "removeElement", target: id }); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d4599cabc9..52e078f238 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -435,6 +435,10 @@ export interface Composition { timing: { start?: number; duration?: number; trackIndex?: number }, opts?: { linked?: boolean }, ): void; + /** Frames `id` sits from its `data-sync-origin` partner, positive when late; null if unpaired or rates differ. */ + syncOffset(id: HfId, fps?: number): number | null; + moveIntoSync(id: HfId): void; + slipIntoSync(id: HfId): void; removeElement(id: HfId): void; /** * Insert an HTML fragment as a child of `parent` at `index` (WS-D). 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; -} diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 66873d43bc..ccf8216846 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -702,10 +702,14 @@ export function unwrapElementsFromHtml( }; } -/** After a cut, give each linked group's right halves their own shared `data-link`. */ +/** After a cut, give each linked group's right halves their own `data-link` and `data-sync-origin`. */ export function relinkSplitHalvesInHtml(source: string, rightHalfIds: readonly string[]): string { const { document, wrappedFragment } = parseSourceDocument(source); - if (!rightHalfIds.some((id) => document.getElementById(id)?.hasAttribute("data-link"))) { + const carriesPairing = (id: string) => { + const el = document.getElementById(id); + return Boolean(el?.hasAttribute("data-link") || el?.hasAttribute("data-sync-origin")); + }; + if (!rightHalfIds.some(carriesPairing)) { return source; } relinkSplitHalves(document, rightHalfIds); diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index 2ed4297a8d..47208de26e 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -411,6 +411,19 @@ describe("relinkSplitHalvesInHtml", () => { expect(link("a-split")).toBe("lk-2"); }); + it("gives an unlinked pair's right halves their own sync origin", () => { + const source = + '
'; + const first = splitElementInHtml(source, { id: "v" }, 2, "v-split"); + const second = splitElementInHtml(first.html, { id: "a" }, 2, "a-split"); + const html = relinkSplitHalvesInHtml(second.html, ["v-split", "a-split"]); + const origin = (id: string) => + new RegExp(`id="${id}"[^>]*data-sync-origin="([^"]+)"`).exec(html)?.[1]; + expect(origin("v")).toBe("lk-1"); + expect(origin("v-split")).not.toBe("lk-1"); + expect(origin("v-split")).toBe(origin("a-split")); + }); + it("leaves unlinked splits byte-identical", () => { const source = '
'; expect(relinkSplitHalvesInHtml(source, ["i"])).toBe(source); diff --git a/packages/studio/src/components/LinkedSelectionToggle.tsx b/packages/studio/src/components/LinkedSelectionToggle.tsx new file mode 100644 index 0000000000..8f01a2d144 --- /dev/null +++ b/packages/studio/src/components/LinkedSelectionToggle.tsx @@ -0,0 +1,73 @@ +import { useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import { LinkSimple } from "@phosphor-icons/react"; +import { useContextMenuDismiss } from "../hooks/useContextMenuDismiss"; +import { useMenuKeyboardNav } from "../player/components/menuKeyboardNav"; +import { useLinkedClipPreferences } from "../utils/linkedClipPreferences"; +import { Tooltip } from "./ui"; +import { flatActive, flatIdle } from "./timelineToolbarStyles"; + +function SyncIndicatorMenu({ x, y, onClose }: { x: number; y: number; onClose: () => void }) { + const menuRef = useContextMenuDismiss(onClose); + useMenuKeyboardNav(menuRef); + const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible); + const setVisible = useLinkedClipPreferences((s) => s.setSyncIndicatorsVisible); + return createPortal( +
+ +
, + document.body, + ); +} + +/** Premiere's Linked Selection; right-click holds the out-of-sync indicator preference. */ +export function LinkedSelectionToggle() { + const linkedSelection = useLinkedClipPreferences((s) => s.linkedSelection); + const setLinkedSelection = useLinkedClipPreferences((s) => s.setLinkedSelection); + const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null); + const closeMenu = useCallback(() => setMenuAt(null), []); + return ( + <> + + + + {menuAt && } + + ); +} diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index 778194ab62..d9de1d7fe4 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -391,3 +391,34 @@ describe("TimelineToolbar add beat", () => { act(() => root.unmount()); }); }); + +describe("TimelineToolbar Linked Selection", () => { + it("is on by default, first among the editing toggles, and persists a click", async () => { + const { useLinkedClipPreferences } = await import("../utils/linkedClipPreferences"); + const { host } = renderToolbar(); + const button = host.querySelector('button[aria-label="Linked Selection"]'); + expect(button?.getAttribute("aria-pressed")).toBe("true"); + const snapping = host.querySelector('button[aria-label="Toggle timeline snapping"]'); + expect(snapping && button?.compareDocumentPosition(snapping)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + act(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(button?.getAttribute("aria-pressed")).toBe("false"); + expect(readStudioUiPreferences().linkedSelectionEnabled).toBe(false); + act(() => useLinkedClipPreferences.getState().setLinkedSelection(true)); + }); + + it("right-click toggles the out-of-sync indicator preference", async () => { + const { useLinkedClipPreferences } = await import("../utils/linkedClipPreferences"); + const { host } = renderToolbar(); + const button = host.querySelector('button[aria-label="Linked Selection"]'); + act(() => button?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }))); + const item = document.querySelector('[role="menuitemcheckbox"]'); + expect(item?.textContent).toContain("Show out-of-sync indicators"); + expect(item?.getAttribute("aria-checked")).toBe("true"); + act(() => item?.click()); + expect(useLinkedClipPreferences.getState().syncIndicatorsVisible).toBe(false); + expect(readStudioUiPreferences().syncIndicatorsVisible).toBe(false); + act(() => useLinkedClipPreferences.getState().setSyncIndicatorsVisible(true)); + }); +}); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index fc4424f6d9..7f0da96b33 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -31,6 +31,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "./editor/domEditingTypes"; import { canSplitElement } from "../utils/timelineElementSplit"; import { useAudioMetersVisible } from "../utils/audioMeterVisibility"; +import { LinkedSelectionToggle } from "./LinkedSelectionToggle"; import { useProjectHasAudio } from "../utils/audioMeterMath"; import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions"; import { isTypingTarget } from "../utils/typingTarget"; @@ -218,6 +219,7 @@ export function TimelineToolbar({ {/* Divider: tool-mode | editing-actions */} 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))); }, }, }; diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 3ec34a8889..3144ee661f 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -45,6 +45,7 @@ const EDIT_CALLBACK_KEY_SET: Record = { onRazorSplit: true, onRazorSplitAll: true, onFreezeFrame: true, + onNotice: true, onDeleteKeyframe: true, onDeleteAllKeyframes: true, onMoveKeyframeToPlayhead: true, diff --git a/packages/studio/src/hooks/timelineLinkEditPlan.test.ts b/packages/studio/src/hooks/timelineLinkEditPlan.test.ts index 0513e12a41..5c95035193 100644 --- a/packages/studio/src/hooks/timelineLinkEditPlan.test.ts +++ b/packages/studio/src/hooks/timelineLinkEditPlan.test.ts @@ -78,3 +78,36 @@ describe("planLinkEdit", () => { expect(out).toContain('data-track-index="1"'); }); }); + +describe("planLinkEdit into sync", () => { + const drifted = + '
'; + const late = clip("talk-audio", "audio", { start: 2.5, playbackStart: 0 }); + + it("moves one clip's start, leaving its partner and link alone", () => { + const plan = planLinkEdit({ kind: "move-into-sync", element: late, start: 2 }, [video, late]); + expect(plan?.label).toBe("Move into Sync"); + const html = plan?.transform(drifted) ?? ""; + expect(html).toContain('id="talk-audio" src="talk.mp4" data-sync-origin="lk-1" data-start="2"'); + expect(html).toContain('id="talk" src="talk.mp4" muted data-sync-origin="lk-1" data-start="2"'); + }); + + it("slips one clip's media start in place", () => { + const plan = planLinkEdit({ kind: "slip-into-sync", element: late, mediaStart: 0.5 }, [ + video, + late, + ]); + expect(plan?.label).toBe("Slip into Sync"); + const html = plan?.transform(drifted) ?? ""; + expect(html).toContain('data-start="2.5" data-duration="6" data-media-start="0.5"'); + }); + + it("slips the legacy playback-start attribute when that is what the clip uses", () => { + const legacy = { ...late, playbackStartAttr: "playback-start" as const }; + const html = + planLinkEdit({ kind: "slip-into-sync", element: legacy, mediaStart: 0.5 }, [])?.transform( + '', + ) ?? ""; + expect(html).toBe(''); + }); +}); diff --git a/packages/studio/src/hooks/timelineLinkEditPlan.ts b/packages/studio/src/hooks/timelineLinkEditPlan.ts index 65b0c25f0d..c3d6bce414 100644 --- a/packages/studio/src/hooks/timelineLinkEditPlan.ts +++ b/packages/studio/src/hooks/timelineLinkEditPlan.ts @@ -2,6 +2,11 @@ import type { TimelineElement } from "../player"; import type { TimelineLinkEdit } from "../player/components/timelineCallbacks"; import { buildPatchTarget, type PatchTarget } from "./timelineEditingHelpers"; import { sharesLinkGroup } from "../player/components/audioClipLink"; +import { toAuthoredStart } from "../player/store/timelineElement"; +import { formatTimelineMediaOffset } from "../player/components/timelineEditing"; +import { applyPatchByTarget } from "../utils/sourcePatcher"; +import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; +import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers"; import { detachAudioInSource, linkInSource, @@ -42,6 +47,35 @@ export function clipsToUnlink( return [...removed.filter((el) => el.link), ...orphans.values()]; } +type SyncEdit = Extract; + +function planSyncEdit(edit: SyncEdit): LinkEditPlan | null { + const target = buildPatchTarget(edit.element); + if (!target) return null; + if (edit.kind === "move-into-sync") { + const value = formatTimelineMediaOffset(toAuthoredStart(edit.element, edit.start)); + return { + label: "Move into Sync", + anchor: edit.element, + transform: (s) => { + const moved = applyPatchByTarget(s, target, { + type: "attribute", + property: "start", + value, + }); + return setCompositionDurationToContent(moved, furthestClipEndFromSource(moved)); + }, + }; + } + const property = edit.element.playbackStartAttr ?? "media-start"; + const value = formatTimelineMediaOffset(edit.mediaStart); + return { + label: "Slip into Sync", + anchor: edit.element, + transform: (s) => applyPatchByTarget(s, target, { type: "attribute", property, value }), + }; +} + export function planLinkEdit( edit: TimelineLinkEdit, elements: readonly TimelineElement[], @@ -72,6 +106,9 @@ export function planLinkEdit( null, }; } + case "move-into-sync": + case "slip-into-sync": + return planSyncEdit(edit); case "merge": { const videoTarget = buildPatchTarget(edit.video); const audioTarget = buildPatchTarget(edit.audio); diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 29b9113c01..1769211825 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -5,6 +5,7 @@ import { getTimelineElementLabel } from "../utils/studioHelpers"; import { trackStudioRazorSplit } from "../telemetry/events"; import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit"; import { linkedMembersOf } from "../player/components/audioClipLink"; +import { isLinkedSelectionOn } from "../utils/linkedClipPreferences"; import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction"; import type { RecordEditInput } from "./timelineEditingHelpers"; import { useFreezeFrame } from "./useFreezeFrame"; @@ -104,7 +105,11 @@ export function useRazorSplit({ } if (!canSplitElementAt(element, splitTime)) return; try { - const members = linkedMembersOf(element, usePlayerStore.getState().elements); + const members = linkedMembersOf( + element, + usePlayerStore.getState().elements, + isLinkedSelectionOn(), + ); const result = await runCut( members.filter((member) => canSplitElementAt(member, splitTime)), splitTime, diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 0edcc303c0..9528406fb0 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); } }); @@ -250,6 +250,7 @@ describe("useRenderClipContent", () => { expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe( "/api/projects/my-project/waveform/talk.mp4", ); + expect(isValidElement<{ labelInset?: number }>(waveform) && waveform.props.labelInset).toBe(0); }); it("keeps a muted or silent video to thumbnails only", () => { diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index a929e6ac3a..87d4d7a657 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -63,6 +63,7 @@ function renderAudioClip( labelColor: string, context: TimelineClipRenderContext, elements: readonly TimelineElement[], + labelInset?: number, ): ReactNode { const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin); const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin); @@ -84,6 +85,7 @@ function renderAudioClip( projectId: pid, sessionEpoch, priority: context.priority, + labelInset, ...audioPillFlags(el, elements), }); return createElement( @@ -103,10 +105,10 @@ function renderAudioClip( function withSoundStrip( el: TimelineElement, thumbnail: ReactNode, - waveform: () => ReactNode, + waveform: (labelInset: number) => ReactNode, ): ReactNode { if (el.tag !== "video" || !clipHasSound(el)) return thumbnail; - return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() }); + return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform(0) }); } export interface UseRenderClipContentOptions { @@ -141,7 +143,8 @@ export function useRenderClipContent({ // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar. - const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements); + const waveform = (labelInset?: number) => + renderAudioClip(el, pid, sessionEpoch, style.label, context, elements, labelInset); if (effectiveMode === "hidden") { return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform); } diff --git a/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx b/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx index 06808aec51..de7d0cd473 100644 --- a/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineLinkEditing.test.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore, type TimelineElement } from "../player"; import { useTimelineLinkEditing, withLinkPartners } from "./useTimelineLinkEditing"; +import { useLinkedClipPreferences } from "../utils/linkedClipPreferences"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -74,6 +75,20 @@ describe("useTimelineLinkEditing", () => { unmount(); }); + it("with Linked Selection off, delete removes the clip alone and unlinks the survivor", async () => { + const del = vi.fn().mockResolvedValue(undefined); + const { api, unmount } = await renderLinkEditing(del); + useLinkedClipPreferences.getState().setLinkedSelection(false); + try { + expect(withLinkPartners([video])).toEqual([video]); + await act(async () => api.handleLinkedElementDelete(video)); + expect(del).toHaveBeenCalledWith([video], [audio]); + } finally { + useLinkedClipPreferences.getState().setLinkedSelection(true); + } + unmount(); + }); + it("delete-only removes one member and unlinks the survivor", async () => { const del = vi.fn().mockResolvedValue(undefined); const { api, unmount } = await renderLinkEditing(del); diff --git a/packages/studio/src/hooks/useTimelineLinkEditing.ts b/packages/studio/src/hooks/useTimelineLinkEditing.ts index b8fadcd919..6423e3acee 100644 --- a/packages/studio/src/hooks/useTimelineLinkEditing.ts +++ b/packages/studio/src/hooks/useTimelineLinkEditing.ts @@ -2,6 +2,7 @@ import { useCallback, type MutableRefObject } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import type { TimelineLinkEdit } from "../player/components/timelineCallbacks"; import { expandToLinkedMembers } from "../player/components/audioClipLink"; +import { isLinkedSelectionOn } from "../utils/linkedClipPreferences"; import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory"; import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; import { readFileContent } from "./timelineEditingHelpers"; @@ -33,6 +34,8 @@ export function linkEditTargets(edit: TimelineLinkEdit): TimelineElement[] { case "link": return [...edit.elements]; case "detach": + case "move-into-sync": + case "slip-into-sync": return [edit.element]; case "merge": return [edit.video, edit.audio]; @@ -41,7 +44,7 @@ export function linkEditTargets(edit: TimelineLinkEdit): TimelineElement[] { export function withLinkPartners(selection: readonly TimelineElement[]): TimelineElement[] { const elements = usePlayerStore.getState().elements; - const keys = expandToLinkedMembers(selection.map(keyOf), elements); + const keys = expandToLinkedMembers(selection.map(keyOf), elements, isLinkedSelectionOn()); const known = new Set(selection.map(keyOf)); return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))]; } @@ -115,13 +118,20 @@ export function useTimelineLinkEditing({ ); const handleLinkedElementsDelete = useCallback( - (selection: TimelineElement[]) => handleTimelineElementsDelete(withLinkPartners(selection)), + (selection: TimelineElement[]) => { + if (isLinkedSelectionOn()) return handleTimelineElementsDelete(withLinkPartners(selection)); + const removed = new Set(selection.map(keyOf)); + const orphans = clipsToUnlink(selection, usePlayerStore.getState().elements).filter( + (el) => !removed.has(keyOf(el)), + ); + return handleTimelineElementsDelete(selection, orphans); + }, [handleTimelineElementsDelete], ); const handleLinkedElementDelete = useCallback( - (element: TimelineElement) => handleTimelineElementsDelete(withLinkPartners([element])), - [handleTimelineElementsDelete], + (element: TimelineElement) => handleLinkedElementsDelete([element]), + [handleLinkedElementsDelete], ); const handleDeleteElementOnly = useCallback( diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index b79b3d6df6..b891063737 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -31,6 +31,7 @@ export { liveTime, formatTime, } from "./player"; +export { usePreviewIframeStore } from "./player/store/previewIframeStore"; export type { PlayerHandle, PlayerHandleElement, diff --git a/packages/studio/src/player/components/AudioWaveform.test.tsx b/packages/studio/src/player/components/AudioWaveform.test.tsx index f03c11df92..89f6a8e813 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,15 +112,42 @@ 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); }); + + it("fills a short sound strip when the label band is dropped", () => { + const heights = [16, 0].map((labelInset) => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const canvas = host.querySelector("canvas"); + const box = { top: canvas?.style.top, height: canvas?.style.height }; + act(() => root.unmount()); + return box; + }); + expect(heights).toEqual([ + { top: "16px", height: "calc(100% - 16px)" }, + { top: "0px", height: "calc(100% - 0px)" }, + ]); + }); }); diff --git a/packages/studio/src/player/components/AudioWaveform.tsx b/packages/studio/src/player/components/AudioWaveform.tsx index 2d8eef38f9..0a2ce9dee4 100644 --- a/packages/studio/src/player/components/AudioWaveform.tsx +++ b/packages/studio/src/player/components/AudioWaveform.tsx @@ -18,8 +18,7 @@ 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; + labelInset?: number; } const BAR_STEP = 3; @@ -167,7 +166,7 @@ export const AudioWaveform = memo(function AudioWaveform({ sessionEpoch, priority, muted = false, - linked = false, + labelInset = 16, }: AudioWaveformProps) { const rootRef = useRef(null); const canvasRef = useRef(null); @@ -236,17 +235,17 @@ export const AudioWaveform = memo(function AudioWaveform({ return (
- {linked ?