fix(studio): preserve media offsets when splitting clips (#3272)

This commit is contained in:
Miguel Ángel
2026-08-14 09:47:21 -04:00
committed by GitHub
parent 532caf7aa2
commit b1b368d0f0
3 changed files with 89 additions and 2 deletions
@@ -367,14 +367,18 @@ export function splitElementInHtml(
// Keep the "clip" class — the runtime uses it to control visibility // Keep the "clip" class — the runtime uses it to control visibility
// based on data-start/data-duration timing. // 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") const playbackStartAttr = el.hasAttribute("data-playback-start")
? "data-playback-start" ? "data-playback-start"
: el.hasAttribute("data-media-start") : el.hasAttribute("data-media-start")
? "data-media-start" ? "data-media-start"
: fallbackTiming?.stampPlaybackStart : fallbackTiming?.stampPlaybackStart
? "data-playback-start" ? "data-playback-start"
: null; : el.matches("audio, video")
? "data-media-start"
: null;
if (playbackStartAttr) { if (playbackStartAttr) {
const currentTrim = const currentTrim =
parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0; parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0;
@@ -123,6 +123,36 @@ describe("splitElementInHtml", () => {
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/); 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", () => { it("stamps a legacy composition offset and advances the second half by playback rate", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", { const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", {
start: 1, start: 1,
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono"; import { Hono } from "hono";
import { parseHTML } from "linkedom";
import { import {
existsSync, existsSync,
mkdirSync, mkdirSync,
@@ -107,6 +108,9 @@ function postCutBatch(
splitTime: number; splitTime: number;
elementStart: number; elementStart: number;
elementDuration: number; elementDuration: number;
playbackStart?: number;
playbackRate?: number;
isComposition?: boolean;
}>; }>;
}>, }>,
): Promise<Response> { ): Promise<Response> {
@@ -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 () => { it("cuts multiple id-less selector targets against their original indices", async () => {
const projectDir = createProjectDir(); const projectDir = createProjectDir();
const before = const before =