diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index f2626dd280..da3dfb8adf 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -200,6 +200,12 @@ "types": "./dist/audioCarve.d.ts", "environments": ["browser", "bun", "node"] }, + "./media-link": { + "source": "./src/mediaLink.ts", + "runtime": "./dist/mediaLink.js", + "types": "./dist/mediaLink.d.ts", + "environments": ["browser", "bun", "node"] + }, "./audio-groups": { "source": "./src/audioGroups.ts", "runtime": "./dist/audioGroups.js", diff --git a/packages/core/package.json b/packages/core/package.json index 836954ea54..62f017b31e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -214,6 +214,12 @@ "import": "./src/audioCarve.ts", "types": "./src/audioCarve.ts" }, + "./media-link": { + "bun": "./src/mediaLink.ts", + "node": "./dist/mediaLink.js", + "import": "./src/mediaLink.ts", + "types": "./src/mediaLink.ts" + }, "./audio-groups": { "bun": "./src/audioGroups.ts", "node": "./dist/audioGroups.js", @@ -608,6 +614,10 @@ "import": "./dist/audioCarve.js", "types": "./dist/audioCarve.d.ts" }, + "./media-link": { + "import": "./dist/mediaLink.js", + "types": "./dist/mediaLink.d.ts" + }, "./audio-groups": { "import": "./dist/audioGroups.js", "types": "./dist/audioGroups.d.ts" diff --git a/packages/core/src/mediaLink.test.ts b/packages/core/src/mediaLink.test.ts new file mode 100644 index 0000000000..568c7f930d --- /dev/null +++ b/packages/core/src/mediaLink.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + MEDIA_LINK_ATTR, + linkTimingMismatches, + mintLinkId, + readLinkTiming, + relinkSplitHalves, +} from "./mediaLink"; + +const attrs = (values: Record) => ({ + getAttribute: (name: string) => values[name] ?? null, +}); + +describe("readLinkTiming", () => { + it("defaults media-start to 0 and playback-rate to 1", () => { + expect(readLinkTiming(attrs({ "data-start": "2", "data-duration": "6" }))).toEqual({ + start: 2, + duration: 6, + mediaStart: 0, + playbackRate: 1, + }); + }); + + it("reads data-playback-start as the media-start alias", () => { + expect(readLinkTiming(attrs({ "data-playback-start": "1.5" })).mediaStart).toBe(1.5); + }); +}); + +describe("linkTimingMismatches", () => { + const base = { + "data-start": "2", + "data-duration": "6", + "data-media-start": "1", + "data-playback-rate": "1", + }; + + it("is empty for members in sync, treating absent defaults as equal", () => { + expect( + linkTimingMismatches([ + attrs(base), + attrs({ "data-start": "2", "data-duration": "6", "data-media-start": "1" }), + ]), + ).toEqual([]); + }); + + it.each([ + ["data-start", "start"], + ["data-duration", "duration"], + ["data-media-start", "media-start"], + ["data-playback-rate", "playback-rate"], + ])("names %s when it drifts", (attr, field) => { + expect(linkTimingMismatches([attrs(base), attrs({ ...base, [attr]: "3" })])).toEqual([field]); + }); + + it("ignores sub-millisecond float noise", () => { + expect(linkTimingMismatches([attrs(base), attrs({ ...base, "data-start": "2.0004" })])).toEqual( + [], + ); + }); +}); + +describe("mintLinkId", () => { + it("returns the first lk-N not already taken", () => { + expect(mintLinkId([])).toBe("lk-1"); + expect(mintLinkId(["lk-1", "lk-2", "lk-4"])).toBe("lk-3"); + }); +}); + +describe("relinkSplitHalves", () => { + function doc(html: string): Document { + document.body.innerHTML = html; + return document; + } + + it("gives each linked group's right halves one fresh shared id", () => { + const d = doc(` + + + `); + relinkSplitHalves(d, ["v-split", "a-split", "i-split"]); + const link = (id: string) => d.getElementById(id)?.getAttribute(MEDIA_LINK_ATTR) ?? null; + expect(link("v")).toBe("lk-1"); + expect(link("a")).toBe("lk-1"); + expect(link("v-split")).toBe("lk-2"); + expect(link("a-split")).toBe("lk-2"); + expect(link("i-split")).toBeNull(); + }); + + it("mints distinct ids for distinct groups", () => { + const d = doc(` + + `); + relinkSplitHalves(d, ["v2", "w2"]); + const ids = ["v2", "w2"].map((id) => d.getElementById(id)?.getAttribute(MEDIA_LINK_ATTR)); + expect(new Set(ids).size).toBe(2); + expect(ids).not.toContain("lk-1"); + expect(ids).not.toContain("lk-7"); + }); +}); diff --git a/packages/core/src/mediaLink.ts b/packages/core/src/mediaLink.ts new file mode 100644 index 0000000000..a74e24b224 --- /dev/null +++ b/packages/core/src/mediaLink.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ +export const MEDIA_LINK_ATTR = "data-link"; + +interface AttributeReader { + getAttribute(name: string): string | null; +} + +export interface LinkTiming { + start: number; + duration: number; + mediaStart: number; + playbackRate: number; +} + +const LINK_TIMING_FIELDS: ReadonlyArray<[keyof LinkTiming, string]> = [ + ["start", "start"], + ["duration", "duration"], + ["mediaStart", "media-start"], + ["playbackRate", "playback-rate"], +]; + +const SYNC_TOLERANCE_S = 1e-3; + +function readNumber(el: AttributeReader, names: string[], fallback: number): number { + for (const name of names) { + const raw = el.getAttribute(name); + if (raw === null || raw.trim() === "") continue; + const value = Number(raw); + if (Number.isFinite(value)) return value; + } + return fallback; +} + +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), + playbackRate: readNumber(el, ["data-playback-rate"], 1), + }; +} + +/** The timing fields (`start`, `duration`, `media-start`, `playback-rate`) on which members disagree. */ +export function linkTimingMismatches(members: readonly AttributeReader[]): string[] { + const timings = members.map(readLinkTiming); + const first = timings[0]; + if (!first) return []; + return LINK_TIMING_FIELDS.filter(([key]) => + timings.some((timing) => Math.abs(timing[key] - first[key]) > SYNC_TOLERANCE_S), + ).map(([, field]) => field); +} + +/** 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; +} + +export function mintLinkId(taken: Iterable): string { + const used = new Set(taken); + let n = 1; + while (used.has(`lk-${n}`)) n += 1; + return `lk-${n}`; +} + +function takenLinkIds(doc: Document): string[] { + return Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`)).flatMap((el) => [ + el.id, + el.getAttribute(MEDIA_LINK_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)); + const renamed = new Map(); + for (const id of rightHalfIds) { + const el = doc.getElementById(id); + const oldLink = el?.getAttribute(MEDIA_LINK_ATTR); + if (!el || !oldLink) continue; + let fresh = renamed.get(oldLink); + if (!fresh) { + fresh = mintLinkId(taken); + taken.add(fresh); + renamed.set(oldLink, fresh); + } + el.setAttribute(MEDIA_LINK_ATTR, fresh); + } +} diff --git a/packages/lint/src/rules/linkedClips.test.ts b/packages/lint/src/rules/linkedClips.test.ts new file mode 100644 index 0000000000..c5f0977c78 --- /dev/null +++ b/packages/lint/src/rules/linkedClips.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { lintHyperframeHtml } from "../hyperframeLinter.js"; + +const VIDEO = + 'id="talk" src="talk.mp4" muted data-link="lk-1" data-start="2" data-duration="6" data-media-start="1" data-track-index="0"'; +const AUDIO = + 'id="talk-audio" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6" data-media-start="1" data-track-index="2"'; + +function composition(body: string): string { + return ` + +
+ ${body} +
+ +`; +} + +async function linkFindings(body: string) { + const result = await lintHyperframeHtml(composition(body)); + return result.findings.filter((f) => f.code.startsWith("linked_clip")); +} + +describe("linked clip rules", () => { + it("an in-sync pair is clean", async () => { + expect(await linkFindings(``)).toEqual([]); + }); + + it("absent media-start and playback-rate equal their defaults", async () => { + const video = VIDEO.replace(' data-media-start="1"', ' data-media-start="0"'); + const audio = AUDIO.replace(' data-media-start="1"', ' data-playback-rate="1"'); + expect(await linkFindings(``)).toEqual([]); + }); + + it.each([ + ['data-start="2"', 'data-start="2.5"', "start"], + ['data-duration="6"', 'data-duration="5"', "duration"], + ['data-media-start="1"', 'data-media-start="0"', "media-start"], + ['data-media-start="1"', 'data-media-start="1" data-playback-rate="2"', "playback-rate"], + ])("warns when %s drifts to %s", async (from, to, field) => { + const findings = await linkFindings( + ``, + ); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe("linked_clips_out_of_sync"); + expect(findings[0]?.severity).toBe("warning"); + expect(findings[0]?.message).toContain(field); + expect(findings[0]?.message).toContain("lk-1"); + }); + + it("warns on a link id with one member", async () => { + const findings = await linkFindings(``); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe("linked_clip_orphan"); + expect(findings[0]?.severity).toBe("warning"); + expect(findings[0]?.elementId).toBe("talk"); + }); + + it("track index, volume and fades may differ freely", async () => { + const audio = `${AUDIO} data-volume="0.5" data-fade-in="1"`; + expect(await linkFindings(``)).toEqual([]); + }); + + it("groups links per composition, so a nested composition may reuse an id", async () => { + const nested = `
+ + +
`; + expect(await linkFindings(`${nested}`)).toEqual( + [], + ); + }); + + it("treats an inlined file host as its own composition too", async () => { + const hosted = `
+ +
`; + const findings = await linkFindings( + `${hosted}`, + ); + expect(findings.map((f) => [f.code, f.elementId])).toEqual([["linked_clip_orphan", "h-talk"]]); + }); +}); diff --git a/packages/lint/src/rules/linkedClips.ts b/packages/lint/src/rules/linkedClips.ts new file mode 100644 index 0000000000..90c5a57c23 --- /dev/null +++ b/packages/lint/src/rules/linkedClips.ts @@ -0,0 +1,94 @@ +import type { LintContext, HyperframeLintFinding, OpenTag } from "../context"; +import { readAttr, truncateSnippet } from "../utils"; + +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: "playback-rate", attrs: ["data-playback-rate"], fallback: 1 }, +]; + +function readTimingField(tag: OpenTag, attrs: string[], fallback: number): number { + for (const attr of attrs) { + const raw = readAttr(tag.raw, attr); + if (raw === null || raw.trim() === "") continue; + const value = Number(raw); + if (Number.isFinite(value)) return value; + } + return fallback; +} + +function driftingFields(members: readonly OpenTag[]): string[] { + return LINKED_TIMING_FIELDS.filter(({ attrs, fallback }) => { + const values = members.map((tag) => readTimingField(tag, attrs, fallback)); + const first = values[0] ?? fallback; + return values.some((value) => Math.abs(value - first) > SYNC_TOLERANCE_S); + }).map(({ field }) => field); +} + +function compositionScopeOf(tag: OpenTag, compositions: readonly OpenTag[]): number { + let scope = -1; + for (const composition of compositions) { + const end = composition.endIndex ?? composition.closeIndex ?? Number.POSITIVE_INFINITY; + const encloses = composition.index < tag.index && tag.index < end; + if (composition !== tag && encloses && composition.index > scope) scope = composition.index; + } + return scope; +} + +function groupByLink(tags: readonly OpenTag[]): Map { + const compositions = tags.filter((tag) => + ["data-composition-id", "data-composition-file"].some( + (name) => readAttr(tag.raw, name) !== null, + ), + ); + const groups = new Map(); + for (const tag of tags) { + const link = readAttr(tag.raw, "data-link"); + if (!link) continue; + const key = `${compositionScopeOf(tag, compositions)}\u0000${link}`; + const group = groups.get(key) ?? { link, members: [] }; + group.members.push(tag); + groups.set(key, group); + } + return groups; +} + +const memberLabel = (tag: OpenTag) => { + const id = readAttr(tag.raw, "id"); + return id ? `#${id}` : `<${tag.name}>`; +}; + +export function findLinkedClipFindings(ctx: LintContext): HyperframeLintFinding[] { + const findings: HyperframeLintFinding[] = []; + for (const { link, members } of groupByLink(ctx.tags).values()) { + const first = members[0]; + if (!first) continue; + const elementId = readAttr(first.raw, "id") || undefined; + if (members.length === 1) { + findings.push({ + code: "linked_clip_orphan", + severity: "warning", + message: `${memberLabel(first)} is the only clip with data-link="${link}"; its linked partner is gone.`, + elementId, + fixHint: `Remove data-link="${link}" from ${memberLabel(first)}, or restore the partner clip.`, + snippet: truncateSnippet(first.raw), + }); + continue; + } + const drift = driftingFields(members); + if (drift.length === 0) continue; + findings.push({ + code: "linked_clips_out_of_sync", + severity: "warning", + message: `Linked clips ${members.map(memberLabel).join(", ")} (data-link="${link}") differ in ${drift.join(", ")}.`, + elementId, + fixHint: + "Linked clips are edited as one: give every member the same data-start, data-duration, data-media-start and data-playback-rate, or remove data-link from all of them to unlink.", + snippet: truncateSnippet(first.raw), + }); + } + return findings; +} diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts index 695705b63d..3515e88a07 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -13,6 +13,7 @@ import { import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract"; import { extractMediaSrcMutations } from "@hyperframes/parsers/composition"; import { parseHTML } from "linkedom"; +import { findLinkedClipFindings } from "./linkedClips"; /** * Does the GSAP call that names `#id` also set `volume` in the same call? @@ -831,6 +832,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = // audio_group_carve_attr findAudioGroupCarveAttrFindings, + findLinkedClipFindings, ]; /** diff --git a/packages/sdk/src/engine/linkedTiming.ts b/packages/sdk/src/engine/linkedTiming.ts new file mode 100644 index 0000000000..cd4265ca13 --- /dev/null +++ b/packages/sdk/src/engine/linkedTiming.ts @@ -0,0 +1,68 @@ +import { MEDIA_LINK_ATTR, linkScopeOf } from "@hyperframes/core/media-link"; +import type { HfId } from "../types.js"; +import { escapeHfId, querySelectorAllDeep, resolveScoped } from "./model.js"; + +interface LinkMember { + id: HfId; + link: string; +} + +function scopePrefix(id: HfId): string { + const cut = id.lastIndexOf("/"); + return cut < 0 ? "" : id.slice(0, cut + 1); +} + +/** + * Every element sharing `id`'s link, addressed in `id`'s scope, `id` included. + * Throws when a member's address resolves to a different element (a duplicate + * id elsewhere), since editing that address would change the wrong clip. + */ +function linkGroup(document: Document, id: HfId): LinkMember[] { + const el = resolveScoped(document, id); + const link = el?.getAttribute(MEDIA_LINK_ATTR); + if (!el || !link) return []; + const scope = linkScopeOf(el); + const prefix = scopePrefix(id); + const members: LinkMember[] = []; + for (const member of querySelectorAllDeep( + scope ?? document, + `[${MEDIA_LINK_ATTR}="${escapeHfId(link)}"]`, + )) { + const hfId = member.getAttribute("data-hf-id"); + if (!hfId || linkScopeOf(member) !== scope) continue; + const memberId = `${prefix}${hfId}`; + if (resolveScoped(document, memberId) !== member) { + throw new Error( + `Link partner "${memberId}" of "${id}" is not uniquely addressable; give it a unique data-hf-id.`, + ); + } + members.push({ id: memberId, link }); + } + return members; +} + +/** Link partners of `ids` that are not themselves in `ids`. */ +export function linkedPartnerIds(document: Document, ids: readonly HfId[]): HfId[] { + const own = new Set(ids); + const partners = new Set(); + for (const id of ids) { + for (const member of linkGroup(document, id)) { + if (!own.has(member.id)) partners.add(member.id); + } + } + return [...partners]; +} + +/** `ids` leaving their link groups: themselves, plus any partner left alone. */ +export function idsToUnlink(document: Document, ids: readonly HfId[]): HfId[] { + const leaving = new Set(ids); + const result = new Set(); + for (const id of ids) { + const group = linkGroup(document, id); + if (group.length === 0) continue; + result.add(id); + const survivors = group.filter((member) => !leaving.has(member.id)); + if (survivors.length === 1 && survivors[0]) result.add(survivors[0].id); + } + return [...result]; +} diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 8b18eb5b32..4d5cc7f74d 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -17,6 +17,8 @@ import type { JsonPatchOp, } from "../types.js"; import type { ParsedDocument } from "./model.js"; +import { MEDIA_LINK_ATTR } from "@hyperframes/core/media-link"; +import { idsToUnlink, linkedPartnerIds } from "./linkedTiming.js"; import { resolveScoped, escapeHfId, @@ -276,6 +278,38 @@ function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undef } } +const concatResults = (a: MutationResult, b: MutationResult): MutationResult => ({ + forward: [...a.forward, ...b.forward], + inverse: [...a.inverse, ...b.inverse], +}); + +/** + * Timing edits apply to link partners too (start/duration; each keeps its own + * track). `linked: false` edits the targets alone and unlinks them. + */ +function applySetTiming( + parsed: ParsedDocument, + op: Extract, +): MutationResult { + const ids = targets(op.target); + const timing = { start: op.start, duration: op.duration, trackIndex: op.trackIndex }; + if (op.linked === false) { + const unlink = idsToUnlink(parsed.document, ids); + const own = handleSetTiming(parsed, ids, timing); + return concatResults(own, handleSetAttribute(parsed, unlink, MEDIA_LINK_ATTR, null)); + } + const partners = + op.start === undefined && op.duration === undefined + ? [] + : linkedPartnerIds(parsed.document, ids); + const own = handleSetTiming(parsed, ids, timing); + if (partners.length === 0) return own; + return concatResults( + own, + handleSetTiming(parsed, partners, { start: op.start, duration: op.duration }), + ); +} + export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { const gsap = applyGsapOp(parsed, op); if (gsap !== undefined) return gsap; @@ -287,11 +321,7 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { case "setAttribute": return handleSetAttribute(parsed, targets(op.target), op.name, op.value); case "setTiming": - return handleSetTiming(parsed, targets(op.target), { - start: op.start, - duration: op.duration, - trackIndex: op.trackIndex, - }); + return applySetTiming(parsed, op); case "setHold": return handleSetHold(parsed, targets(op.target), op.hold); case "moveElement": diff --git a/packages/sdk/src/session.linked.test.ts b/packages/sdk/src/session.linked.test.ts new file mode 100644 index 0000000000..4baac66e70 --- /dev/null +++ b/packages/sdk/src/session.linked.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { openComposition } from "./session.js"; + +const LINKED_HTML = ` +
+ + + +
+`.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("setTiming on linked clips", () => { + it("moves link partners by default, each keeping its own track", async () => { + const comp = await openComposition(LINKED_HTML); + comp.setTiming("hf-talk", { start: 3, trackIndex: 1 }); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-start")).toBe("3"); + expect(attr(html, "hf-talk-audio", "data-start")).toBe("3"); + expect(attr(html, "hf-talk", "data-track-index")).toBe("1"); + expect(attr(html, "hf-talk-audio", "data-track-index")).toBe("2"); + expect(attr(html, "hf-music", "data-start")).toBe("0"); + }); + + it("trims partners to the same duration, and one undo reverts both", async () => { + const comp = await openComposition(LINKED_HTML); + comp.setTiming("hf-talk-audio", { duration: 4 }); + expect(attr(comp.serialize(), "hf-talk", "data-duration")).toBe("4"); + comp.undo(); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-duration")).toBe("6"); + expect(attr(html, "hf-talk-audio", "data-duration")).toBe("6"); + }); + + it("{ linked: false } edits one member and unlinks the pair", async () => { + const comp = await openComposition(LINKED_HTML); + comp.setTiming("hf-talk", { start: 5 }, { linked: false }); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-start")).toBe("5"); + expect(attr(html, "hf-talk-audio", "data-start")).toBe("2"); + expect(attr(html, "hf-talk", "data-link")).toBeNull(); + expect(attr(html, "hf-talk-audio", "data-link")).toBeNull(); + comp.undo(); + expect(attr(comp.serialize(), "hf-talk", "data-link")).toBe("lk-1"); + }); + + it("a batch naming both members stays in sync", async () => { + const comp = await openComposition(LINKED_HTML); + comp.setElementTiming({ "hf-talk": { start: 4 }, "hf-talk-audio": { start: 4 } }); + const html = comp.serialize(); + expect(attr(html, "hf-talk", "data-start")).toBe("4"); + expect(attr(html, "hf-talk-audio", "data-start")).toBe("4"); + }); + + it.each([ + 'data-composition-id="child" data-composition-file="child.html"', + 'data-composition-file="child.html"', + ])("keeps a link group inside its own composition (host %s)", async (host) => { + const comp = await openComposition( + ` +
+ + +
+ + +
+
`.trim(), + ); + comp.setTiming("hf-v", { start: 2 }); + let html = comp.serialize(); + expect(attr(html, "hf-a", "data-start")).toBe("2"); + expect(attr(html, "hf-cv", "data-start")).toBe("0"); + expect(attr(html, "hf-ca", "data-start")).toBe("0"); + comp.setTiming("hf-host/hf-cv", { start: 1 }); + html = comp.serialize(); + expect(attr(html, "hf-ca", "data-start")).toBe("1"); + expect(attr(html, "hf-v", "data-start")).toBe("2"); + expect(attr(html, "hf-a", "data-start")).toBe("2"); + }); + + it("keeps a link group inside an inline composition that reuses the id", async () => { + const comp = await openComposition( + ` +
+ + +
+ + +
+
`.trim(), + ); + comp.setTiming("hf-v", { start: 2 }); + let html = comp.serialize(); + expect(attr(html, "hf-a", "data-start")).toBe("2"); + expect(attr(html, "hf-cv", "data-start")).toBe("5"); + expect(attr(html, "hf-ca", "data-start")).toBe("5"); + comp.setTiming("hf-cv", { start: 6 }); + html = comp.serialize(); + expect(attr(html, "hf-ca", "data-start")).toBe("6"); + expect(attr(html, "hf-v", "data-start")).toBe("2"); + comp.setTiming("hf-ca", { start: 7 }, { linked: false }); + html = comp.serialize(); + expect(attr(html, "hf-cv", "data-link")).toBeNull(); + expect(attr(html, "hf-v", "data-link")).toBe("lk-1"); + expect(attr(html, "hf-a", "data-link")).toBe("lk-1"); + }); + + it("refuses to edit a link partner whose id also names a root clip", async () => { + const html = ` +
+ +
+ + +
+
`.trim(); + const comp = await openComposition(html); + expect(() => comp.setTiming("hf-cv", { start: 2 })).toThrow(/not uniquely addressable/); + expect(() => comp.setTiming("hf-cv", { start: 2 }, { linked: false })).toThrow( + /not uniquely addressable/, + ); + expect(comp.serialize()).toBe((await openComposition(html)).serialize()); + }); +}); diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 29ca50f951..1a77c7cc7e 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -161,8 +161,12 @@ class CompositionImpl implements Composition { this.dispatch({ type: "setAttribute", target: id, name, value }); } - setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void { - this.dispatch({ type: "setTiming", target: id, ...timing }); + setTiming( + id: HfId, + timing: { start?: number; duration?: number; trackIndex?: number }, + opts?: { linked?: boolean }, + ): void { + this.dispatch({ type: "setTiming", target: id, ...timing, ...opts }); } removeElement(id: HfId): void { @@ -563,7 +567,8 @@ class CompositionImpl implements Composition { setText: (value) => this.dispatch({ type: "setText", target: ids, value }), setAttribute: (name, value) => this.dispatch({ type: "setAttribute", target: ids, name, value }), - setTiming: (timing) => this.dispatch({ type: "setTiming", target: ids, ...timing }), + setTiming: (timing, opts) => + this.dispatch({ type: "setTiming", target: ids, ...timing, ...opts }), removeElement: () => this.dispatch({ type: "removeElement", target: ids }), }; } @@ -575,7 +580,8 @@ class CompositionImpl implements Composition { setText: (value) => this.dispatch({ type: "setText", target: id, value }), setAttribute: (name, value) => this.dispatch({ type: "setAttribute", target: id, name, value }), - setTiming: (timing) => this.dispatch({ type: "setTiming", target: id, ...timing }), + setTiming: (timing, opts) => + this.dispatch({ type: "setTiming", target: id, ...timing, ...opts }), removeElement: () => this.dispatch({ type: "removeElement", target: id }), }; } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ba902e7d07..d4599cabc9 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -111,6 +111,8 @@ export type EditOp = start?: number; duration?: number; trackIndex?: number; + /** Default true: start/duration also apply to `data-link` partners. `false` edits the target alone and unlinks it. */ + linked?: boolean; } | { type: "setHold"; target: HfId | HfId[]; hold: ElasticHold } | { type: "moveElement"; target: HfId | HfId[]; x: number; y: number } @@ -378,7 +380,10 @@ export interface SelectionProxy { setStyle(styles: Record): void; setText(value: string): void; setAttribute(name: string, value: string | null): void; - setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void; + setTiming( + timing: { start?: number; duration?: number; trackIndex?: number }, + opts?: { linked?: boolean }, + ): void; removeElement(): void; } @@ -391,7 +396,10 @@ export interface ElementHandle { setStyle(styles: Record): void; setText(value: string): void; setAttribute(name: string, value: string | null): void; - setTiming(timing: { start?: number; duration?: number; trackIndex?: number }): void; + setTiming( + timing: { start?: number; duration?: number; trackIndex?: number }, + opts?: { linked?: boolean }, + ): void; removeElement(): void; } @@ -421,7 +429,12 @@ export interface Composition { setStyle(id: HfId, styles: Record): void; setText(id: HfId, value: string): void; setAttribute(id: HfId, name: string, value: string | null): void; - setTiming(id: HfId, timing: { start?: number; duration?: number; trackIndex?: number }): void; + /** Also moves `data-link` partners unless `{ linked: false }`, which edits `id` alone and unlinks it. */ + setTiming( + id: HfId, + timing: { start?: number; duration?: number; trackIndex?: number }, + opts?: { linked?: boolean }, + ): 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/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 164a88824f..66873d43bc 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -12,6 +12,7 @@ import { walkCompositionDescendants, } from "@hyperframes/parsers/hf-ids"; import { readClipTiming, writeClipTiming } from "@hyperframes/core/composition-contract"; +import { relinkSplitHalves } from "@hyperframes/core/media-link"; import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js"; export interface SourceMutationTarget { @@ -700,3 +701,13 @@ export function unwrapElementsFromHtml( groupCenter, }; } + +/** After a cut, give each linked group's right halves their own shared `data-link`. */ +export function relinkSplitHalvesInHtml(source: string, rightHalfIds: readonly string[]): string { + const { document, wrappedFragment } = parseSourceDocument(source); + if (!rightHalfIds.some((id) => document.getElementById(id)?.hasAttribute("data-link"))) { + return source; + } + relinkSplitHalves(document, rightHalfIds); + return wrappedFragment ? document.body.innerHTML || "" : document.toString(); +} diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index 8585d06a79..2ed4297a8d 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; import { splitElementInHtml, + relinkSplitHalvesInHtml, unwrapElementsFromHtml, wrapElementsInHtml, } from "./sourceMutation.js"; @@ -395,3 +396,23 @@ describe("wrapElementsInHtml / unwrapElementsFromHtml", () => { expect(result.html).toBe(html); }); }); + +describe("relinkSplitHalvesInHtml", () => { + it("makes each half of a split linked pair its own pair", () => { + 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 link = (id: string) => new RegExp(`id="${id}"[^>]*data-link="([^"]+)"`).exec(html)?.[1]; + expect(link("v")).toBe("lk-1"); + expect(link("a")).toBe("lk-1"); + expect(link("v-split")).toBe("lk-2"); + expect(link("a-split")).toBe("lk-2"); + }); + + it("leaves unlinked splits byte-identical", () => { + const source = '
'; + expect(relinkSplitHalvesInHtml(source, ["i"])).toBe(source); + }); +}); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 9fe116873f..585bb38e23 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -83,6 +83,7 @@ import { patchElementInHtml, probeElementInSource, splitElementInHtml, + relinkSplitHalvesInHtml, wrapElementsInHtml, unwrapElementsFromHtml, isHTMLElement, @@ -2163,6 +2164,7 @@ async function foldAtomicCutFile( let after = before; let splitCount = 0; const skippedSelectors = new Set(); + const rightHalfIds: string[] = []; const respond = (data: unknown, status?: number) => status ? c.json(data, status) : c.json(data); @@ -2204,6 +2206,7 @@ async function foldAtomicCutFile( } after = split.html; splitCount++; + rightHalfIds.push(split.newId); if (!cut.originalId) continue; const block = extractGsapScriptBlock(after); @@ -2235,6 +2238,7 @@ async function foldAtomicCutFile( } } + after = relinkSplitHalvesInHtml(after, rightHalfIds); return { path: file.path, absPath, diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2aeb005dbd..316a4e5acc 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -17,11 +17,7 @@ import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; -import { - persistTimelineMoveEditsAtomically, - type TimelineMoveEditsHandler, - type TimelineMoveOperation, -} from "./hooks/timelineMoveAdapter"; +import { useTimelineMoveEditsHandler } from "./hooks/timelineMoveAdapter"; import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; @@ -179,12 +175,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St invalidateGsapCache, handleDomZIndexReorderCommitRef, }); - const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback( - async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => { - const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove }; - await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs); - }, - [timelineEditing.handleTimelineGroupMove], + const handleTimelineElementsMove = useTimelineMoveEditsHandler( + timelineEditing.handleTimelineGroupMove, ); const { addAssetAtPlayhead: handleAddAssetAtPlayhead, @@ -246,6 +238,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St }); const appHotkeys = useAppHotkeys({ handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete, + handleLinkEdit: timelineEditing.handleLinkEdit, + handleTimelineElementDeleteOnly: timelineEditing.handleTimelineElementDeleteOnly, handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, domEditSelectionRef: domEditSelectionBridgeRef, @@ -553,6 +547,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St handleRazorSplit={timelineEditing.handleRazorSplit} handleRazorSplitAll={timelineEditing.handleRazorSplitAll} handleFreezeFrame={timelineEditing.handleFreezeFrame} + handleLinkEdit={timelineEditing.handleLinkEdit} + handleTimelineElementDeleteOnly={timelineEditing.handleTimelineElementDeleteOnly} onCopyClip={handleCopy} onPasteClip={handlePaste} onDuplicateClip={handleDuplicate} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index 1bd2e60d38..ce0250769c 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -104,6 +104,8 @@ export function EditorShell({ handleRazorSplit, handleRazorSplitAll, handleFreezeFrame, + handleLinkEdit, + handleTimelineElementDeleteOnly, onCopyClip, onPasteClip, onDuplicateClip, @@ -165,6 +167,8 @@ export function EditorShell({ handleRazorSplit, handleRazorSplitAll, handleFreezeFrame, + handleLinkEdit, + handleTimelineElementDeleteOnly, }); return ( diff --git a/packages/studio/src/components/editor/mediaAudioEdits.test.ts b/packages/studio/src/components/editor/mediaAudioEdits.test.ts index 9f2e0203b1..199d0f49ac 100644 --- a/packages/studio/src/components/editor/mediaAudioEdits.test.ts +++ b/packages/studio/src/components/editor/mediaAudioEdits.test.ts @@ -88,9 +88,9 @@ describe("cutoutOps", () => { describe("mintLinkId", () => { it("dedupes against element ids and existing links", () => { const doc = document.implementation.createHTMLDocument("t"); - expect(mintLinkId(doc)).toBe("link"); - doc.body.innerHTML = ''; - expect(mintLinkId(doc)).toBe("link-3"); + expect(mintLinkId(doc)).toBe("lk-1"); + doc.body.innerHTML = '
'; + expect(mintLinkId(doc)).toBe("lk-3"); }); }); @@ -176,10 +176,24 @@ describe("buildKeepSoundCutoutEdit", () => { { target: "rate", points: [{ t: 0, v: 2 }] }, ]); expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link")); - expect(video.getAttribute("data-link")).toBe("link"); + expect(video.getAttribute("data-link")).toBe("lk-1"); expect(audio.parentElement).toBe(video.parentElement); }); + it("keeps a hidden video's sound silent on the linked audio", () => { + const video = liveVideo(); + video.setAttribute("data-hidden", ""); + const edit = buildKeepSoundCutoutEdit({ + video, + videoId: "clip", + target: { id: "clip" }, + cutoutSrc: "assets/talk-cutout.webm", + }); + const hidden = source.replace('