mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): preserve clip lengths when duplicating media (#6280)
* fix(cli): prevent overlaps when duplicating natural-duration media * fix(cli): preserve absolute ends when duplicating clips * fix(cli): keep rippled clip bounds together
This commit is contained in:
@@ -1364,6 +1364,12 @@ npx hyperframes timeline delete '#outro' --start 6 --end 7 # cut one second ou
|
||||
| `undo <receipt>` | Restore the files an edit changed, from its receipt (the JSON or its file) |
|
||||
| `ids` | Stamp stable ids on the timeline's clips |
|
||||
|
||||
`duplicate` shifts clips at or after the insertion time on the same track by
|
||||
the copied clip's length. Video and audio clips can use their source length
|
||||
without `data-duration`, including clips shifted by the insertion.
|
||||
For clips with `data-end`, the copy and any shifted clips keep their length by
|
||||
moving their end with their start.
|
||||
|
||||
A time is seconds (`4.5`), an offset from the clip's start (`+1`, `-0.5`),
|
||||
frames (`30f`), `end`, or an anchor on another clip: `after:#intro`,
|
||||
`before:#intro`, `start-of:#intro` or `end-of:#intro`. A flag an edit command does not
|
||||
|
||||
@@ -462,6 +462,7 @@ function duplicateMutation(
|
||||
context.resolved.target,
|
||||
`${splitBaseId(context.row)}-copy`,
|
||||
time.seconds,
|
||||
context.row.duration,
|
||||
);
|
||||
if (!duplicate.matched) {
|
||||
return { ok: false, reason: `${context.ref} was not found`, fix: "choose an existing clip" };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -626,6 +627,130 @@ describe("timeline edit command", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(spawnSync("ffprobe", ["-version"]).status !== 0).each([
|
||||
{ name: "natural source", sourceDuration: "", neighbourDuration: 'data-duration="1"' },
|
||||
{ name: "natural neighbour", sourceDuration: 'data-duration="1"', neighbourDuration: "" },
|
||||
{ name: "both natural", sourceDuration: "", neighbourDuration: "" },
|
||||
{
|
||||
name: "explicit durations",
|
||||
sourceDuration: 'data-duration="1"',
|
||||
neighbourDuration: 'data-duration="1"',
|
||||
},
|
||||
{
|
||||
name: "absolute end shorter than the source",
|
||||
sourceDuration: 'data-end="1.5"',
|
||||
neighbourDuration: 'data-duration="1"',
|
||||
duration: 0.5,
|
||||
},
|
||||
])(
|
||||
"duplicates with resolved timing and ripples a $name",
|
||||
({ sourceDuration, neighbourDuration, duration = 1 }) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-timeline-duplicate-media-"));
|
||||
try {
|
||||
copyFileSync(
|
||||
resolve(cliEntry, "..", "commands", "__fixtures__", "silent.mp4"),
|
||||
join(dir, "silent.mp4"),
|
||||
);
|
||||
const indexPath = join(dir, "index.html");
|
||||
const source = `<div data-composition-id="main" data-duration="12">
|
||||
<video id="clip" src="silent.mp4" data-start="1" ${sourceDuration} data-track-index="0" muted></video>
|
||||
<video id="neighbour" src="silent.mp4" data-start="${1 + duration}" ${neighbourDuration} data-track-index="0" muted></video>
|
||||
<div id="other-track" data-start="2" data-duration="1" data-track-index="1"></div>
|
||||
</div>`;
|
||||
writeFileSync(indexPath, source);
|
||||
const planned = run(dir, "duplicate", "#clip", "--plan");
|
||||
expect(planned.status, planned.stderr).toBe(0);
|
||||
expect(readFileSync(indexPath, "utf8")).toBe(source);
|
||||
const expectedRows = [
|
||||
expect.objectContaining({ ref: "#clip", start: 1, duration, end: 1 + duration }),
|
||||
expect.objectContaining({
|
||||
ref: "#clip-copy",
|
||||
start: 1 + duration,
|
||||
duration,
|
||||
end: 1 + 2 * duration,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ref: "#neighbour",
|
||||
start: 1 + 2 * duration,
|
||||
duration: 1,
|
||||
end: 2 + 2 * duration,
|
||||
}),
|
||||
expect.objectContaining({ ref: "#other-track", start: 2, duration: 1, end: 3 }),
|
||||
];
|
||||
const plan = JSON.parse(planned.stdout);
|
||||
expect(plan.after).toEqual(expect.arrayContaining(expectedRows));
|
||||
const applied = run(dir, "duplicate", "#clip");
|
||||
expect(applied.status, applied.stderr).toBe(0);
|
||||
expect(JSON.parse(applied.stdout).after).toEqual(plan.after);
|
||||
const reopened = spawnSync("bun", ["run", cliEntry, "timeline", dir, "--json"], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, HYPERFRAMES_SKIP_UPDATE_CHECK: "1" },
|
||||
});
|
||||
expect(reopened.status, reopened.stderr).toBe(0);
|
||||
expect(JSON.parse(reopened.stdout)).toMatchObject({
|
||||
timeline: {
|
||||
tracks: expect.arrayContaining([
|
||||
expect.objectContaining({ rows: expect.arrayContaining(expectedRows.slice(0, 3)) }),
|
||||
expect.objectContaining({ rows: expect.arrayContaining([expectedRows[3]]) }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(spawnSync("ffprobe", ["-version"]).status !== 0)(
|
||||
"preserves a bounded neighbour's duration when rippling past a third clip",
|
||||
() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-timeline-duplicate-bounded-"));
|
||||
try {
|
||||
copyFileSync(
|
||||
resolve(cliEntry, "..", "commands", "__fixtures__", "silent.mp4"),
|
||||
join(dir, "silent.mp4"),
|
||||
);
|
||||
const indexPath = join(dir, "index.html");
|
||||
const source = `<div data-composition-id="main" data-duration="12">
|
||||
<video id="clip" src="silent.mp4" data-start="1" data-duration="1" data-track-index="0" muted></video>
|
||||
<video id="next" src="silent.mp4" data-start="2" data-end="2.5" data-track-index="0" muted></video>
|
||||
<video id="third" src="silent.mp4" data-start="2.5" data-duration="1" data-track-index="0" muted></video>
|
||||
<video id="other" src="silent.mp4" data-start="2" data-end="2.5" data-track-index="1" muted></video>
|
||||
</div>`;
|
||||
writeFileSync(indexPath, source);
|
||||
const expectedRows = [
|
||||
expect.objectContaining({ ref: "#clip-copy", start: 2, duration: 1, end: 3 }),
|
||||
expect.objectContaining({ ref: "#next", start: 3, duration: 0.5, end: 3.5 }),
|
||||
expect.objectContaining({ ref: "#third", start: 3.5, duration: 1, end: 4.5 }),
|
||||
expect.objectContaining({ ref: "#other", start: 2, duration: 0.5, end: 2.5 }),
|
||||
];
|
||||
const planned = run(dir, "duplicate", "#clip", "--plan");
|
||||
expect(planned.status, planned.stderr).toBe(0);
|
||||
expect(readFileSync(indexPath, "utf8")).toBe(source);
|
||||
expect(JSON.parse(planned.stdout).after).toEqual(expect.arrayContaining(expectedRows));
|
||||
|
||||
const applied = run(dir, "duplicate", "#clip");
|
||||
expect(applied.status, applied.stderr).toBe(0);
|
||||
expect(JSON.parse(applied.stdout).after).toEqual(JSON.parse(planned.stdout).after);
|
||||
const reopened = spawnSync("bun", ["run", cliEntry, "timeline", dir, "--json"], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, HYPERFRAMES_SKIP_UPDATE_CHECK: "1" },
|
||||
});
|
||||
expect(reopened.status, reopened.stderr).toBe(0);
|
||||
const rows = JSON.parse(reopened.stdout).timeline.tracks.flatMap(
|
||||
(track: { rows: unknown[] }) => track.rows,
|
||||
);
|
||||
expect(rows).toEqual(expect.arrayContaining(expectedRows));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a trim that would leave a clip one float rounding step long", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-timeline-cli-"));
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { duplicateElementInHtml } from "./duplicateElement.js";
|
||||
import { parseSourceDocument } from "./sourceMutation.js";
|
||||
|
||||
describe("duplicateElementInHtml", () => {
|
||||
it("ripples a following natural-duration video by the resolved source clip length", () => {
|
||||
const source = `<div>
|
||||
<video id="clip" src="red.mp4" data-start="1" data-track-index="0"></video>
|
||||
<video id="next" src="blue.mp4" data-start="3" data-track-index="0"></video>
|
||||
<video id="other" src="blue.mp4" data-start="3" data-track-index="1"></video>
|
||||
</div>`;
|
||||
|
||||
const result = duplicateElementInHtml(source, { id: "clip" }, "clip-copy", 3, 2);
|
||||
const { document } = parseSourceDocument(result.html);
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(document.getElementById("clip")?.getAttribute("data-start")).toBe("1");
|
||||
expect(document.getElementById("clip-copy")?.getAttribute("data-start")).toBe("3");
|
||||
expect(document.getElementById("next")?.getAttribute("data-start")).toBe("5");
|
||||
expect(document.getElementById("other")?.getAttribute("data-start")).toBe("3");
|
||||
expect(document.getElementById("clip-copy")?.hasAttribute("data-duration")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, 7])("keeps an authored duration with fallback %s", (fallback) => {
|
||||
const source = `<div>
|
||||
<div id="clip" data-start="1" data-duration="2" data-track-index="0"></div>
|
||||
<div id="next" data-start="3" data-duration="2" data-track-index="0"></div>
|
||||
</div>`;
|
||||
|
||||
const result = duplicateElementInHtml(source, { id: "clip" }, "clip-copy", 3, fallback);
|
||||
const { document } = parseSourceDocument(result.html);
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(document.getElementById("next")?.getAttribute("data-start")).toBe("5");
|
||||
expect(document.getElementById("clip-copy")?.getAttribute("data-duration")).toBe("2");
|
||||
});
|
||||
|
||||
it.each([0, 4])("shifts the copied absolute end when inserted at %s", (at) => {
|
||||
const source = `<div>
|
||||
<video id="clip" src="red.mp4" data-start="1" data-end="2" data-track-index="0"></video>
|
||||
</div>`;
|
||||
|
||||
const result = duplicateElementInHtml(source, { id: "clip" }, "clip-copy", at, 1);
|
||||
const { document } = parseSourceDocument(result.html);
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(document.getElementById("clip")?.getAttribute("data-start")).toBe("1");
|
||||
expect(document.getElementById("clip")?.getAttribute("data-end")).toBe("2");
|
||||
expect(document.getElementById("clip-copy")?.getAttribute("data-start")).toBe(String(at));
|
||||
expect(document.getElementById("clip-copy")?.getAttribute("data-end")).toBe(String(at + 1));
|
||||
expect(document.getElementById("clip-copy")?.hasAttribute("data-duration")).toBe(false);
|
||||
});
|
||||
|
||||
it("shifts a rippled neighbour's absolute end on the same track", () => {
|
||||
const source = `<div>
|
||||
<video id="clip" data-start="1" data-duration="1" data-track-index="0"></video>
|
||||
<video id="next" data-start="2" data-end="3" data-track-index="0"></video>
|
||||
<video id="other" data-start="2" data-end="3" data-track-index="1"></video>
|
||||
</div>`;
|
||||
|
||||
const result = duplicateElementInHtml(source, { id: "clip" }, "clip-copy", 2);
|
||||
const { document } = parseSourceDocument(result.html);
|
||||
|
||||
expect(document.getElementById("next")?.getAttribute("data-start")).toBe("3");
|
||||
expect(document.getElementById("next")?.getAttribute("data-end")).toBe("4");
|
||||
expect(document.getElementById("other")?.getAttribute("data-start")).toBe("2");
|
||||
expect(document.getElementById("other")?.getAttribute("data-end")).toBe("3");
|
||||
});
|
||||
|
||||
it("duplicates a target inside a nested template", () => {
|
||||
const source = `<!doctype html><html><body>
|
||||
<template data-composition-id="outer"><template data-composition-id="inner">
|
||||
|
||||
@@ -20,13 +20,17 @@ export function duplicateElementInHtml(
|
||||
target: SourceMutationTarget,
|
||||
newId: string,
|
||||
at: number,
|
||||
fallbackDuration = 0,
|
||||
): DuplicateElementResult {
|
||||
const { document, wrappedFragment } = parseSourceDocument(source);
|
||||
const element = findTargetElement(document, target);
|
||||
if (!element || !element.parentElement) return { html: source, matched: false, newId: null };
|
||||
const duration = numericAttribute(element, "data-duration");
|
||||
const duration = element.hasAttribute("data-duration")
|
||||
? numericAttribute(element, "data-duration")
|
||||
: fallbackDuration;
|
||||
const start = numericAttribute(element, "data-start");
|
||||
const track = numericAttribute(element, "data-track-index") ?? 0;
|
||||
if (duration === null || numericAttribute(element, "data-start") === null) {
|
||||
if (duration === null || start === null) {
|
||||
return { html: source, matched: false, newId: null };
|
||||
}
|
||||
const uniqueId = nextUniqueId(document, newId);
|
||||
@@ -36,7 +40,7 @@ export function duplicateElementInHtml(
|
||||
clone.setAttribute("id", uniqueId);
|
||||
dedupeClonedCompositionId(document, clone);
|
||||
stripStableIds(clone);
|
||||
clone.setAttribute("data-start", String(at));
|
||||
moveElementStart(clone, start, at);
|
||||
element.parentElement.insertBefore(clone, element.nextSibling);
|
||||
const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
||||
return { html: ensureHfIds(html), matched: true, newId: uniqueId };
|
||||
@@ -49,7 +53,7 @@ function rippleElements(
|
||||
duration: number,
|
||||
track: number,
|
||||
): void {
|
||||
for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) {
|
||||
for (const candidate of Array.from(document.querySelectorAll("[data-start]"))) {
|
||||
if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) {
|
||||
continue;
|
||||
}
|
||||
@@ -57,10 +61,17 @@ function rippleElements(
|
||||
if (start === null) continue;
|
||||
const metInsertionPoint = sameInstant(start, at);
|
||||
if (start < at && !metInsertionPoint) continue;
|
||||
candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
|
||||
moveElementStart(candidate, start, (metInsertionPoint ? at : start) + duration);
|
||||
}
|
||||
}
|
||||
|
||||
function moveElementStart(element: Element, start: number, nextStart: number): void {
|
||||
element.setAttribute("data-start", String(nextStart));
|
||||
if (!element.hasAttribute("data-end")) return;
|
||||
const end = numericAttribute(element, "data-end");
|
||||
if (end !== null) element.setAttribute("data-end", String(end + (nextStart - start)));
|
||||
}
|
||||
|
||||
function stripStableIds(element: Element): void {
|
||||
element.removeAttribute("data-hf-id");
|
||||
for (const child of Array.from(element.querySelectorAll("[data-hf-id]"))) {
|
||||
|
||||
Reference in New Issue
Block a user