diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bc88c182fb..da5bbe1e34 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1364,6 +1364,12 @@ npx hyperframes timeline delete '#outro' --start 6 --end 7 # cut one second ou | `undo ` | 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 diff --git a/packages/cli/src/timeline/a2Shared.ts b/packages/cli/src/timeline/a2Shared.ts index a71fb2627e..acce76684d 100644 --- a/packages/cli/src/timeline/a2Shared.ts +++ b/packages/cli/src/timeline/a2Shared.ts @@ -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" }; diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index 3abbe6c1c4..b58143c596 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -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 = `
+ + +
+
`; + 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 = `
+ + + + +
`; + 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 { diff --git a/packages/studio-server/src/helpers/duplicateElement.test.ts b/packages/studio-server/src/helpers/duplicateElement.test.ts index 9e445d1ace..0e87bb80de 100644 --- a/packages/studio-server/src/helpers/duplicateElement.test.ts +++ b/packages/studio-server/src/helpers/duplicateElement.test.ts @@ -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 = `
+ + + +
`; + + 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 = `
+
+ +
`; + + 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 = `
+ +
`; + + 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 = `
+ + + +
`; + + 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 = `