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:
Miguel Ángel
2026-06-25 19:16:27 -04:00
committed by GitHub
parent 764aa02a3a
commit 92385711dc
8 changed files with 195 additions and 11 deletions
+21 -2
View File
@@ -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