mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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>`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user