@@ -14,6 +14,7 @@ import { join } from "node:path";
1414import { Readable } from "node:stream" ;
1515import { EncodedPacketSink , FilePathSource , Input , MP4 } from "mediabunny" ;
1616import { muxMediaTracksToMp4 } from "../../lib/media-video" ;
17+ import { proveRecordingPackets } from "../../lib/recording-packet-proof" ;
1718import {
1819 RecordingTimingError ,
1920 readRecordingVideoTiming ,
@@ -24,6 +25,7 @@ import {
2425 verifyRecording ,
2526 verifyRemoteRecording ,
2627 verifyRemoteRecordingBytes ,
28+ verifyRemuxedRecording ,
2729} from "../../lib/recording-verification" ;
2830
2931const FIXTURES = join ( import . meta. dir , ".." , "fixtures" ) ;
@@ -486,6 +488,158 @@ afterAll(async () => {
486488 if ( directory ) await rm ( directory , { recursive : true , force : true } ) ;
487489} ) ;
488490
491+ describe ( "encoded recording preservation" , ( ) => {
492+ test ( "verifies looped AAC through decoded evidence when edit-list rounding changes the tail" , async ( ) => {
493+ const input = join ( directory , "looped-audio.mp4" ) ;
494+ const output = join ( directory , "looped-audio-remux.mp4" ) ;
495+ await run ( [
496+ "ffmpeg" ,
497+ "-v" ,
498+ "error" ,
499+ "-stream_loop" ,
500+ "2" ,
501+ "-i" ,
502+ silent ,
503+ "-c" ,
504+ "copy" ,
505+ input ,
506+ ] ) ;
507+ await muxMediaTracksToMp4 ( input , input , output ) ;
508+ const verified = await verifyRemuxedRecording ( input , input , output , {
509+ requireAudio : true ,
510+ } ) ;
511+ expect ( verified . fullDecode ) . toBe ( true ) ;
512+ expect ( verified . sourcePreserved ) . toBe ( true ) ;
513+ const source = await inspectRecordingSources ( input , input ) ;
514+ expect ( verified . audio ) . toEqual ( source . audio ) ;
515+ expect ( verified . video ) . toEqual ( source . video ) ;
516+ } ) ;
517+ test ( "refuses identical corrupt packets rather than treating preservation as decodability" , async ( ) => {
518+ await expect (
519+ verifyRemuxedRecording ( corruptTail , corruptTail , corruptTail , {
520+ requireAudio : true ,
521+ } ) ,
522+ ) . rejects . toThrow ( ) ;
523+ } ) ;
524+ test ( "binds backward presentation timestamps without changing the recording" , async ( ) => {
525+ const samples = bFrameSamples . map ( ( sample ) => ( { ...sample } ) ) ;
526+ samples [ 13 ] . pts -= 6000 ;
527+ const input = join ( directory , "packet-backward-pts.mp4" ) ;
528+ await writeFile (
529+ input ,
530+ Buffer . concat ( [
531+ bFrameInit ,
532+ ...samples . map ( ( sample , index ) => sampleFragment ( [ sample ] , index + 1 ) ) ,
533+ ] ) ,
534+ ) ;
535+ const output = join ( directory , "packet-backward-output.mp4" ) ;
536+ await muxMediaTracksToMp4 ( input , null , output ) ;
537+ const result = await verifyRemuxedRecording ( input , null , output , {
538+ requireAudio : false ,
539+ } ) ;
540+ expect ( result . sourcePreserved ) . toBe ( true ) ;
541+ expect ( result . video . frameCount ) . toBe ( samples . length ) ;
542+ expect ( result . integrity ) . toBeUndefined ( ) ;
543+ } ) ;
544+ test ( "applies one deadline to packet inspection and decode" , async ( ) => {
545+ await expect (
546+ verifyRemuxedRecording ( silent , silent , silent , {
547+ requireAudio : true ,
548+ timeoutMs : 1 ,
549+ } ) ,
550+ ) . rejects . toThrow ( ) ;
551+ expect ( await decoderPids ( silent ) ) . toEqual ( [ ] ) ;
552+ } ) ;
553+
554+ test . each ( [ true , false ] ) (
555+ "decodes preserved packets once with audio=%s" ,
556+ async ( audio ) => {
557+ const input = join (
558+ FIXTURES ,
559+ audio ? "test-with-audio.mp4" : "test-no-audio.mp4" ,
560+ ) ;
561+ const output = join ( directory , `packet-proof-${ audio } .mp4` ) ;
562+ await muxMediaTracksToMp4 ( input , audio ? input : null , output ) ;
563+ await proveRecordingPackets (
564+ input ,
565+ audio ? input : null ,
566+ output ,
567+ AbortSignal . timeout ( 5000 ) ,
568+ ) ;
569+ const verified = await verifyRemuxedRecording (
570+ input ,
571+ audio ? input : null ,
572+ output ,
573+ { requireAudio : audio } ,
574+ ) ;
575+ expect ( verified . fullDecode ) . toBe ( true ) ;
576+ expect ( verified . sourcePreserved ) . toBe ( true ) ;
577+ expect ( Boolean ( verified . audio ) ) . toBe ( audio ) ;
578+ expect ( verified . integrity ) . toBeUndefined ( ) ;
579+ } ,
580+ ) ;
581+ test ( "rejects changed source bytes" , async ( ) => {
582+ await expect (
583+ proveRecordingPackets (
584+ silent ,
585+ silent ,
586+ corruptTail ,
587+ AbortSignal . timeout ( 5000 ) ,
588+ ) ,
589+ ) . rejects . toThrow ( ) ;
590+ await expect (
591+ verifyRemuxedRecording ( silent , silent , corruptTail , {
592+ requireAudio : true ,
593+ } ) ,
594+ ) . rejects . toThrow ( ) ;
595+ } ) ;
596+ test ( "retains the strict fallback for packet transformations" , async ( ) => {
597+ const output = join ( directory , "proof-short-audio.mp4" ) ;
598+ await muxMediaTracksToMp4 ( silent , shortAudio , output ) ;
599+ const verified = await verifyRemuxedRecording ( silent , shortAudio , output , {
600+ requireAudio : true ,
601+ } ) ;
602+ expect ( verified . sourcePreserved ) . toBe ( true ) ;
603+ } ) ;
604+ test ( "rejects shifted audio despite identical encoded content" , async ( ) => {
605+ const output = join ( directory , "proof-shifted-audio.mp4" ) ;
606+ await run ( [
607+ "ffmpeg" ,
608+ "-v" ,
609+ "error" ,
610+ "-i" ,
611+ silent ,
612+ "-itsoffset" ,
613+ "0.5" ,
614+ "-i" ,
615+ silent ,
616+ "-map" ,
617+ "0:v:0" ,
618+ "-map" ,
619+ "1:a:0" ,
620+ "-c" ,
621+ "copy" ,
622+ output ,
623+ ] ) ;
624+ await expect (
625+ proveRecordingPackets ( silent , silent , output , AbortSignal . timeout ( 5000 ) ) ,
626+ ) . rejects . toThrow ( ) ;
627+ await expect (
628+ verifyRemuxedRecording ( silent , silent , output , { requireAudio : true } ) ,
629+ ) . rejects . toThrow ( ) ;
630+ } ) ;
631+ test ( "does not start cancelled packet verification" , async ( ) => {
632+ const controller = new AbortController ( ) ;
633+ controller . abort ( ) ;
634+ await expect (
635+ verifyRemuxedRecording ( silent , silent , silent , {
636+ requireAudio : true ,
637+ abortSignal : controller . signal ,
638+ } ) ,
639+ ) . rejects . toThrow ( ) ;
640+ } ) ;
641+ } ) ;
642+
489643describe ( "complete recording decode" , ( ) => {
490644 test . each ( [
491645 { audio : true , hasAudio : true } ,
@@ -640,6 +794,7 @@ describe("complete recording decode", () => {
640794 requireAudio : false ,
641795 timeoutMs : 110_000 ,
642796 } ) ;
797+ const stockElapsed = performance . now ( ) - stockStarted ;
643798 expect ( stock . integrity ?. video . contentSha256 ) . toBe (
644799 source . integrity . video . contentSha256 ,
645800 ) ;
@@ -648,8 +803,23 @@ describe("complete recording decode", () => {
648803 ) ;
649804 expect ( source . video ) . toEqual ( stock . video ) ;
650805 expect ( source . audio ) . toEqual ( stock . audio ) ;
806+ const efficientStarted = performance . now ( ) ;
807+ const efficient = await verifyRemuxedRecording ( input , input , input , {
808+ requireAudio : true ,
809+ timeoutMs : 30_000 ,
810+ } ) ;
811+ const efficientElapsed = performance . now ( ) - efficientStarted ;
812+ expect ( efficient . sourcePreserved ) . toBe ( true ) ;
813+ expect ( efficient . integrity ) . toBeUndefined ( ) ;
814+ expect ( efficient . video ) . toEqual ( source . video ) ;
815+ expect ( efficient . audio ) . toEqual ( source . audio ) ;
816+ expect ( efficientElapsed ) . toBeLessThan ( 30_000 ) ;
817+ console . info (
818+ `Packet-bound full verification: ${ efficientElapsed . toFixed ( 0 ) } ms` ,
819+ ) ;
820+
651821 console . info (
652- `Recording decode: ${ elapsed . toFixed ( 0 ) } ms, stock ${ ( performance . now ( ) - stockStarted ) . toFixed ( 0 ) } ms, ${ ( ( sourcePeak - baseline ) / 1_024 / 1_024 ) . toFixed ( 1 ) } MiB peak RSS increase` ,
822+ `Recording decode: ${ elapsed . toFixed ( 0 ) } ms, stock ${ stockElapsed . toFixed ( 0 ) } ms, ${ ( ( sourcePeak - baseline ) / 1_024 / 1_024 ) . toFixed ( 1 ) } MiB peak RSS increase` ,
653823 ) ;
654824 } finally {
655825 clearInterval ( memory ) ;
@@ -2153,6 +2323,68 @@ function objectResponse(
21532323}
21542324
21552325describe ( "remote recording object identity" , ( ) => {
2326+ test ( "resumes interrupted byte verification without rereading the verified prefix" , async ( ) => {
2327+ const identity = '"resume-object"' ;
2328+ let reads = 0 ;
2329+ let sent = 0 ;
2330+ const server = Bun . serve ( {
2331+ hostname : "127.0.0.1" ,
2332+ port : 0 ,
2333+ fetch ( request ) {
2334+ if ( request . method === "HEAD" )
2335+ return new Response ( null , {
2336+ headers : {
2337+ ETag : identity ,
2338+ "Content-Length" : String ( silentBytes . length ) ,
2339+ } ,
2340+ } ) ;
2341+ const range = request . headers . get ( "range" ) ;
2342+ if ( range === "bytes=0-0" )
2343+ return new Response ( silentBytes . slice ( 0 , 1 ) , {
2344+ status : 206 ,
2345+ headers : {
2346+ ETag : identity ,
2347+ "Content-Range" : `bytes 0-0/${ silentBytes . length } ` ,
2348+ } ,
2349+ } ) ;
2350+ expect ( request . headers . get ( "if-match" ) ) . toBe ( identity ) ;
2351+ reads ++ ;
2352+ if ( reads === 1 ) {
2353+ sent += 128 ;
2354+ return new Response ( silentBytes . slice ( 0 , 128 ) , {
2355+ headers : { ETag : identity } ,
2356+ } ) ;
2357+ }
2358+ expect ( range ) . toBe ( `bytes=128-${ silentBytes . length - 1 } ` ) ;
2359+ sent += silentBytes . length - 128 ;
2360+ return new Response ( silentBytes . slice ( 128 ) , {
2361+ status : 206 ,
2362+ headers : {
2363+ ETag : identity ,
2364+ "Content-Range" : `bytes 128-${ silentBytes . length - 1 } /${ silentBytes . length } ` ,
2365+ } ,
2366+ } ) ;
2367+ } ,
2368+ } ) ;
2369+ try {
2370+ const result = await verifyRemoteRecordingBytes (
2371+ `http://127.0.0.1:${ server . port } /recording.mp4` ,
2372+ {
2373+ expectedObjectIdentity : identity ,
2374+ expectedSha256 : createHash ( "sha256" )
2375+ . update ( silentBytes )
2376+ . digest ( "hex" ) ,
2377+ expectedFileSize : silentBytes . length ,
2378+ } ,
2379+ ) ;
2380+ expect ( result . fileSize ) . toBe ( silentBytes . length ) ;
2381+ expect ( reads ) . toBe ( 2 ) ;
2382+ expect ( sent ) . toBe ( silentBytes . length ) ;
2383+ } finally {
2384+ await server . stop ( true ) ;
2385+ }
2386+ } ) ;
2387+
21562388 test ( "binds remote bytes without manufacturing decoded evidence" , async ( ) => {
21572389 const identity = '"byte-bound-output"' ;
21582390 const sha256 = createHash ( "sha256" ) . update ( silentBytes ) . digest ( "hex" ) ;
0 commit comments