mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(engine): hold last frame when a clip's media is shorter than its slot (#1726)
Renders showed the page background (a one-frame black flash) right before a cut when a video clip's source media was a hair shorter than its data-duration slot — the common case, since `ffmpeg -t 1.45` emits 43 frames = 1.433s at 30fps. The frame lookup only held the last frame at the exact clip end, so the sub-frame remainder rendered blank. - Hold the last extracted frame for the rest of the slot once the source is exhausted, within a tolerance floored at the compiler's 0.05s clamp epsilon so the seam is covered at any fps (2 frames alone is < 0.05s above 40fps). Clips deliberately much shorter than their slot still blank for the tail (unchanged). - Warn when the compiler clamps a video's data-duration down to its media length (slot longer than source by more than the clamp epsilon): a render-time `[compile]` warning in the producer, plus a matching `validate` warning that reads each <video>'s live duration in headless Chrome (static HTML lint can't see media durations). A shared `analyzeClipMediaFit` keeps both on one threshold. Adds engine unit tests for the hold behavior and the analyzer.
This commit is contained in:
@@ -41,7 +41,7 @@ import type { Page } from "puppeteer-core";
|
||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
import { prepareAnimatedGifInputs } from "./animatedGifPrep.js";
|
||||
import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script";
|
||||
import { defaultLogger } from "../logger.js";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
|
||||
export interface CompiledComposition {
|
||||
html: string;
|
||||
@@ -209,6 +209,7 @@ async function compileHtmlFile(
|
||||
html: string,
|
||||
baseDir: string,
|
||||
downloadDir: string,
|
||||
log?: ProducerLogger,
|
||||
): Promise<{ html: string; unresolvedCompositions: UnresolvedElement[] }> {
|
||||
const { html: staticCompiled, unresolved } = compileTimingAttrs(html);
|
||||
|
||||
@@ -244,13 +245,25 @@ async function compileHtmlFile(
|
||||
downloadDir,
|
||||
el.tagName,
|
||||
);
|
||||
return { id: el.id, duration: el.duration, maxDuration, src: el.src! };
|
||||
return { id: el.id, tagName: el.tagName, duration: el.duration, maxDuration, src: el.src! };
|
||||
}),
|
||||
);
|
||||
const clampList: ResolvedDuration[] = [];
|
||||
for (const r of clampResults) {
|
||||
if (r.maxDuration > 0 && shouldClampMediaDuration(r.duration, r.maxDuration)) {
|
||||
clampList.push({ id: r.id, duration: r.maxDuration });
|
||||
// This clip's `data-duration` is being silently shortened to its source.
|
||||
// Surface it so the author can confirm the longer slot wasn't intended.
|
||||
// ponytail: top-level only — sub-composition clips still get clamped (and
|
||||
// videos still hold the last frame); thread `log` through
|
||||
// parseSubCompositions to warn for them too.
|
||||
const kind = r.tagName === "audio" ? "Audio" : "Video";
|
||||
log?.warn(
|
||||
`[compile] ${kind} "${r.id}" (${r.src}) is ${r.maxDuration.toFixed(2)}s but its ` +
|
||||
`data-duration is ${r.duration.toFixed(2)}s — the slot is shortened to the media ` +
|
||||
`length. Set data-duration to ~${r.maxDuration.toFixed(2)}s, trim data-media-start, ` +
|
||||
`or use a longer/looping source if that isn't intended.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1295,6 +1308,11 @@ async function embedLocalFontFaces(html: string, projectDir: string): Promise<st
|
||||
* additive; omitting `options` preserves the in-process renderer's defaults.
|
||||
*/
|
||||
export interface CompileForRenderOptions {
|
||||
/**
|
||||
* Logger for compile-time diagnostics (e.g. the data-duration vs. media
|
||||
* mismatch warning). Optional so non-render callers can omit it.
|
||||
*/
|
||||
log?: ProducerLogger;
|
||||
/**
|
||||
* Threaded through to {@link injectDeterministicFontFaces}. When `true`,
|
||||
* any external font fetch failure throws `FontFetchError` instead of
|
||||
@@ -1353,6 +1371,7 @@ export async function compileForRender(
|
||||
rawHtml,
|
||||
projectDir,
|
||||
downloadDir,
|
||||
options.log,
|
||||
);
|
||||
|
||||
// Parse sub-compositions first (extracts media + compiled HTML for each)
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
|
||||
|
||||
const compileStart = Date.now();
|
||||
const compiled = await compileForRender(projectDir, htmlPath, join(workDir, "downloads"), {
|
||||
log,
|
||||
failClosedFontFetch: failClosedFontFetch === true,
|
||||
allowSystemFontCapture,
|
||||
animatedGifCacheDir: cfg.extractCacheDir
|
||||
|
||||
Reference in New Issue
Block a user