From 0ce0bb13eb6468529b08df882f990e6c14a9ddcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 8 Sep 2026 14:11:40 -0400 Subject: [PATCH] fix(runtime): preserve fractional final-frame visibility (#3696) * fix(runtime): preserve fractional final-frame visibility * test(runtime): cover repeated nested fractional tails --- .../src/inline-scripts/parityContract.test.ts | 21 +++- .../core/src/inline-scripts/parityContract.ts | 13 +++ packages/core/src/runtime/init.test.ts | 108 ++++++++++++++++++ packages/core/src/runtime/init.ts | 14 +-- .../services/render/stages/probeStage.test.ts | 42 +++++++ 5 files changed, 190 insertions(+), 8 deletions(-) diff --git a/packages/core/src/inline-scripts/parityContract.test.ts b/packages/core/src/inline-scripts/parityContract.test.ts index 58da0044e9..e8252247ed 100644 --- a/packages/core/src/inline-scripts/parityContract.test.ts +++ b/packages/core/src/inline-scripts/parityContract.test.ts @@ -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); diff --git a/packages/core/src/inline-scripts/parityContract.ts b/packages/core/src/inline-scripts/parityContract.ts index 9af34d4eb9..21ba002a85 100644 --- a/packages/core/src/inline-scripts/parityContract.ts +++ b/packages/core/src/inline-scripts/parityContract.ts @@ -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, diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index bdce3cf232..e40335dac3 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index d138254122..cd35eda018 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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) ); }; diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts index 08caa14f29..79721a7d91 100644 --- a/packages/producer/src/services/render/stages/probeStage.test.ts +++ b/packages/producer/src/services/render/stages/probeStage.test.ts @@ -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({});