mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 15:49:53 +00:00
fix(studio): preserve media offsets when splitting clips (#3272)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 () => {
|
||||
const projectDir = createProjectDir();
|
||||
const before =
|
||||
|
||||
Reference in New Issue
Block a user