mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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:
@@ -136,6 +136,7 @@ export {
|
||||
getFrameAtTime,
|
||||
createFrameLookupTable,
|
||||
FrameLookupTable,
|
||||
analyzeClipMediaFit,
|
||||
type VideoElement,
|
||||
type ImageElement,
|
||||
type ExtractedFrames,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
codecMayHaveAlpha,
|
||||
decoderForCodec,
|
||||
getFrameAtTime,
|
||||
analyzeClipMediaFit,
|
||||
type VideoElement,
|
||||
type ExtractedFrames,
|
||||
} from "./videoFrameExtractor.js";
|
||||
@@ -375,6 +376,33 @@ describe("FrameLookupTable", () => {
|
||||
expect(table.getActiveFramePayloads(5.0).get("hero")?.frameIndex).toBe(29);
|
||||
});
|
||||
|
||||
it("holds the last frame when the source is a sub-frame shorter than the slot", () => {
|
||||
// clip [2, 3.45] declares a 1.45s slot, but `ffmpeg -t 1.45` at 30fps emits
|
||||
// 43 frames = 1.433s — a half-frame short. The tail between source
|
||||
// exhaustion (~3.433) and the clip end (3.45) must hold the last frame
|
||||
// rather than render the page background (a one-frame black flash at the
|
||||
// cut). The held index is the final extracted frame (42).
|
||||
const table = createFrameLookupTable(
|
||||
[
|
||||
{
|
||||
id: "hero",
|
||||
src: "clip.mp4",
|
||||
start: 2,
|
||||
end: 3.45,
|
||||
mediaStart: 0,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
},
|
||||
],
|
||||
[fakeExtracted(43, 30)],
|
||||
);
|
||||
// last real frame
|
||||
expect(table.getActiveFramePayloads(3.4).get("hero")?.frameIndex).toBe(42);
|
||||
// source exhausted but within tolerance of the end → hold, don't blank
|
||||
expect(table.getActiveFramePayloads(3.44).get("hero")?.frameIndex).toBe(42);
|
||||
expect(table.getActiveFramePayloads(3.45).get("hero")?.frameIndex).toBe(42);
|
||||
});
|
||||
|
||||
it("keeps both clips active at a shared adjacent boundary, matching the runtime", () => {
|
||||
// clip A ends at 3.0, clip B starts at 3.0. The runtime shows both at the
|
||||
// shared instant; the active set must too.
|
||||
@@ -395,6 +423,35 @@ describe("FrameLookupTable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeClipMediaFit", () => {
|
||||
it("returns null for a sub-tolerance shortfall the compiler leaves unclamped", () => {
|
||||
// 1.433s media in a 1.45s slot — a sub-frame shortfall (<0.05s) the renderer
|
||||
// freezes seamlessly and the compiler never clamps. Not worth warning about.
|
||||
expect(analyzeClipMediaFit({ slotSeconds: 1.45, mediaSeconds: 1.433 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when media is longer than or equal to the slot", () => {
|
||||
expect(analyzeClipMediaFit({ slotSeconds: 2, mediaSeconds: 2 })).toBeNull();
|
||||
expect(analyzeClipMediaFit({ slotSeconds: 2, mediaSeconds: 5 })).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the shortfall when the slot exceeds media beyond the clamp epsilon", () => {
|
||||
const fit = analyzeClipMediaFit({ slotSeconds: 5, mediaSeconds: 1 });
|
||||
expect(fit).not.toBeNull();
|
||||
expect(fit?.shortfallSeconds).toBeCloseTo(4, 5);
|
||||
expect(fit?.toleranceSeconds).toBeCloseTo(0.05, 5);
|
||||
});
|
||||
|
||||
it("never flags looping clips (they repeat to fill the slot)", () => {
|
||||
expect(analyzeClipMediaFit({ slotSeconds: 5, mediaSeconds: 1, loop: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unusable inputs (non-finite media, zero slot)", () => {
|
||||
expect(analyzeClipMediaFit({ slotSeconds: 0, mediaSeconds: 1 })).toBeNull();
|
||||
expect(analyzeClipMediaFit({ slotSeconds: 5, mediaSeconds: NaN })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImageElements", () => {
|
||||
it("parses images with data-start and data-duration", () => {
|
||||
const images = parseImageElements(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { spawn } from "child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { isAbsolute, join, posix, resolve, sep } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { decodeUrlPathVariants } from "@hyperframes/core";
|
||||
import { decodeUrlPathVariants, MEDIA_DURATION_CLAMP_EPSILON_SECONDS } from "@hyperframes/core";
|
||||
import { trackChildProcess } from "../utils/processTracker.js";
|
||||
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||
import {
|
||||
@@ -963,6 +963,32 @@ export function getFrameAtTime(
|
||||
return extracted.framePaths.get(frameIndex) || null;
|
||||
}
|
||||
|
||||
const HOLD_LAST_FRAME_TOLERANCE_FRAMES = 2;
|
||||
|
||||
/**
|
||||
* Whether a clip's source is shorter than its `data-duration` slot by more than
|
||||
* the compiler tolerates before clamping the slot to the media
|
||||
* (MEDIA_DURATION_CLAMP_EPSILON_SECONDS) — the case worth warning about. Shared
|
||||
* by the render and `validate` warnings. `null` when the media covers the slot,
|
||||
* the clip loops, or inputs are unusable.
|
||||
*/
|
||||
export function analyzeClipMediaFit(params: {
|
||||
/** Timeline slot length in seconds — `end - start` (a.k.a. data-duration). */
|
||||
slotSeconds: number;
|
||||
/** Playable source media after the trim offset — `duration - mediaStart`. */
|
||||
mediaSeconds: number;
|
||||
/** Looping clips repeat to fill the slot, so they never fall short. */
|
||||
loop?: boolean;
|
||||
}): { shortfallSeconds: number; toleranceSeconds: number } | null {
|
||||
const { slotSeconds, mediaSeconds, loop } = params;
|
||||
if (loop) return null;
|
||||
if (!(slotSeconds > 0) || !Number.isFinite(mediaSeconds) || mediaSeconds < 0) return null;
|
||||
const toleranceSeconds = MEDIA_DURATION_CLAMP_EPSILON_SECONDS;
|
||||
const shortfallSeconds = slotSeconds - mediaSeconds;
|
||||
if (shortfallSeconds <= toleranceSeconds) return null;
|
||||
return { shortfallSeconds, toleranceSeconds };
|
||||
}
|
||||
|
||||
export class FrameLookupTable {
|
||||
private videos: Map<
|
||||
string,
|
||||
@@ -1079,11 +1105,17 @@ export class FrameLookupTable {
|
||||
continue;
|
||||
}
|
||||
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) {
|
||||
// At the inclusive clip end (globalTime === end), hold the last
|
||||
// extracted frame so the render matches the runtime, which keeps the
|
||||
// element visible on its final frame at `t === end`. Mid-clip source
|
||||
// exhaustion (globalTime < end) stays blank — unchanged.
|
||||
if (globalTime >= video.end && video.extracted.totalFrames > 0) {
|
||||
// Source exhausted. Hold the last frame near the clip end so a media that
|
||||
// falls a hair short of its slot (e.g. `ffmpeg -t 1.45` → 1.433s at 30fps)
|
||||
// doesn't flash the background for one frame. A clip that's substantially
|
||||
// shorter than its slot still blanks for the tail. Tolerance floored at
|
||||
// the clamp epsilon so the seam is covered at any fps (see that const).
|
||||
const fps = video.extracted.fps;
|
||||
const holdTolerance = Math.max(
|
||||
fps > 0 ? HOLD_LAST_FRAME_TOLERANCE_FRAMES / fps : 0,
|
||||
MEDIA_DURATION_CLAMP_EPSILON_SECONDS,
|
||||
);
|
||||
if (globalTime >= video.end - holdTolerance && video.extracted.totalFrames > 0) {
|
||||
const lastIndex = video.extracted.totalFrames - 1;
|
||||
const lastPath = video.extracted.framePaths.get(lastIndex);
|
||||
if (lastPath) frames.set(videoId, { framePath: lastPath, frameIndex: lastIndex });
|
||||
|
||||
Reference in New Issue
Block a user