Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/studio-server/src/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!DOCTYPE html><html><body><div data-composition-id="root"><${tag} id="media" class="clip" src="asset.mp4" data-start="1" data-duration="6"></${tag}></div></body></html>`;

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 = `<!DOCTYPE html><html><body><div data-composition-id="root"><video id="media" class="clip" src="asset.mp4" data-start="1" data-duration="6" data-playback-rate="2"></video></div></body></html>`;

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,
Expand Down
53 changes: 53 additions & 0 deletions packages/studio-server/src/routes/files.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { parseHTML } from "linkedom";
import {
existsSync,
mkdirSync,
Expand Down Expand Up @@ -107,6 +108,9 @@ function postCutBatch(
splitTime: number;
elementStart: number;
elementDuration: number;
playbackStart?: number;
playbackRate?: number;
isComposition?: boolean;
}>;
}>,
): Promise<Response> {
Expand Down Expand Up @@ -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 =
'<audio id="audio" src="voice.mp3" data-start="0" data-duration="6"></audio>' +
'<video id="video" src="clip.mp4" data-start="0" data-duration="6" data-playback-rate="2"></video>';
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 =
Expand Down
Loading