mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(runtime): preserve fractional final-frame visibility (#3696)
* fix(runtime): preserve fractional final-frame visibility * test(runtime): cover repeated nested fractional tails
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "./parityContract.js";
|
||||
import {
|
||||
quantizeTimeToFrame,
|
||||
snapTimeToFrameBoundary,
|
||||
MEDIA_VISUAL_STYLE_PROPERTIES,
|
||||
} from "./parityContract.js";
|
||||
|
||||
describe("quantizeTimeToFrame", () => {
|
||||
it("quantizes a time to the nearest frame boundary at 30fps", () => {
|
||||
@@ -43,6 +47,21 @@ describe("quantizeTimeToFrame", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapTimeToFrameBoundary", () => {
|
||||
it("snaps decimal noise around an exact rational frame boundary", () => {
|
||||
expect(snapTimeToFrameBoundary(19.466666666667, 30)).toBe(584 / 30);
|
||||
});
|
||||
|
||||
it("preserves a genuinely fractional authored boundary", () => {
|
||||
expect(snapTimeToFrameBoundary(1.01, 30)).toBe(1.01);
|
||||
expect(snapTimeToFrameBoundary(79.402, 60)).toBe(79.402);
|
||||
});
|
||||
|
||||
it("preserves exact frame boundaries", () => {
|
||||
expect(snapTimeToFrameBoundary(79.4, 60)).toBe(79.4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MEDIA_VISUAL_STYLE_PROPERTIES", () => {
|
||||
it("is a non-empty readonly array", () => {
|
||||
expect(MEDIA_VISUAL_STYLE_PROPERTIES.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -32,6 +32,8 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
|
||||
|
||||
export type MediaVisualStyleProperty = (typeof MEDIA_VISUAL_STYLE_PROPERTIES)[number];
|
||||
|
||||
const FRAME_BOUNDARY_EPSILON = 1e-3;
|
||||
|
||||
export function quantizeTimeToFrame(timeSeconds: number, fps: number): number {
|
||||
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
||||
const safeTime = Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
|
||||
@@ -39,6 +41,17 @@ export function quantizeTimeToFrame(timeSeconds: number, fps: number): number {
|
||||
return frameIndex / safeFps;
|
||||
}
|
||||
|
||||
/** Snap decimal noise near a frame boundary without moving genuinely fractional timing. */
|
||||
export function snapTimeToFrameBoundary(timeSeconds: number, fps: number): number {
|
||||
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : 30;
|
||||
const safeTime = Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
|
||||
const framePosition = safeTime * safeFps;
|
||||
const nearestFrame = Math.round(framePosition);
|
||||
return Math.abs(framePosition - nearestFrame) <= FRAME_BOUNDARY_EPSILON
|
||||
? nearestFrame / safeFps
|
||||
: safeTime;
|
||||
}
|
||||
|
||||
export function copyMediaVisualStyles(
|
||||
targetStyle: CSSStyleDeclaration,
|
||||
sourceStyle: CSSStyleDeclaration,
|
||||
|
||||
@@ -631,6 +631,114 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(video.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("keeps the producer's final sample visible through a fractional authored end", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1080");
|
||||
root.setAttribute("data-height", "1920");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const ctaHost = document.createElement("div");
|
||||
ctaHost.setAttribute("data-start", "0");
|
||||
root.appendChild(ctaHost);
|
||||
|
||||
const cta = document.createElement("div");
|
||||
cta.setAttribute("data-start", "0");
|
||||
ctaHost.appendChild(cta);
|
||||
|
||||
const setDuration = (duration: number) => {
|
||||
for (const element of [root, ctaHost, cta]) {
|
||||
element.setAttribute("data-duration", String(duration));
|
||||
}
|
||||
};
|
||||
setDuration(79.402);
|
||||
|
||||
window.__timelines = { main: createMockTimeline(79.402) };
|
||||
window.__HF_EXPORT_RENDER_SEEK_CONFIG = { fps: 60, fpsSource: "render-options" };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const finalSample = 4764 / 60;
|
||||
window.__player?.renderSeek(finalSample);
|
||||
expect([root, ctaHost, cta].map((element) => element.style.visibility)).toEqual([
|
||||
"visible",
|
||||
"visible",
|
||||
"visible",
|
||||
]);
|
||||
|
||||
setDuration(79.4);
|
||||
window.__player?.renderSeek(finalSample);
|
||||
expect([root, ctaHost, cta].map((element) => element.style.visibility)).toEqual([
|
||||
"hidden",
|
||||
"hidden",
|
||||
"hidden",
|
||||
]);
|
||||
|
||||
setDuration(79.41666666666667);
|
||||
window.__player?.renderSeek(finalSample);
|
||||
expect([root, ctaHost, cta].map((element) => element.style.visibility)).toEqual([
|
||||
"visible",
|
||||
"visible",
|
||||
"visible",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps all 14 reported nested cumulative tail samples paintable", () => {
|
||||
const segmentFrameCounts = [
|
||||
484, 728, 551, 633, 477, 257, 383, 305, 446, 640, 414, 511, 904, 3028,
|
||||
];
|
||||
const reportedTailFrames = [
|
||||
483, 1211, 1762, 2395, 2872, 3129, 3512, 3817, 4263, 4903, 5317, 5828, 6732, 9760,
|
||||
];
|
||||
const fps = 30;
|
||||
const fractionalFrameRemainder = 0.99;
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", String((9760 + fractionalFrameRemainder) / fps));
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
let startFrame = 0;
|
||||
const seams = segmentFrameCounts.map((frameCount, index) => {
|
||||
const host = document.createElement("section");
|
||||
host.setAttribute("data-start", String(startFrame / fps));
|
||||
host.setAttribute("data-duration", String((frameCount - 1 + fractionalFrameRemainder) / fps));
|
||||
const child = document.createElement("span");
|
||||
child.setAttribute("data-start", String(startFrame / fps));
|
||||
child.setAttribute(
|
||||
"data-duration",
|
||||
String((frameCount - 1 + fractionalFrameRemainder) / fps),
|
||||
);
|
||||
host.appendChild(child);
|
||||
root.appendChild(host);
|
||||
const tailFrame = startFrame + frameCount - 1;
|
||||
expect(tailFrame).toBe(reportedTailFrames[index]);
|
||||
startFrame += frameCount;
|
||||
return { host, child, tailFrame };
|
||||
});
|
||||
expect(startFrame).toBe(9761);
|
||||
|
||||
window.__timelines = { main: createMockTimeline(9761 / fps) };
|
||||
window.__HF_EXPORT_RENDER_SEEK_CONFIG = { fps, fpsSource: "render-options" };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
// Screenshot and drawElement both call the engine's shared
|
||||
// prepareFrameForCapture -> window.__hf.seek path before reading pixels.
|
||||
// Lock the visibility state at that common pre-capture boundary; the
|
||||
// unavailable private project is still required for buffer/encode proof.
|
||||
for (const { host, child, tailFrame } of seams) {
|
||||
window.__player?.renderSeek(tailFrame / fps);
|
||||
expect([host.style.visibility, child.style.visibility]).toEqual(["visible", "visible"]);
|
||||
|
||||
window.__player?.renderSeek((tailFrame + 1) / fps);
|
||||
expect([host.style.visibility, child.style.visibility]).toEqual(["hidden", "hidden"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces unknown export render fps sources without collapsing them to render-options", () => {
|
||||
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
const root = document.createElement("div");
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
isMemberGroupHidden,
|
||||
} from "../audioGroups";
|
||||
import { clampNativeMediaVolume } from "../audioGain";
|
||||
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
|
||||
import { quantizeTimeToFrame, snapTimeToFrameBoundary } from "../inline-scripts/parityContract";
|
||||
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
|
||||
import type {
|
||||
RuntimeDeterministicAdapter,
|
||||
@@ -743,16 +743,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
const computedEnd =
|
||||
duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
|
||||
const frameAlignedStart = window.__HF_EXPORT_RENDER_SEEK_CONFIG
|
||||
? quantizeTimeToFrame(start, state.canonicalFps)
|
||||
const visibilityStart = window.__HF_EXPORT_RENDER_SEEK_CONFIG
|
||||
? snapTimeToFrameBoundary(start, state.canonicalFps)
|
||||
: start;
|
||||
const frameAlignedEnd =
|
||||
const visibilityEnd =
|
||||
window.__HF_EXPORT_RENDER_SEEK_CONFIG && Number.isFinite(computedEnd)
|
||||
? quantizeTimeToFrame(computedEnd, state.canonicalFps)
|
||||
? snapTimeToFrameBoundary(computedEnd, state.canonicalFps)
|
||||
: computedEnd;
|
||||
return (
|
||||
currentTime >= frameAlignedStart &&
|
||||
(Number.isFinite(frameAlignedEnd) ? currentTime < frameAlignedEnd : true)
|
||||
currentTime >= visibilityStart &&
|
||||
(Number.isFinite(visibilityEnd) ? currentTime < visibilityEnd : true)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -803,6 +803,48 @@ describe("runProbeStage — render variable threading", () => {
|
||||
});
|
||||
|
||||
describe("runProbeStage — decimal duration frame count", () => {
|
||||
it("reproduces all 14 cumulative final-frame indices from the field report", async () => {
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const segmentFrameCounts = [
|
||||
484, 728, 551, 633, 477, 257, 383, 305, 446, 640, 414, 511, 904, 3028,
|
||||
];
|
||||
const expectedTailFrames = [
|
||||
483, 1211, 1762, 2395, 2872, 3129, 3512, 3817, 4263, 4903, 5317, 5828, 6732, 9760,
|
||||
];
|
||||
let cumulativeFrames = 0;
|
||||
const actualTailFrames: number[] = [];
|
||||
|
||||
for (const frameCount of segmentFrameCounts) {
|
||||
const input = makeProbeInput({});
|
||||
input.job.config.fps = { num: 30, den: 1 };
|
||||
const duration = (frameCount - 0.01) / 30;
|
||||
input.composition.duration = duration;
|
||||
input.compiled.staticDuration = duration;
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(result.totalFrames).toBe(frameCount);
|
||||
cumulativeFrames += result.totalFrames;
|
||||
actualTailFrames.push(cumulativeFrames - 1);
|
||||
}
|
||||
|
||||
expect(actualTailFrames).toEqual(expectedTailFrames);
|
||||
expect(cumulativeFrames).toBe(9761);
|
||||
});
|
||||
|
||||
it("covers the final 60fps sample when duration extends fractionally past it", async () => {
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.job.config.fps = { num: 60, den: 1 };
|
||||
input.composition.duration = 79.402;
|
||||
input.compiled.staticDuration = 79.402;
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(result.totalFrames).toBe(4765);
|
||||
expect((result.totalFrames - 1) / 60).toBeLessThan(result.duration);
|
||||
});
|
||||
|
||||
it("does not add a frame for a six-decimal duration rounded from an exact frame boundary", async () => {
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
|
||||
Reference in New Issue
Block a user