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:
Miguel Ángel
2026-09-08 18:11:40 +00:00
committed by GitHub
parent 6c0ee41ac4
commit 0ce0bb13eb
5 changed files with 190 additions and 8 deletions
@@ -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,
+108
View File
@@ -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");
+7 -7
View File
@@ -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({});