/** * Build the `RenderPerfSummary` that lands on `job.perfSummary` and * the `perf-summary.json` debug artifact. */ import { fpsToNumber } from "@hyperframes/core"; import type { CapturePerfSummary, SubTimelineWaitOutcome } from "@hyperframes/engine"; import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js"; import type { CaptureAttemptSummary, HdrDiagnostics, RenderJob, RenderPerfSummary, } from "../renderOrchestrator.js"; import { type HdrPerfCollector, finalizeHdrPerf } from "./hdrPerf.js"; import type { RenderObservabilitySummary } from "./observability.js"; /** * Worst sub-composition timeline wait outcome across sessions: script_failure * > timeout > ready. Shared by the success-path perf summary and the error * path (a fail-fast can still be followed by an unrelated downstream * failure, e.g. in `pollVideosReady` or encode — that render fires * `render_error`, not `render_complete`, and should carry this too). */ export function worstSubTimelineWaitOutcome( perfs: CapturePerfSummary[], ): SubTimelineWaitOutcome | undefined { const outcomes = perfs .map((p) => p.subTimelineWaitOutcome) .filter((o): o is SubTimelineWaitOutcome => !!o); if (outcomes.length === 0) return undefined; if (outcomes.includes("script_failure")) return "script_failure"; if (outcomes.includes("timeout")) return "timeout"; return "ready"; } /** * Append each parallel worker's static-dedup perf into the render-level sink * (skipping workers that reported none). Shared by the disk + streaming parallel * paths so the collection contract lives in one place. */ export function pushWorkerDedupPerfs( results: ReadonlyArray<{ perf?: CapturePerfSummary }>, sink: CapturePerfSummary[], ): void { for (const r of results) { if (r.perf) sink.push(r.perf); } } /** * Collapse per-session/per-worker static-dedup perf into one render-level * outcome. enabled/armed = OR across workers (they run the same gates on the * same composition); predicted/reused = SUM (each worker dedups its own frame * range); skipReason = the distinct reasons (sorted, `|`-joined) when not armed. */ /** * Collapse per-session capture perf + producer-side decisions into the * render-level drawElement outcome. mode/gateReason |-join distinct values * across workers (bounded cardinality); counters SUM. */ /** Orchestrator-supplied render-level drawElement outcome (one shape, used by * both the aggregate function and buildRenderPerfSummary's input). */ export interface DrawElementPerfInput { compileGate?: string; clampReason?: string; workerInversion?: "inverted" | "reverted"; /** Auto-resolved worker count before the inversion pinned it to 1 (set only when the inversion fired). */ preInversionWorkers?: number; parallelRouter?: "routed" | "reverted"; /** Auto-resolved worker count before the router pinned it to 3 (set only when the router fired). */ preRouterWorkers?: number; selfVerifyFallback: boolean; fallbackReason?: string; drainStats?: { verifyChecked: number; verifyMinDb?: number; blankSuspects: number; blankDeterministicAccepts: number; blankRecaptures: number; }; } // Flat field mapping — branches are ?? fallbacks, not logic. // fallow-ignore-next-line complexity function aggregateDrawElement( perfs: CapturePerfSummary[], de: DrawElementPerfInput, ): RenderPerfSummary["drawElement"] { if (perfs.length === 0) return undefined; const modes = [...new Set(perfs.map((p) => p.captureMode).filter(Boolean))].sort(); const gateReasons = [ ...new Set(perfs.map((p) => p.deGateReason).filter((r): r is string => !!r)), ].sort(); const drain = de.drainStats; return { mode: modes.join("|") || "unknown", compileGate: de.compileGate, clampReason: de.clampReason, workerInversion: de.workerInversion ?? "none", preInversionWorkers: de.preInversionWorkers, parallelRouter: de.parallelRouter ?? "none", preRouterWorkers: de.preRouterWorkers, gateReason: gateReasons.length > 0 ? gateReasons.join("|") : undefined, workerEncode: perfs.some((p) => p.deWorkerEncode), verifyArmed: perfs.reduce((sum, p) => sum + (p.deVerifyArmed ?? 0), 0), verifyChecked: drain?.verifyChecked ?? 0, verifyMinDb: drain?.verifyMinDb === undefined ? undefined : Math.round(Math.min(drain.verifyMinDb, 999) * 10) / 10, verifyInitMs: perfs.reduce((sum, p) => sum + (p.deVerifyInitMs ?? 0), 0), selfVerifyFallback: de.selfVerifyFallback, fallbackReason: de.fallbackReason, blankSuspects: drain?.blankSuspects ?? 0, blankDeterministicAccepts: drain?.blankDeterministicAccepts ?? 0, blankRecaptures: drain?.blankRecaptures ?? 0, boundaryFrames: perfs.reduce((sum, p) => sum + (p.deBoundaryFrames ?? 0), 0), ncprFallbacks: perfs.reduce((sum, p) => sum + (p.deNcprFallbacks ?? 0), 0), }; } function aggregateDedup(perfs: CapturePerfSummary[]): RenderPerfSummary["staticDedup"] { if (perfs.length === 0) return undefined; const armed = perfs.some((p) => p.staticDedupArmed); // When unarmed, report every DISTINCT skip reason across workers (sorted, joined) // rather than just the first — workers can diverge (e.g. one `ineligible`, one // `capture_mode`), and dropping the rest hides why dedup didn't engage. Cardinality // stays bounded (a handful of codes, small combinations). const skipReasons = armed ? [] : [ ...new Set(perfs.map((p) => p.staticDedupSkipReason).filter((r): r is string => !!r)), ].sort(); return { enabled: perfs.some((p) => p.staticDedupEnabled), armed, predictedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupPredicted ?? 0), 0), reusedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupReused ?? 0), 0), skipReason: skipReasons.length > 0 ? skipReasons.join("|") : undefined, }; } /** * Collapse per-session/per-worker BeginFrame damage counters into one * render-level reuse outcome (SUM across workers — each worker ticks its own * frame range). Both zero ⟺ no beginframe session ran (screenshot/drawElement * sessions never increment these) → undefined, mirroring staticDedup's * "undefined when it never engaged" contract. Also inherits `dedupPerfs`' * retry semantics: a partial-capture retry resets the sink, so the sums cover * only the final attempt's recaptured ranges (may be < totalFrames). */ function aggregateBeginFrameReuse( perfs: CapturePerfSummary[], ): RenderPerfSummary["beginFrameReuse"] { const noDamageFrames = perfs.reduce((sum, p) => sum + (p.beginFrameNoDamage ?? 0), 0); const hasDamageFrames = perfs.reduce((sum, p) => sum + (p.beginFrameHasDamage ?? 0), 0); if (noDamageFrames + hasDamageFrames === 0) return undefined; return { noDamageFrames, hasDamageFrames }; } export function buildRenderPerfSummary(input: { job: RenderJob; workerCount: number; enableChunkedEncode: boolean; chunkedEncodeSize: number; compositionDurationSeconds: number; totalFrames: number; outputWidth: number; outputHeight: number; videoCount: number; audioCount: number; totalElapsedMs: number; perfStages: Record; videoExtractBreakdown: RenderPerfSummary["videoExtractBreakdown"]; tmpPeakBytes: number; captureCalibration?: { estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[]; }; captureAttempts: CaptureAttemptSummary[]; hdrDiagnostics: HdrDiagnostics; hdrPerf?: HdrPerfCollector; observability?: RenderObservabilitySummary; peakRssBytes: number; peakHeapUsedBytes: number; /** Per-session/per-worker static-dedup perf; aggregated into `staticDedup`. */ dedupPerfs: CapturePerfSummary[]; drawElement?: DrawElementPerfInput; }): RenderPerfSummary { return { renderId: input.job.id, totalElapsedMs: input.totalElapsedMs, // RenderPerfSummary surfaces fps as a decimal because it lands in JSON // payloads (CLI telemetry, regression-harness reports) where a single // number is friendlier than `{num,den}`. Callers needing the rational // back can read `job.config.fps`. fps: fpsToNumber(input.job.config.fps), quality: input.job.config.quality, workers: input.workerCount, chunkedEncode: input.enableChunkedEncode, chunkSizeFrames: input.enableChunkedEncode ? input.chunkedEncodeSize : null, compositionDurationSeconds: input.compositionDurationSeconds, totalFrames: input.totalFrames, resolution: { width: input.outputWidth, height: input.outputHeight }, videoCount: input.videoCount, audioCount: input.audioCount, stages: input.perfStages, videoExtractBreakdown: input.videoExtractBreakdown, tmpPeakBytes: input.tmpPeakBytes, captureCalibration: input.captureCalibration ? { sampledFrames: input.captureCalibration.samples.map((sample) => sample.frameIndex), p95Ms: input.captureCalibration.estimate.p95Ms, multiplier: input.captureCalibration.estimate.multiplier, reasons: input.captureCalibration.estimate.reasons, } : undefined, captureAttempts: input.captureAttempts.length > 0 ? input.captureAttempts : undefined, hdrDiagnostics: input.hdrDiagnostics.videoExtractionFailures > 0 || input.hdrDiagnostics.imageDecodeFailures > 0 ? { ...input.hdrDiagnostics } : undefined, hdrPerf: input.hdrPerf ? finalizeHdrPerf(input.hdrPerf) : undefined, observability: input.observability, captureAvgMs: input.totalFrames > 0 ? Math.round( (input.perfStages.captureFrameMs ?? input.perfStages.captureMs ?? 0) / input.totalFrames, ) : undefined, subTimelineWait: worstSubTimelineWaitOutcome(input.dedupPerfs), captureP50Ms: (() => { // Per-frame median from the engine's samples; when parallel workers // report separately, take the busiest session's median. const withSamples = input.dedupPerfs.filter((p) => (p.p50TotalMs ?? 0) > 0); if (withSamples.length === 0) return undefined; return withSamples.reduce((a, b) => (b.frames > a.frames ? b : a)).p50TotalMs; })(), peakRssMb: Math.round(input.peakRssBytes / (1024 * 1024)), peakHeapUsedMb: Math.round(input.peakHeapUsedBytes / (1024 * 1024)), staticDedup: aggregateDedup(input.dedupPerfs), beginFrameReuse: aggregateBeginFrameReuse(input.dedupPerfs), drawElement: aggregateDrawElement( input.dedupPerfs, input.drawElement ?? { selfVerifyFallback: false }, ), }; }