feat(producer): report which element set the film length (#6689)

* feat(producer): report which element set the film length

* fix(core): keep the duration guard and comment share in step with the length helper
This commit is contained in:
Miguel Ángel
2026-10-28 19:05:59 +00:00
committed by GitHub
parent 9f65efb19c
commit 3ca1005a81
11 changed files with 224 additions and 39 deletions
@@ -25,7 +25,7 @@ assert(
"init.ts should not hard-clamp safe duration to media floor",
);
assert(
initSource.includes("resolveMediaWindowDurationSeconds"),
initSource.includes("longestMediaWindow"),
"init.ts should compute media window duration across timed media",
);
@@ -4,7 +4,9 @@ import {
findRootCompositionElement,
LOOP_INFLATED_TIMELINE_SECONDS,
MIN_VALID_TIMELINE_DURATION_SECONDS,
longestMediaWindow,
readStaticCompositionMeta,
resolveCompositionLength,
resolveCompositionLengthSeconds,
resolveContentDerivedDuration,
resolveMediaWindowDurationSeconds,
@@ -20,6 +22,20 @@ describe("resolveCompositionLengthSeconds", () => {
derived: () => 0,
};
it("says which input set the length", () => {
expect(resolveCompositionLength({ ...base, declared: 9 }).from).toBe("declared");
expect(resolveCompositionLength({ ...base, timeline: () => 10, floors: () => [4] }).from).toBe(
"timeline",
);
expect(resolveCompositionLength({ ...base, floors: () => [null, 7] })).toEqual({
seconds: 7,
from: "floor",
floorIndex: 1,
});
expect(resolveCompositionLength({ ...base, fallback: 5 }).from).toBe("fallback");
expect(resolveCompositionLength({ ...base, derived: () => 2 }).from).toBe("derived");
});
it("takes a declared length and reads nothing else", () => {
const timeline = vi.fn(() => 10);
const floors = vi.fn(() => [12]);
@@ -147,6 +163,34 @@ describe("rules moved unchanged from the runtime", () => {
expect(readStaticCompositionMeta(parseHTMLContent(html))?.durationSeconds).toBe(3);
});
it("names the clip missing its trim when its full media length sets the film's length", () => {
const doc = parseHTMLContent(
`<div data-composition-id="main">` +
`<video id="cut" data-start="0" data-duration="3" src="cut.mp4"></video>` +
`<video id="source" data-start="2" src="full-source.mp4"></video></div>`,
);
const natural: Record<string, number> = { cut: 3600, source: 5400 };
const media = {
mediaStart: (el: HTMLMediaElement) => Number(el.getAttribute("data-start")),
mediaDuration: (el: HTMLMediaElement) =>
el.hasAttribute("data-duration")
? Number(el.getAttribute("data-duration"))
: natural[el.id]!,
};
const longest = longestMediaWindow(doc, media);
expect(longest?.element.id).toBe("source");
expect(longest?.seconds).toBe(5402);
expect(
resolveCompositionLength({
declared: null,
timeline: () => 3,
floors: () => [longest?.seconds ?? null, 3],
fallback: 0,
derived: () => 0,
}),
).toEqual({ seconds: 5402, from: "floor", floorIndex: 0 });
});
it("skips a media clip of one frame or less", () => {
const doc = parseHTMLContent(
`<div data-composition-id="main"><video data-start="5"></video></div>`,
+55 -23
View File
@@ -34,15 +34,17 @@ export function readCompositionSize(root: Element): {
};
}
/** The latest end among the document's timed video and audio, at their absolute starts. */
export function resolveMediaWindowDurationSeconds(
type MediaTiming = {
mediaStart: (el: HTMLMediaElement) => number;
mediaDuration: (el: HTMLMediaElement) => number | null;
};
/** The timed `<video>`/`<audio>` whose window ends last, and where it ends. */
export function longestMediaWindow(
doc: ParentNode,
media: {
mediaStart: (el: HTMLMediaElement) => number;
mediaDuration: (el: HTMLMediaElement) => number | null;
},
): number | null {
const clipEnds: number[] = [];
media: MediaTiming,
): { seconds: number; element: HTMLMediaElement } | null {
let longest: { seconds: number; element: HTMLMediaElement } | null = null;
for (const node of doc.querySelectorAll<HTMLMediaElement>(
"video[data-start], audio[data-start]",
)) {
@@ -50,12 +52,17 @@ export function resolveMediaWindowDurationSeconds(
if (!Number.isFinite(start)) continue;
const duration = media.mediaDuration(node);
if (duration == null || duration <= MIN_VALID_TIMELINE_DURATION_SECONDS) continue;
clipEnds.push(Math.max(0, start) + duration);
const end = Math.max(0, start) + duration;
if (!longest || end > longest.seconds) longest = { seconds: end, element: node };
}
return aboveOneFrame(
resolveCompositionDuration({ authoredDurationSeconds: null, clipEndsSeconds: clipEnds })
.seconds,
);
return longest && aboveOneFrame(longest.seconds) !== null ? longest : null;
}
export function resolveMediaWindowDurationSeconds(
doc: ParentNode,
media: MediaTiming,
): number | null {
return longestMediaWindow(doc, media)?.seconds ?? null;
}
/** The latest of the root's declared length and its direct sub-compositions' ends. */
@@ -118,14 +125,30 @@ export type CompositionLengthInputs = {
derived: () => number;
};
/** A declared root length is the length; else the longest of timeline, floors and fallback (a
* loop-inflated timeline yields to a floor or fallback); else the length derived from the clips. */
export type CompositionLengthFrom = "declared" | "timeline" | "floor" | "fallback" | "derived";
export type CompositionLengthSource =
| { from: CompositionLengthFrom }
| { from: "media"; tag: string; src: string; trimmed: boolean };
export function resolveCompositionLengthSeconds(input: CompositionLengthInputs): number {
return resolveCompositionLength(input).seconds;
}
/** A declared root length is the length; else the longest of timeline, floors and fallback (a
* loop-inflated timeline yields to a floor or fallback); else the length derived from the clips.
* `from` names the input that set it, and `floorIndex` which floor when a floor did. */
export function resolveCompositionLength(input: CompositionLengthInputs): {
seconds: number;
from: CompositionLengthFrom;
floorIndex: number | null;
} {
if (input.declared !== null && Number.isFinite(input.declared) && input.declared > 0) {
return input.declared;
return { seconds: input.declared, from: "declared", floorIndex: null };
}
const rawTimeline = aboveOneFrame(input.timeline());
const floor = Math.max(0, ...input.floors().map((seconds) => seconds ?? 0));
const floors = input.floors().map((seconds) => seconds ?? 0);
const floor = Math.max(0, ...floors);
const fallback =
Number.isFinite(input.fallback) && input.fallback > MIN_VALID_TIMELINE_DURATION_SECONDS
? input.fallback
@@ -136,12 +159,21 @@ export function resolveCompositionLengthSeconds(input: CompositionLengthInputs):
rawTimeline >= LOOP_INFLATED_TIMELINE_SECONDS &&
aboveOneFrame(floorOrFallback) !== null;
const timeline = loopInflated ? null : rawTimeline;
let seconds: number;
if (timeline !== null) seconds = Math.max(timeline, floorOrFallback);
else if (aboveOneFrame(floor) !== null) seconds = floorOrFallback;
else if (fallback > 0) seconds = fallback;
else seconds = input.derived();
return seconds > 0 ? seconds : 0;
const floorWins = floor >= fallback;
const fromFloorOrFallback = (seconds: number) => ({
seconds,
from: floorWins ? ("floor" as const) : ("fallback" as const),
floorIndex: floorWins ? floors.indexOf(floor) : null,
});
if (timeline !== null) {
return timeline >= floorOrFallback
? { seconds: timeline, from: "timeline", floorIndex: null }
: fromFloorOrFallback(floorOrFallback);
}
if (aboveOneFrame(floor) !== null) return fromFloorOrFallback(floorOrFallback);
if (fallback > 0) return { seconds: fallback, from: "fallback", floorIndex: null };
const derived = input.derived();
return { seconds: derived > 0 ? derived : 0, from: "derived", floorIndex: null };
}
/** Size, frame rate and length of a composition nothing is running (a server-parsed or detached
+44 -14
View File
@@ -127,9 +127,10 @@ import {
MIN_VALID_TIMELINE_DURATION_SECONDS,
readCompositionSize,
resolveAuthoredCompositionFloorSeconds,
resolveCompositionLengthSeconds,
longestMediaWindow as longestMediaWindowIn,
resolveCompositionLength,
type CompositionLengthFrom,
resolveContentDerivedDuration as resolveContentDerivedDurationFor,
resolveMediaWindowDurationSeconds as resolveMediaWindowDurationSecondsIn,
} from "./compositionLength";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree";
@@ -1170,7 +1171,7 @@ export function initSandboxRuntimeModular(): void {
// `getInferredDurationSeconds()` (third-party runtimes). Foreign code inside
// a cache scope can touch the DOM between two resolves. This loop is DOM
// reads only, so nothing can change under it.
const resolveMediaWindowDurationSeconds = (): number | null => {
const longestMediaWindow = (): ReturnType<typeof longestMediaWindowIn> => {
const mediaNodes = Array.from(
document.querySelectorAll("video[data-start], audio[data-start]"),
) as HTMLMediaElement[];
@@ -1178,7 +1179,7 @@ export function initSandboxRuntimeModular(): void {
// this every tick, and it should not pay for a resolver it never uses.
if (mediaNodes.length === 0) return null;
return withTimingResolver(() =>
resolveMediaWindowDurationSecondsIn(document, {
longestMediaWindowIn(document, {
mediaStart: resolveAbsoluteMediaStartSeconds,
mediaDuration: resolveMediaElementDurationSeconds,
}),
@@ -1237,6 +1238,19 @@ export function initSandboxRuntimeModular(): void {
return contentDerivedCache.result;
};
/** Which input set the composition's length; a media clip is named by tag, src and whether it is trimmed. */
const publishLengthSource = (from: CompositionLengthFrom, media: HTMLMediaElement | null) => {
window.__hf = window.__hf || {};
window.__hf.lengthSource = media
? {
from: "media",
tag: media.tagName.toLowerCase(),
src: media.getAttribute("src") ?? media.currentSrc ?? "",
trimmed: media.hasAttribute("data-duration") || media.hasAttribute("data-end"),
}
: { from };
};
/** Carries how a length that no timeline supplied was found, for render telemetry. */
const publishDerivedDuration = (result: ReturnType<typeof resolveCompositionDuration>) => {
window.__hf = window.__hf || {};
@@ -1253,8 +1267,12 @@ export function initSandboxRuntimeModular(): void {
);
};
const resolveMediaDurationFloorSeconds = (): number | null => {
const mediaWindowDuration = resolveMediaWindowDurationSeconds();
const resolveMediaDurationFloor = (): { seconds: number; element: HTMLMediaElement } | null => {
const longest = longestMediaWindow();
return longest && resolveMediaDurationFloorSeconds(longest.seconds) !== null ? longest : null;
};
const resolveMediaDurationFloorSeconds = (mediaWindowDuration: number | null): number | null => {
if (
typeof mediaWindowDuration !== "number" ||
!Number.isFinite(mediaWindowDuration) ||
@@ -1348,7 +1366,11 @@ export function initSandboxRuntimeModular(): void {
// signature compared on read
// The bias is deliberate: a redundant derivation is a missed optimisation,
// a missed one is a wrong duration.
type DurationFloors = { media: number | null; authoredComposition: number | null };
type DurationFloors = {
media: number | null;
mediaElement: HTMLMediaElement | null;
authoredComposition: number | null;
};
const DURATION_FLOOR_INPUT_ATTRIBUTES = [
"data-start",
"data-duration",
@@ -1419,10 +1441,14 @@ export function initSandboxRuntimeModular(): void {
wakeTransport();
};
const deriveDurationFloors = (): DurationFloors => ({
media: resolveMediaDurationFloorSeconds(),
authoredComposition: resolveAuthoredCompositionDurationFloorSeconds(),
});
const deriveDurationFloors = (): DurationFloors => {
const media = resolveMediaDurationFloor();
return {
media: media?.seconds ?? null,
mediaElement: media?.element ?? null,
authoredComposition: resolveAuthoredCompositionDurationFloorSeconds(),
};
};
const readTimelineRegistrySignature = (): string => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
@@ -1545,13 +1571,16 @@ export function initSandboxRuntimeModular(): void {
timingRevision?: number,
): number => {
let derivedDuration: ReturnType<typeof resolveContentDerivedDuration> | null = null;
const seconds = resolveCompositionLengthSeconds({
let mediaElement: HTMLMediaElement | null = null;
const length = resolveCompositionLength({
declared: parseStrictFiniteTimingNumber(
resolveRootCompositionElement()?.getAttribute("data-duration"),
),
timeline: () => getTimelineDurationSeconds(timeline),
floors: () => {
const { media, authoredComposition } = resolveDurationFloors(timingRevision);
const floors = resolveDurationFloors(timingRevision);
const { media, authoredComposition } = floors;
mediaElement = floors.mediaElement;
// Deliberately NOT cached: adapters infer their duration from live animation objects
// (CSS/WAAPI/Lottie), which can change without any DOM mutation to observe.
return [media, authoredComposition, resolveAdapterDurationFloorSeconds()];
@@ -1565,7 +1594,8 @@ export function initSandboxRuntimeModular(): void {
// The published source describes only a length that was derived; any other source clears it.
if (derivedDuration) publishDerivedDuration(derivedDuration);
else if (window.__hf?.durationSource) delete window.__hf.durationSource;
return seconds;
publishLengthSource(length.from, length.floorIndex === 0 ? mediaElement : null);
return length.seconds;
};
// Sub-composition timelines the runtime nested into the root: the host composition id, and where.
+3
View File
@@ -9,6 +9,7 @@ import type { RuntimeColorGradingApi } from "./colorGrading";
import type { HyperframePickerApi } from "../inline-scripts/pickerApi";
import type { PlayerAPI } from "../core.types";
import type { ClipTree } from "./clipTree";
import type { CompositionLengthSource } from "./compositionLength";
import type { SvgSelectorAliases } from "../compiler/svgSelectorAliases";
type ThreeClockLike = {
@@ -59,6 +60,8 @@ declare global {
onSwallowed?: (label: string, err: unknown) => void;
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
duration?: number;
/** Which input set the length; the producer reports it with the render. */
lengthSource?: CompositionLengthSource;
/** How a length that no timeline supplied was found: the render telemetry reads this. */
durationSource?: {
source: "authored" | "derived" | "unresolved";
+2
View File
@@ -133,6 +133,8 @@ export {
writeCapturedFrame,
discardWarmupCapture,
getCompositionDuration,
getCompositionLengthSource,
type CompositionLengthSource,
getCapturePerfSummary,
percentileOf,
prepareCaptureSessionForReuse,
@@ -122,6 +122,7 @@ import {
type ChromePids,
} from "./chromeMemorySampler.js";
import { sampleProcessRss } from "../utils/processRss.js";
import type { CompositionLengthSource } from "@hyperframes/core/runtime/composition-length";
export { isMemoryExhaustionError, isTransientBrowserError } from "./captureFailure.js";
export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
@@ -5345,6 +5346,16 @@ export async function getCompositionDuration(session: CaptureSession): Promise<n
});
}
export type { CompositionLengthSource };
/** Which input set the composition's length, as the runtime published it; null when it did not. */
export async function getCompositionLengthSource(
session: CaptureSession,
): Promise<CompositionLengthSource | null> {
if (!session.isInitialized) throw new Error("[FrameCapture] Session not initialized");
return session.page.evaluate(() => window.__hf?.lengthSource ?? null);
}
/**
* Ungated-release safety net, part 1: capture K screenshot ground-truth frames
* BEFORE the drawElement canvas is injected (the only window where a page
+3
View File
@@ -6,6 +6,7 @@
*/
import type { AudioFailureDiagnostic } from "./services/audioMixer.types.js";
import type { Fps } from "@hyperframes/core";
import type { CompositionLengthSource } from "@hyperframes/core/runtime/composition-length";
import type { ChromeMemoryStats } from "./services/chromeMemorySampler.js";
import type { MotionBlurOptions } from "./services/motionBlur.js";
@@ -130,6 +131,8 @@ export interface HfSeekOptions {
export interface HfProtocol {
/** Total duration of the composition in seconds */
duration: number;
/** Which input set `duration`. */
lengthSource?: CompositionLengthSource;
/** Seek to a specific time. Must produce deterministic visual output. */
seek(time: number, options?: HfSeekOptions): void;
/** Optional: media elements the engine should handle */
@@ -18,6 +18,7 @@ import {
hasAutoStartVideos,
hasScriptedAudioVolumeAutomation,
hasVariableBoundMedia,
reportedLengthSource,
} from "./probeStage.js";
// ── Mocks for runProbeStage tests ────────────────────────────────────────────
@@ -160,6 +161,12 @@ mock.module("@hyperframes/engine", () => ({
durationProbeSessions.push(session);
return 5;
},
getCompositionLengthSource: async () => ({
from: "media",
tag: "video",
src: "/private/customer/Full Source.mp4?token=x",
trimmed: false,
}),
closeCaptureSession: async (session: MockSession) => {
closeCaptureSessionCallCount++;
closedSessions.push(session);
@@ -1457,3 +1464,23 @@ describe("runProbeStage — transient browser error retry (#1687)", () => {
expect(result.probeSession).not.toBeNull();
});
});
describe("reportedLengthSource", () => {
it("names a media clip by tag, trim and a hash of its file name, never its path", () => {
const reported = reportedLengthSource({
from: "media",
tag: "video",
src: "https://cdn.example/private/customer/Full Source.mp4?token=secret#t=2",
trimmed: false,
});
expect(reported).toEqual({
from: "media",
tag: "video",
trimmed: false,
srcHash: createHash("sha256").update("Full Source.mp4").digest("hex").slice(0, 12),
});
expect(JSON.stringify(reported)).not.toMatch(/customer|secret|Full Source/);
expect(reportedLengthSource({ from: "timeline" })).toEqual({ from: "timeline" });
expect(reportedLengthSource(null)).toBeNull();
});
});
@@ -28,11 +28,13 @@
* prefix.
*/
import { join } from "node:path";
import { createHash } from "node:crypto";
import { basename, join } from "node:path";
import { parseHTML } from "linkedom";
import {
type CaptureOptions,
type CaptureSession,
type CompositionLengthSource,
type EngineConfig,
type SubTimelineWaitMemo,
closeCaptureSession,
@@ -40,6 +42,7 @@ import {
createCaptureSession,
deriveBeginFrameProbeTimeTicks,
getCompositionDuration,
getCompositionLengthSource,
initializeSession,
isTransientBrowserError,
probeBeginFrameLiveness,
@@ -129,6 +132,8 @@ export interface ProbeStageResult {
lastBrowserConsole: string[];
/** Composition duration (post-probe). Guaranteed > 0 — the stage throws on <= 0. */
duration: number;
/** Which input set `duration`; null when the runtime did not say. */
lengthSource: ReportedLengthSource | null;
totalFrames: number;
/** Wall-clock ms for the entire probe phase (near-zero when `needsBrowser` was false). */
browserProbeMs: number;
@@ -334,6 +339,21 @@ export function probeRequiresBrowser(args: {
);
}
export type ReportedLengthSource =
| { from: Exclude<CompositionLengthSource["from"], "media"> }
| { from: "media"; tag: string; trimmed: boolean; srcHash: string };
/** The length source with a media clip's src reduced to a hash of its file name, so no path leaves the render. */
export function reportedLengthSource(
source: CompositionLengthSource | null,
): ReportedLengthSource | null {
if (!source) return null;
if (source.from !== "media") return { from: source.from };
const name = basename(source.src.split(/[?#]/, 1)[0] ?? "");
const srcHash = createHash("sha256").update(name).digest("hex").slice(0, 12);
return { from: "media", tag: source.tag, trimmed: source.trimmed, srcHash };
}
export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageResult> {
const {
projectDir,
@@ -358,6 +378,8 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
let fileServer: FileServerHandle | null = null;
let probeSession: CaptureSession | null = null;
let lengthSource: ReportedLengthSource | null =
input.composition.duration > 0 ? { from: "declared" } : null;
let beginFrameStalled = false;
let lastBrowserConsole: string[] = [];
@@ -552,10 +574,12 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
if (composition.duration <= 0) {
log.info("Discovering composition duration...");
const discoveredDuration = await getCompositionDuration(session);
lengthSource = reportedLengthSource(await getCompositionLengthSource(session));
assertNotAborted();
log.info("Probed composition duration from browser", {
discoveredDuration,
staticDuration: compiled.staticDuration,
lengthSource,
});
composition.duration = discoveredDuration;
} else {
@@ -892,6 +916,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
probeSession,
lastBrowserConsole,
duration,
lengthSource,
totalFrames,
browserProbeMs,
beginFrameStalled,
@@ -3429,6 +3429,14 @@ async function executeRenderPipeline(input: {
}
observability.checkpoint("browser_probe", "duration resolved", {
durationSeconds: probeResult.duration,
lengthFrom: probeResult.lengthSource?.from ?? null,
...(probeResult.lengthSource?.from === "media"
? {
lengthTag: probeResult.lengthSource.tag,
lengthTrimmed: probeResult.lengthSource.trimmed,
lengthSrcHash: probeResult.lengthSource.srcHash,
}
: {}),
totalFrames,
compositionHash,
beginFrameStalled: probeResult.beginFrameStalled,