diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index a60a7fd4fa..9caa160937 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -367,14 +367,18 @@ export function splitElementInHtml( // Keep the "clip" class — the runtime uses it to control visibility // based on data-start/data-duration timing. - // Adjust media trim offset for the second half + // A split creates two views over the same media source. Even an untrimmed + // audio/video element needs an explicit zero in-point stamped on the first + // half so the second half can advance from it instead of restarting at zero. const playbackStartAttr = el.hasAttribute("data-playback-start") ? "data-playback-start" : el.hasAttribute("data-media-start") ? "data-media-start" : fallbackTiming?.stampPlaybackStart ? "data-playback-start" - : null; + : el.matches("audio, video") + ? "data-media-start" + : null; if (playbackStartAttr) { const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0; diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts index a405cdb633..48a175feb6 100644 --- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -123,6 +123,36 @@ describe("splitElementInHtml", () => { expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/); }); + it.each(["audio", "video"])( + "seeds the second %s half with a media in-point when the source starts at zero", + (tag) => { + const mediaSource = `
<${tag} id="media" class="clip" src="asset.mp4" data-start="1" data-duration="6">
`; + + const result = splitElementInHtml(mediaSource, { id: "media" }, 3, "media-split"); + const { document } = parseHTML(result.html); + + expect(result.matched).toBe(true); + expect(document.getElementById("media")?.getAttribute("data-media-start")).toBe("0"); + expect(document.getElementById("media-split")?.getAttribute("data-media-start")).toBe("2"); + }, + ); + + it("advances a zero-based media in-point by playback rate", () => { + const mediaSource = `
`; + + const result = splitElementInHtml(mediaSource, { id: "media" }, 3, "media-split"); + const { document } = parseHTML(result.html); + + expect(document.getElementById("media-split")?.getAttribute("data-media-start")).toBe("4"); + }); + + it("does not add a media in-point to non-media elements", () => { + const result = splitElementInHtml(source, { id: "box" }, 3, "box-split"); + + expect(result.html).not.toContain("data-media-start"); + expect(result.html).not.toContain("data-playback-start"); + }); + it("stamps a legacy composition offset and advances the second half by playback rate", () => { const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", { start: 1, diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index e6413b4ada..d3d42d3e2b 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Hono } from "hono"; +import { parseHTML } from "linkedom"; import { existsSync, mkdirSync, @@ -107,6 +108,9 @@ function postCutBatch( splitTime: number; elementStart: number; elementDuration: number; + playbackStart?: number; + playbackRate?: number; + isComposition?: boolean; }>; }>, ): Promise { @@ -851,6 +855,55 @@ describe("registerFileRoutes", () => { }); }); + it("persists media in-points for audio and video in an atomic split-all cut", async () => { + const projectDir = createProjectDir(); + const before = + '' + + ''; + writeFileSync(join(projectDir, "index.html"), before); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postCutBatch(app, [ + { + path: "index.html", + expectedVersion: fileContentVersion(before), + targets: [ + { + target: { id: "audio" }, + originalId: "audio", + splitTime: 2, + elementStart: 0, + elementDuration: 6, + playbackStart: 0, + playbackRate: 1, + }, + { + target: { id: "video" }, + originalId: "video", + splitTime: 2, + elementStart: 0, + elementDuration: 6, + playbackStart: 0, + playbackRate: 2, + }, + ], + }, + ]); + const payload = (await response.json()) as { + files: Array<{ after: string; splitCount: number }>; + }; + const { document } = parseHTML(payload.files[0].after); + + expect(response.status).toBe(200); + expect(payload.files[0].splitCount).toBe(2); + expect(document.getElementById("audio")?.getAttribute("data-media-start")).toBe("0"); + expect(document.getElementById("audio-split")?.getAttribute("data-media-start")).toBe("2"); + expect(document.getElementById("video")?.getAttribute("data-media-start")).toBe("0"); + expect(document.getElementById("video-split")?.getAttribute("data-media-start")).toBe("4"); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after); + }); + it("cuts multiple id-less selector targets against their original indices", async () => { const projectDir = createProjectDir(); const before =