mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
fix(video): hold final frame through composition
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user