mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
fix(studio): preserve media offsets when splitting clips (#3272)
This commit is contained in:
@@ -367,13 +367,17 @@ 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"
|
||||||
|
: el.matches("audio, video")
|
||||||
|
? "data-media-start"
|
||||||
: null;
|
: null;
|
||||||
if (playbackStartAttr) {
|
if (playbackStartAttr) {
|
||||||
const currentTrim =
|
const currentTrim =
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user