diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 47f23bc19e..13a86b0901 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -4,7 +4,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { applyResolutionPreset, injectTailwindBrowserScript } from "./init.js"; +import { + applyResolutionPreset, + injectTailwindBrowserScript, + resolveVideoDurationSeconds, +} from "./init.js"; const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); const tailwindScript = @@ -177,6 +181,26 @@ describe("hyperframes init flag rename", () => { } }); + it("uses the video stream duration when audio outlasts the final video frame", () => { + expect( + resolveVideoDurationSeconds({ + streamDuration: 1, + frameDuration: 1, + formatDuration: 1.2, + }), + ).toBe(1); + }); + + 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", () => { 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..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 // --------------------------------------------------------------------------- @@ -103,6 +119,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 +142,18 @@ 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 = resolveVideoDurationSeconds({ + streamDuration, + frameDuration, + formatDuration, + }); 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..4cd177442b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -325,6 +325,33 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("hidden"); }); + 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"); + 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 - 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 () => { 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..63843be906 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -250,6 +250,28 @@ describe("syncRuntimeMedia", () => { expect(clip.el.play).toHaveBeenCalled(); }); + 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 - 1e-9, + playing: true, + playbackRate: 1, + }); + expect(clip.el.play).toHaveBeenCalledTimes(1); + + 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)", () => { // 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) { diff --git a/packages/producer/tests/style-7-prod/output/output.mp4 b/packages/producer/tests/style-7-prod/output/output.mp4 index 42facdf17d..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:07aebceb20c5963e7a2feb392a564e2f592c39490dba1f6af07bb08b6a3c63d8 -size 12267006 +oid sha256:74c710f61ae3400ecc5477e9ab42fa78c25340e6947b8dc5e0a916da166620e4 +size 12282546