fix(video): hold final frame through composition

This commit is contained in:
Miguel Ángel
2026-07-16 02:15:01 +00:00
parent 71d7be7921
commit 2e8f871bc8
15 changed files with 352 additions and 90 deletions
@@ -12,11 +12,39 @@ describe("compileHtml", () => {
expect(compiled).toContain('data-end="4"');
});
it("still clamps non-looping media durations to source duration", async () => {
it("preserves an explicit non-looping video slot past source end", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0" data-duration="4" data-end="4">';
const compiled = await compileHtml(html, "/project", async () => 3.125);
expect(compiled).toContain('data-duration="4"');
expect(compiled).toContain('data-end="4"');
});
it("uses natural duration for a video without an explicit slot inside a composition", async () => {
const html =
'<div data-composition-id="root" data-start="0" data-duration="5">' +
'<video id="hero" src="hero.webm" data-start="0">' +
"</div>";
const compiled = await compileHtml(html, "/project", async () => 1);
expect(compiled).toContain('data-duration="1"');
expect(compiled).toContain('data-end="1"');
});
it("uses natural duration for a standalone video without a composition window", async () => {
const html = '<video id="hero" src="hero.webm" data-start="0">';
const compiled = await compileHtml(html, "/project", async () => 1);
expect(compiled).toContain('data-duration="1"');
expect(compiled).toContain('data-end="1"');
});
it("still clamps non-looping audio durations to source duration", async () => {
const html = '<audio id="voice" src="voice.wav" data-start="0" data-duration="4" data-end="4">';
const compiled = await compileHtml(html, "/project", async () => 3.125);
expect(compiled).toContain('data-duration="3.125"');
expect(compiled).toContain('data-end="3.125"');
});
+8 -8
View File
@@ -4,7 +4,7 @@ import {
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
shouldClampResolvedMediaDuration,
type ResolvedDuration,
} from "./timingCompiler";
@@ -23,7 +23,8 @@ function resolveMediaSrc(src: string, projectDir: string): string {
*
* 1. Static pass: compileTimingAttrs() adds data-end where data-duration exists
* 2. For unresolved video/audio (no data-duration): probe via probeMediaDuration, inject durations
* 3. For pre-resolved video/audio: validate data-duration against actual source, clamp if needed
* 3. For pre-resolved audio: clamp data-duration to playable source when needed.
* Explicit video slots are preserved and hold their final frame.
*
* @param rawHtml - The raw HTML string
* @param projectDir - The project directory for resolving relative paths
@@ -54,10 +55,8 @@ export async function compileHtml(
if (fileDuration <= 0) continue;
const effectiveDuration = fileDuration - el.mediaStart;
resolutions.push({
id: el.id,
duration: effectiveDuration > 0 ? effectiveDuration : fileDuration,
});
const sourceDuration = effectiveDuration > 0 ? effectiveDuration : fileDuration;
resolutions.push({ id: el.id, duration: sourceDuration });
}
if (resolutions.length > 0) {
@@ -65,7 +64,8 @@ export async function compileHtml(
}
}
// Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration
// Phase 2: Bound authored audio to playable source. Explicit video slots may
// outlive their source and render by holding the final frame.
const preResolved = extractResolvedMedia(html);
const clampList: ResolvedDuration[] = [];
@@ -77,7 +77,7 @@ export async function compileHtml(
if (fileDuration <= 0) continue;
const maxDuration = fileDuration - el.mediaStart;
if (maxDuration > 0 && shouldClampMediaDuration(el.duration, maxDuration)) {
if (maxDuration > 0 && shouldClampResolvedMediaDuration(el.tagName, el.duration, maxDuration)) {
clampList.push({ id: el.id, duration: maxDuration });
}
}
+1
View File
@@ -17,6 +17,7 @@ export {
extractResolvedMedia,
clampDurations,
shouldClampMediaDuration,
shouldClampResolvedMediaDuration,
type UnresolvedElement,
type ResolvedDuration,
type ResolvedMediaElement,
@@ -6,6 +6,7 @@ import {
injectDurations,
extractResolvedMedia,
clampDurations,
shouldClampResolvedMediaDuration,
} from "./timingCompiler.js";
// Raw 0x00 bytes in the HFMASK delimiters shipped once and broke every render
@@ -264,3 +265,10 @@ describe("clampDurations", () => {
expect(result).toContain('data-end="7"');
});
});
describe("shouldClampResolvedMediaDuration", () => {
it("preserves an explicit video slot but keeps audio source-bounded", () => {
expect(shouldClampResolvedMediaDuration("video", 5, 1)).toBe(false);
expect(shouldClampResolvedMediaDuration("audio", 5, 1)).toBe(true);
});
});
+17 -3
View File
@@ -48,15 +48,29 @@ export interface CompilationResult {
unresolved: UnresolvedElement[];
}
// ffprobe precision can differ slightly across local and CI media stacks. Also
// the floor for the engine's hold-last-frame tolerance (a slot left unclamped is
// short by at most this), so they must move together.
// ffprobe precision can differ slightly across local and CI media stacks, so
// avoid shortening authored audio for insignificant probe drift.
export const MEDIA_DURATION_CLAMP_EPSILON_SECONDS = 0.05;
export function shouldClampMediaDuration(declaredDuration: number, maxDuration: number): boolean {
return declaredDuration > maxDuration + MEDIA_DURATION_CLAMP_EPSILON_SECONDS;
}
/**
* Whether compilation should shorten an authored media slot to its source.
*
* Non-looping video intentionally keeps an explicit longer slot: browsers and
* the render frame injector hold its final frame until that authored slot ends.
* Audio has no frame to hold, so its slot remains bounded by playable source.
*/
export function shouldClampResolvedMediaDuration(
tagName: ResolvedMediaElement["tagName"],
declaredDuration: number,
maxDuration: number,
): boolean {
return tagName === "audio" && shouldClampMediaDuration(declaredDuration, maxDuration);
}
// ── Helpers ──────────────────────────────────────────────────────────────
function getAttr(tag: string, attr: string): string | null {