77
88import { cpus , freemem } from "os" ;
99import { existsSync , mkdirSync , readdirSync } from "fs" ;
10- import { copyFile , rename } from "fs/promises" ;
10+ import { copyFile , readFile , rename } from "fs/promises" ;
1111import { join } from "path" ;
1212import { getHeapStatistics } from "v8" ;
1313
@@ -19,11 +19,13 @@ import {
1919 captureFrameToBufferPipelined ,
2020 captureFrameToBuffer ,
2121 getCapturePerfSummary ,
22+ DrawElementVerificationError ,
2223 type CaptureSession ,
2324 type CaptureOptions ,
2425 type CapturePerfSummary ,
2526 type BeforeCaptureHook ,
2627} from "./frameCapture.js" ;
28+ import { psnrDb , resolveDeVerifyMinDb } from "../utils/psnr.js" ;
2729import { DEFAULT_CONFIG , type EngineConfig } from "../config.js" ;
2830import { assertSwiftShader } from "../utils/assertSwiftShader.js" ;
2931import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js" ;
@@ -551,6 +553,126 @@ async function captureFrameRange(
551553 return framesCaptured ;
552554}
553555
556+ /**
557+ * The armed self-verify sample indices this task actually captured: inside
558+ * `[startFrame, endFrame)` and on the task's stride lattice. Mirrors the
559+ * capture loop in `captureFrameRange` (`i += stride` from `startFrame`).
560+ */
561+ export function selectVerifySampleIndicesForTask (
562+ sampleIndices : Iterable < number > ,
563+ task : Pick < WorkerTask , "startFrame" | "endFrame" | "frameStride" > ,
564+ ) : number [ ] {
565+ const stride = task . frameStride ?? 1 ;
566+ const selected : number [ ] = [ ] ;
567+ for ( const idx of sampleIndices ) {
568+ if ( idx < task . startFrame || idx >= task . endFrame ) continue ;
569+ if ( ( idx - task . startFrame ) % stride !== 0 ) continue ;
570+ selected . push ( idx ) ;
571+ }
572+ return selected . sort ( ( a , b ) => a - b ) ;
573+ }
574+
575+ /**
576+ * Disk-path drawElement self-verification (PRINFRA-352). Parallel DISK
577+ * workers arm the same pre-injection ground-truth samples as the streaming
578+ * path (`resolveParallelDeVerifySamples` even raises the density for
579+ * multi-worker capture) — but only the streaming drain ever CHECKED them,
580+ * so an explicit `--experimental-fast-capture --workers N` render shipped
581+ * unverified drawElement frames. On a 16GB host, two concurrent
582+ * hardware-GPU Chrome instances hit the documented compositor-tile-eviction
583+ * damage class (frames displaced into vertical strips for one worker's
584+ * whole range — reads as "corruption from the exact worker boundary").
585+ *
586+ * After a worker's range completes, re-read its captured files for the
587+ * sampled indices and PSNR-compare against the session's ground truth.
588+ * A breach throws `DrawElementVerificationError`, which the orchestrator's
589+ * existing pinned-fallback retry converts into a screenshot re-render —
590+ * the same recovery the streaming drain gets.
591+ */
592+ /** HF_DE_PAR_DEBUG=1 gated per-worker trace line (message built lazily). */
593+ function logParDebug ( message : ( ) => string ) : void {
594+ if ( process . env . HF_DE_PAR_DEBUG === "1" ) console . log ( message ( ) ) ;
595+ }
596+
597+ /**
598+ * Throw the verification error for a sample below the PSNR floor; log the
599+ * pass otherwise. Split from the sampling loop for the complexity gate.
600+ */
601+ function assertDiskSampleAboveFloor (
602+ db : number ,
603+ verifyMinDb : number ,
604+ idx : number ,
605+ workerId : number ,
606+ ) : void {
607+ if ( db < verifyMinDb ) {
608+ // Message keeps the contiguous "drawElement self-verify" phrase —
609+ // captureFailure's VERIFICATION_ERROR_PATTERNS classifies on it.
610+ throw new DrawElementVerificationError (
611+ `drawElement self-verify failed at frame ${ idx } (disk path, worker ${ workerId } ): ` +
612+ `${ db . toFixed ( 1 ) } dB < ${ verifyMinDb } dB vs pre-injection screenshot` ,
613+ { kind : "psnr" , frameIndex : idx , failedDb : db , verifyThresholdDb : verifyMinDb } ,
614+ ) ;
615+ }
616+ console . log (
617+ `[Parallel] drawElement disk self-verify passed (worker ${ workerId } , frame ${ idx } , ` +
618+ `${ db === Infinity ? "inf" : db . toFixed ( 1 ) } dB)` ,
619+ ) ;
620+ }
621+
622+ /**
623+ * Compare one captured frame file against its ground truth. Returns the
624+ * PSNR, or null on infrastructure failure (missing file already surfaces
625+ * via the frame completeness check; ffmpeg spawn/tmpdir here) — a skipped
626+ * sample is not damage evidence and must not fail the capture.
627+ */
628+ async function psnrForDiskSample (
629+ framePath : string ,
630+ truth : Buffer ,
631+ workerId : number ,
632+ idx : number ,
633+ ) : Promise < number | null > {
634+ try {
635+ return await psnrDb ( await readFile ( framePath ) , truth ) ;
636+ } catch ( err ) {
637+ console . warn (
638+ `[Parallel] drawElement disk self-verify sample skipped (worker ${ workerId } , ` +
639+ `frame ${ idx } ): ${ err instanceof Error ? err . message : String ( err ) } ` ,
640+ ) ;
641+ return null ;
642+ }
643+ }
644+
645+ // Branches are the gate conditions themselves (mode/armed/streaming guards +
646+ // per-sample skip/breach) — already decomposed into psnrForDiskSample +
647+ // assertDiskSampleAboveFloor; further splitting obscures the check.
648+ // fallow-ignore-next-line complexity
649+ async function verifyDiskDrawElementSamples (
650+ session : CaptureSession ,
651+ task : WorkerTask ,
652+ streaming : boolean ,
653+ ) : Promise < void > {
654+ // Streaming capture verifies every sampled frame in the drain guard already.
655+ if ( streaming || session . captureMode !== "drawelement" ) return ;
656+ const truths = session . deVerifyFrames ;
657+ if ( ! truths || truths . size === 0 ) return ;
658+ const verifyMinDb = resolveDeVerifyMinDb ( ) ;
659+ const ext = session . options . format === "png" ? "png" : "jpg" ;
660+ const offset = task . outputFrameOffset ?? 0 ;
661+ for ( const idx of selectVerifySampleIndicesForTask ( truths . keys ( ) , task ) ) {
662+ const truth = truths . get ( idx ) ;
663+ if ( ! truth ) continue ;
664+ const framePath = join ( task . outputDir , `frame_${ String ( idx - offset ) . padStart ( 6 , "0" ) } .${ ext } ` ) ;
665+ const db = await psnrForDiskSample ( framePath , truth , task . workerId , idx ) ;
666+ if ( db === null ) continue ;
667+ assertDiskSampleAboveFloor ( db , verifyMinDb , idx , task . workerId ) ;
668+ }
669+ }
670+
671+ // Inherited worker-lifecycle shape (session create → verify GPU → init →
672+ // capture → self-verify → perf, with a classifying catch + closing finally);
673+ // flagged only because the disk self-verify call shifted its line range into
674+ // the changed-code audit. Not restructured by this PR.
675+ // fallow-ignore-next-line complexity
554676async function executeWorkerTask (
555677 task : WorkerTask ,
556678 serverUrl : string ,
@@ -590,19 +712,16 @@ async function executeWorkerTask(
590712 createBeforeCaptureHook ( ) ,
591713 workerConfig ,
592714 ) ;
593- if ( process . env . HF_DE_PAR_DEBUG === "1" ) {
594- console . log ( `[par:w${ task . workerId } ] session created` ) ;
595- }
715+ logParDebug ( ( ) => `[par:w${ task . workerId } ] session created` ) ;
596716 // Worker-0-only SwiftShader assertion — see `shouldVerifyWorkerGpu` and #955.
597717 if ( shouldVerifyWorkerGpu ( task . workerId , workerConfig ) ) {
598718 await assertSwiftShader ( session . page , readWebGlVendorInfoFromCanvas ) ;
599719 }
600720 await initializeSession ( session ) ;
601- if ( process . env . HF_DE_PAR_DEBUG === "1" ) {
602- console . log (
603- `[par:w${ task . workerId } ] init done (mode=${ session . captureMode } workerEncode=${ session . workerEncodeEnabled === true } )` ,
604- ) ;
605- }
721+ logParDebug (
722+ ( ) =>
723+ `[par:w${ task . workerId } ] init done (mode=${ session ?. captureMode } workerEncode=${ session ?. workerEncodeEnabled === true } )` ,
724+ ) ;
606725 framesCaptured = await captureFrameRange (
607726 session ,
608727 task ,
@@ -612,6 +731,8 @@ async function executeWorkerTask(
612731 onFrameBuffer ,
613732 ) ;
614733
734+ await verifyDiskDrawElementSamples ( session , task , Boolean ( onFrameBuffer ) ) ;
735+
615736 perf = getCapturePerfSummary ( session ) ;
616737 return {
617738 workerId : task . workerId ,
0 commit comments