From 7e29907342bca6439d067d57f1132a9ff4652e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 17:08:11 +0000 Subject: [PATCH 1/5] fix(cli): align video output boundaries --- packages/cli/src/commands/init.test.ts | 51 ++++++++++++++++++++++++- packages/cli/src/commands/init.ts | 17 +++++++-- packages/core/src/runtime/init.test.ts | 21 ++++++++++ packages/core/src/runtime/init.ts | 4 +- packages/core/src/runtime/media.test.ts | 9 +++++ packages/core/src/runtime/media.ts | 2 +- 6 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 47f23bc19e..c784f1959f 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; -import { spawnSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { applyResolutionPreset, injectTailwindBrowserScript } from "./init.js"; +import { findFFmpeg } from "../browser/ffmpeg.js"; const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); const tailwindScript = @@ -177,6 +178,54 @@ describe("hyperframes init flag rename", () => { } }); + it("uses the video stream duration when audio outlasts the final video frame", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); + const target = join(dir, "proj"); + const source = join(dir, "source.mp4"); + try { + const ffmpeg = findFFmpeg(); + expect(ffmpeg).toBeDefined(); + execFileSync( + ffmpeg as string, + [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=black:s=320x240:r=30:d=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1.2", + "-c:v", + "libx264", + "-c:a", + "aac", + source, + ], + { stdio: "ignore" }, + ); + + const res = runInit([ + target, + "--example", + "blank", + "--non-interactive", + "--skip-skills", + "--skip-transcribe", + "--video", + source, + ]); + + expect(res.status).toBe(0); + const html = readFileSync(join(target, "index.html"), "utf-8"); + expect(html).toContain('data-duration="1"'); + expect(html).not.toContain('data-duration="1.2"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("--audio with a missing file fails without creating the project directory", () => { const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); const target = join(dir, "proj"); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 7b058673ec..dfebc3391d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -103,6 +103,8 @@ function probeVideo(filePath: string): VideoMeta | undefined { height?: number; r_frame_rate?: string; avg_frame_rate?: string; + duration?: string; + nb_frames?: string; }[]; format?: { duration?: string }; } = JSON.parse(raw); @@ -124,11 +126,20 @@ function probeVideo(filePath: string): VideoMeta | undefined { } } - const durationStr = parsed.format?.duration; - const durationSeconds = durationStr !== undefined ? parseFloat(durationStr) : 5; + const streamDuration = parseFloat(videoStream.duration ?? ""); + const frameCount = parseInt(videoStream.nb_frames ?? "", 10); + const frameDuration = Number.isFinite(frameCount) && fps > 0 ? frameCount / fps : NaN; + const formatDuration = parseFloat(parsed.format?.duration ?? ""); + const durationSeconds = Number.isFinite(streamDuration) + ? streamDuration + : Number.isFinite(frameDuration) + ? frameDuration + : Number.isFinite(formatDuration) + ? formatDuration + : 5; return { - durationSeconds: Number.isNaN(durationSeconds) ? 5 : durationSeconds, + durationSeconds, width: videoStream.width ?? 1920, height: videoStream.height ?? 1080, fps, diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 340456850c..e1cb7cda30 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -325,6 +325,27 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("hidden"); }); + it("hides a timed element at its exact end boundary", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const clip = document.createElement("div"); + clip.setAttribute("data-start", "0"); + clip.setAttribute("data-duration", "2.5"); + root.appendChild(clip); + + window.__timelines = { main: createMockTimeline(5) }; + initSandboxRuntimeModular(); + + window.__player?.renderSeek(2.5); + expect(clip.style.visibility).toBe("hidden"); + }); + it("keeps external composition hosts visible through their authored duration", async () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 24fb83cb57..d2317f6e59 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -583,7 +583,7 @@ export function initSandboxRuntimeModular(): void { const computedEnd = duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY; return ( - currentTime >= start && (Number.isFinite(computedEnd) ? currentTime <= computedEnd : true) + currentTime >= start && (Number.isFinite(computedEnd) ? currentTime < computedEnd : true) ); }; @@ -2813,7 +2813,7 @@ export function initSandboxRuntimeModular(): void { const mediaStart = Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0; - if (Number.isFinite(start) && state.currentTime >= start && state.currentTime <= end) { + if (Number.isFinite(start) && state.currentTime >= start && state.currentTime < end) { if (!rawEl.paused) { clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart }); foundActive = true; diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index bd15db9942..50bd82005e 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -250,6 +250,15 @@ describe("syncRuntimeMedia", () => { expect(clip.el.play).toHaveBeenCalled(); }); + it("does not play a clip at its exact end boundary", () => { + const clip = createMockClip({ start: 0, end: 2.5 }); + Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); + + syncRuntimeMedia({ clips: [clip], timeSeconds: 2.5, playing: true, playbackRate: 1 }); + + expect(clip.el.play).not.toHaveBeenCalled(); + }); + it("plays synchronously even when media is unbuffered (preserves user gesture)", () => { // Calling play() synchronously inside the user-gesture call chain lets the // browser queue playback until data buffers, while consuming the transient diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index aa8e1b71da..2214588558 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -173,7 +173,7 @@ export function syncRuntimeMedia(params: { // (el.ended resets to false when the user scrubs back, so seeks work.) const isActive = params.timeSeconds >= clip.start && - params.timeSeconds <= clip.end && + params.timeSeconds < clip.end && relTime >= 0 && (!el.ended || clip.loop); if (isActive) { From 450a357ab4b0b5eba9043848779c67b9be311c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 17:16:57 +0000 Subject: [PATCH 2/5] test(cli): honor CI ffmpeg fixture path --- packages/cli/src/commands/init.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index c784f1959f..1615650c7e 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -183,7 +183,7 @@ describe("hyperframes init flag rename", () => { const target = join(dir, "proj"); const source = join(dir, "source.mp4"); try { - const ffmpeg = findFFmpeg(); + const ffmpeg = process.env.FFMPEG_BIN ?? findFFmpeg(); expect(ffmpeg).toBeDefined(); execFileSync( ffmpeg as string, From 09cb2ef24c4059ef71b2573e035c8fe81bc0ab6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 17:45:18 +0000 Subject: [PATCH 3/5] test(cli): decouple duration precedence from ffmpeg --- packages/cli/src/commands/init.test.ts | 69 ++++++++------------------ packages/cli/src/commands/init.ts | 28 ++++++++--- 2 files changed, 43 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 1615650c7e..13a86b0901 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { applyResolutionPreset, injectTailwindBrowserScript } from "./init.js"; -import { findFFmpeg } from "../browser/ffmpeg.js"; +import { + applyResolutionPreset, + injectTailwindBrowserScript, + resolveVideoDurationSeconds, +} from "./init.js"; const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); const tailwindScript = @@ -179,51 +182,23 @@ describe("hyperframes init flag rename", () => { }); it("uses the video stream duration when audio outlasts the final video frame", () => { - const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); - const target = join(dir, "proj"); - const source = join(dir, "source.mp4"); - try { - const ffmpeg = process.env.FFMPEG_BIN ?? findFFmpeg(); - expect(ffmpeg).toBeDefined(); - execFileSync( - ffmpeg as string, - [ - "-y", - "-f", - "lavfi", - "-i", - "color=c=black:s=320x240:r=30:d=1", - "-f", - "lavfi", - "-i", - "sine=frequency=440:duration=1.2", - "-c:v", - "libx264", - "-c:a", - "aac", - source, - ], - { stdio: "ignore" }, - ); - - const res = runInit([ - target, - "--example", - "blank", - "--non-interactive", - "--skip-skills", - "--skip-transcribe", - "--video", - source, - ]); + expect( + resolveVideoDurationSeconds({ + streamDuration: 1, + frameDuration: 1, + formatDuration: 1.2, + }), + ).toBe(1); + }); - expect(res.status).toBe(0); - const html = readFileSync(join(target, "index.html"), "utf-8"); - expect(html).toContain('data-duration="1"'); - expect(html).not.toContain('data-duration="1.2"'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + it("falls through unusable stream durations before using the container duration", () => { + expect( + resolveVideoDurationSeconds({ + streamDuration: 0, + frameDuration: Number.NaN, + formatDuration: 1.2, + }), + ).toBe(1.2); }); it("--audio with a missing file fails without creating the project directory", () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index dfebc3391d..921320d68d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -81,6 +81,22 @@ const TAILWIND_BROWSER_SRC = `https://cdn.jsdelivr.net/npm/@tailwindcss/browser@ const TAILWIND_BROWSER_INTEGRITY = "sha384-v5YF9xS+gLRWdvrQ0u/WRbCkjSIH0NjHIPe8tBL1ZRrmI7PiSH6LLdzs0aAIMCuh"; +export function resolveVideoDurationSeconds({ + streamDuration, + frameDuration, + formatDuration, +}: { + streamDuration: number; + frameDuration: number; + formatDuration: number; +}): number { + return ( + [streamDuration, frameDuration, formatDuration].find( + (duration) => Number.isFinite(duration) && duration > 0, + ) ?? DEFAULT_META.durationSeconds + ); +} + // --------------------------------------------------------------------------- // ffprobe helper — shells out to ffprobe to avoid engine dependency // --------------------------------------------------------------------------- @@ -130,13 +146,11 @@ function probeVideo(filePath: string): VideoMeta | undefined { const frameCount = parseInt(videoStream.nb_frames ?? "", 10); const frameDuration = Number.isFinite(frameCount) && fps > 0 ? frameCount / fps : NaN; const formatDuration = parseFloat(parsed.format?.duration ?? ""); - const durationSeconds = Number.isFinite(streamDuration) - ? streamDuration - : Number.isFinite(frameDuration) - ? frameDuration - : Number.isFinite(formatDuration) - ? formatDuration - : 5; + const durationSeconds = resolveVideoDurationSeconds({ + streamDuration, + frameDuration, + formatDuration, + }); return { durationSeconds, From 6fca3d5304bc08c27ab24969bc073b2df0a1adbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 18:27:20 +0000 Subject: [PATCH 4/5] test(cli): pin half-open video boundaries --- packages/core/src/runtime/init.test.ts | 8 +++++++- packages/core/src/runtime/media.test.ts | 19 ++++++++++++++++--- .../tests/style-7-prod/output/output.mp4 | 4 ++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e1cb7cda30..4cd177442b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -325,7 +325,7 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("hidden"); }); - it("hides a timed element at its exact end boundary", () => { + it("uses a half-open interval around a timed element's end boundary", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); @@ -342,8 +342,14 @@ describe("initSandboxRuntimeModular", () => { window.__timelines = { main: createMockTimeline(5) }; initSandboxRuntimeModular(); + window.__player?.renderSeek(2.5 - 1e-9); + expect(clip.style.visibility).toBe("visible"); + window.__player?.renderSeek(2.5); expect(clip.style.visibility).toBe("hidden"); + + window.__player?.renderSeek(2.5 + 1e-9); + expect(clip.style.visibility).toBe("hidden"); }); it("keeps external composition hosts visible through their authored duration", async () => { diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 50bd82005e..63843be906 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -250,13 +250,26 @@ describe("syncRuntimeMedia", () => { expect(clip.el.play).toHaveBeenCalled(); }); - it("does not play a clip at its exact end boundary", () => { + it("uses a half-open interval around a clip's end boundary", () => { const clip = createMockClip({ start: 0, end: 2.5 }); Object.defineProperty(clip.el, "readyState", { value: 4, writable: true }); - syncRuntimeMedia({ clips: [clip], timeSeconds: 2.5, playing: true, playbackRate: 1 }); + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 2.5 - 1e-9, + playing: true, + playbackRate: 1, + }); + expect(clip.el.play).toHaveBeenCalledTimes(1); - expect(clip.el.play).not.toHaveBeenCalled(); + syncRuntimeMedia({ clips: [clip], timeSeconds: 2.5, playing: true, playbackRate: 1 }); + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 2.5 + 1e-9, + playing: true, + playbackRate: 1, + }); + expect(clip.el.play).toHaveBeenCalledTimes(1); }); it("plays synchronously even when media is unbuffered (preserves user gesture)", () => { diff --git a/packages/producer/tests/style-7-prod/output/output.mp4 b/packages/producer/tests/style-7-prod/output/output.mp4 index 42facdf17d..68c486890f 100644 --- a/packages/producer/tests/style-7-prod/output/output.mp4 +++ b/packages/producer/tests/style-7-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07aebceb20c5963e7a2feb392a564e2f592c39490dba1f6af07bb08b6a3c63d8 -size 12267006 +oid sha256:b12ea192c885d09981383abac2d1599bef689593c40c57435775bd025307144c +size 12283323 From 491ed0235f8cffd6eae4df7afab3690d0799191a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 19:22:38 +0000 Subject: [PATCH 5/5] test(producer): refresh style 7 boundary golden --- packages/producer/tests/style-7-prod/output/output.mp4 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/producer/tests/style-7-prod/output/output.mp4 b/packages/producer/tests/style-7-prod/output/output.mp4 index 68c486890f..114442522e 100644 --- a/packages/producer/tests/style-7-prod/output/output.mp4 +++ b/packages/producer/tests/style-7-prod/output/output.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b12ea192c885d09981383abac2d1599bef689593c40c57435775bd025307144c -size 12283323 +oid sha256:74c710f61ae3400ecc5477e9ab42fa78c25340e6947b8dc5e0a916da166620e4 +size 12282546