mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
fix(video): hold final frame through composition
This commit is contained in:
@@ -1235,13 +1235,14 @@ export async function extractAllVideoFrames(
|
||||
};
|
||||
}
|
||||
|
||||
export function getFrameAtTime(
|
||||
function getFrameIndexAtTime(
|
||||
extracted: ExtractedFrames,
|
||||
globalTime: number,
|
||||
videoStart: number,
|
||||
loop = false,
|
||||
mediaStart = 0,
|
||||
): string | null {
|
||||
holdLastFrame = false,
|
||||
): number | null {
|
||||
let localTime = globalTime - videoStart;
|
||||
if (localTime < 0) return null;
|
||||
const loopDuration = Math.max(0, extracted.metadata.durationSeconds - mediaStart);
|
||||
@@ -1251,21 +1252,29 @@ export function getFrameAtTime(
|
||||
// Add epsilon before flooring to avoid IEEE 754 boundary errors where
|
||||
// e.g. 0.28 * 25 === 6.999999999999999 instead of 7.
|
||||
const frameIndex = Math.floor(localTime * extracted.fps + 1e-9);
|
||||
if (loop && frameIndex >= extracted.totalFrames && extracted.totalFrames > 0) {
|
||||
return extracted.framePaths.get(extracted.totalFrames - 1) || null;
|
||||
if (frameIndex < 0 || extracted.totalFrames <= 0) return null;
|
||||
if (frameIndex >= extracted.totalFrames) {
|
||||
return loop || holdLastFrame ? extracted.totalFrames - 1 : null;
|
||||
}
|
||||
if (frameIndex < 0 || frameIndex >= extracted.totalFrames) return null;
|
||||
return extracted.framePaths.get(frameIndex) || null;
|
||||
return frameIndex;
|
||||
}
|
||||
|
||||
const HOLD_LAST_FRAME_TOLERANCE_FRAMES = 2;
|
||||
export function getFrameAtTime(
|
||||
extracted: ExtractedFrames,
|
||||
globalTime: number,
|
||||
videoStart: number,
|
||||
loop = false,
|
||||
mediaStart = 0,
|
||||
): string | null {
|
||||
const frameIndex = getFrameIndexAtTime(extracted, globalTime, videoStart, loop, mediaStart);
|
||||
return frameIndex == null ? null : extracted.framePaths.get(frameIndex) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Whether a media source is shorter than its `data-duration` slot by more than
|
||||
* the compiler tolerance. The calculation stays tag-agnostic; current in-repo
|
||||
* warnings call it for audio only because video slots may intentionally outlive
|
||||
* their source and hold the final frame.
|
||||
*/
|
||||
export function analyzeClipMediaFit(params: {
|
||||
/** Timeline slot length in seconds — `end - start` (a.k.a. data-duration). */
|
||||
@@ -1325,7 +1334,15 @@ export class FrameLookupTable {
|
||||
const video = this.videos.get(videoId);
|
||||
if (!video) return null;
|
||||
if (globalTime < video.start || globalTime > video.end) return null;
|
||||
return getFrameAtTime(video.extracted, globalTime, video.start, video.loop, video.mediaStart);
|
||||
const frameIndex = getFrameIndexAtTime(
|
||||
video.extracted,
|
||||
globalTime,
|
||||
video.start,
|
||||
video.loop,
|
||||
video.mediaStart,
|
||||
true,
|
||||
);
|
||||
return frameIndex == null ? null : video.extracted.framePaths.get(frameIndex) || null;
|
||||
}
|
||||
|
||||
private resetActiveState(): void {
|
||||
@@ -1386,37 +1403,15 @@ export class FrameLookupTable {
|
||||
for (const videoId of this.activeVideoIds) {
|
||||
const video = this.videos.get(videoId);
|
||||
if (!video) continue;
|
||||
let localTime = globalTime - video.start;
|
||||
const loopDuration = Math.max(0, video.extracted.metadata.durationSeconds - video.mediaStart);
|
||||
if (video.loop && loopDuration > 0 && localTime >= loopDuration) {
|
||||
localTime %= loopDuration;
|
||||
}
|
||||
const frameIndex = Math.floor(localTime * video.extracted.fps + 1e-9);
|
||||
if (video.loop && frameIndex >= video.extracted.totalFrames) {
|
||||
const framePath = video.extracted.framePaths.get(video.extracted.totalFrames - 1);
|
||||
if (framePath) {
|
||||
frames.set(videoId, { framePath, frameIndex: video.extracted.totalFrames - 1 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (frameIndex < 0 || frameIndex >= video.extracted.totalFrames) {
|
||||
// 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 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const frameIndex = getFrameIndexAtTime(
|
||||
video.extracted,
|
||||
globalTime,
|
||||
video.start,
|
||||
video.loop,
|
||||
video.mediaStart,
|
||||
true,
|
||||
);
|
||||
if (frameIndex == null) continue;
|
||||
const framePath = video.extracted.framePaths.get(frameIndex);
|
||||
if (!framePath) continue;
|
||||
frames.set(videoId, { framePath, frameIndex });
|
||||
|
||||
Reference in New Issue
Block a user