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:
Akshar Patel
2026-10-25 23:01:27 +00:00
committed by GitHub
parent fc625ac971
commit 6a20ec9e9a
5 changed files with 213 additions and 5 deletions
+6
View File
@@ -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
+1
View File
@@ -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]"))) {