@@ -502,3 +502,143 @@ describe("ffprobe missing-binary fallback", () => {
502502 await expect ( extractAudioMetadata ( "/tmp/example.mp3" ) ) . rejects . toThrow ( / i n s t a l l F F m p e g / i) ;
503503 } ) ;
504504} ) ;
505+
506+ describe ( "ffprobe option separator" , ( ) => {
507+ afterEach ( ( ) => {
508+ vi . resetModules ( ) ;
509+ vi . doUnmock ( "child_process" ) ;
510+ } ) ;
511+
512+ it ( "places -- before the file path so paths starting with - are not parsed as options" , async ( ) => {
513+ const { spawn, calls } = createSpawnSpy ( [
514+ {
515+ kind : "exit" ,
516+ code : 0 ,
517+ stdout : JSON . stringify ( {
518+ streams : [
519+ {
520+ codec_type : "video" ,
521+ codec_name : "h264" ,
522+ width : 320 ,
523+ height : 180 ,
524+ r_frame_rate : "30/1" ,
525+ avg_frame_rate : "30/1" ,
526+ } ,
527+ ] ,
528+ format : { duration : "1.5" } ,
529+ } ) ,
530+ } ,
531+ ] ) ;
532+ vi . resetModules ( ) ;
533+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
534+
535+ const { extractMediaMetadata } = await import ( "./ffprobe.js" ) ;
536+ const filePath = "/tmp/-dangerous-name.mp4" ;
537+ await extractMediaMetadata ( filePath ) ;
538+
539+ const args = calls [ 0 ] ?. args ?? [ ] ;
540+ const filePathIndex = args . indexOf ( filePath ) ;
541+ expect ( filePathIndex ) . toBeGreaterThan ( 0 ) ;
542+ expect ( args [ filePathIndex - 1 ] ) . toBe ( "--" ) ;
543+ } ) ;
544+
545+ it ( "uses -- for audio and keyframe probes too" , async ( ) => {
546+ const { spawn, calls } = createSpawnSpy ( [
547+ {
548+ kind : "exit" ,
549+ code : 0 ,
550+ stdout : JSON . stringify ( {
551+ streams : [ { codec_type : "audio" , codec_name : "aac" , sample_rate : "48000" , channels : 2 } ] ,
552+ format : { duration : "1.25" } ,
553+ } ) ,
554+ } ,
555+ {
556+ kind : "exit" ,
557+ code : 0 ,
558+ stdout : JSON . stringify ( {
559+ streams : [ { nb_read_packets : "783" } ] ,
560+ format : { } ,
561+ } ) ,
562+ } ,
563+ { kind : "exit" , code : 0 , stdout : "0.000\n1.000\n" } ,
564+ ] ) ;
565+ vi . resetModules ( ) ;
566+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
567+
568+ const { extractAudioMetadata, analyzeKeyframeIntervals } = await import ( "./ffprobe.js" ) ;
569+ await extractAudioMetadata ( "/tmp/-audio.wav" ) ;
570+ await analyzeKeyframeIntervals ( "/tmp/-video.mp4" ) ;
571+
572+ const args = calls . flatMap ( ( call ) => [ ...( call . args ?? [ ] ) ] ) ;
573+ expect ( args . filter ( ( arg ) => arg === "--" ) ) . toHaveLength ( 3 ) ;
574+ } ) ;
575+ } ) ;
576+
577+ describe ( "ffprobe frame rate parsing" , ( ) => {
578+ afterEach ( ( ) => {
579+ vi . resetModules ( ) ;
580+ vi . doUnmock ( "child_process" ) ;
581+ } ) ;
582+
583+ it . each ( [
584+ { r : "30/1" , avg : "30/1" , expected : 30 } ,
585+ { r : "30000/1001" , avg : "30000/1001" , expected : 29.97 } ,
586+ { r : "30/" , avg : undefined , expected : 0 } ,
587+ { r : "30/0" , avg : undefined , expected : 0 } ,
588+ { r : "0/0" , avg : undefined , expected : 0 } ,
589+ { r : "abc/def" , avg : undefined , expected : 0 } ,
590+ { r : "60" , avg : undefined , expected : 60 } ,
591+ ] ) ( "parses r=$r avg=$avg as fps=$expected" , async ( { r, avg, expected } ) => {
592+ const { spawn } = createSpawnSpy ( [
593+ {
594+ kind : "exit" ,
595+ code : 0 ,
596+ stdout : JSON . stringify ( {
597+ streams : [
598+ {
599+ codec_type : "video" ,
600+ codec_name : "h264" ,
601+ width : 320 ,
602+ height : 180 ,
603+ r_frame_rate : r ,
604+ avg_frame_rate : avg ,
605+ } ,
606+ ] ,
607+ format : { duration : "1.5" } ,
608+ } ) ,
609+ } ,
610+ ] ) ;
611+ vi . resetModules ( ) ;
612+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
613+
614+ const { extractMediaMetadata } = await import ( "./ffprobe.js" ) ;
615+ const meta = await extractMediaMetadata ( "/tmp/frame-rate.mp4" ) ;
616+
617+ expect ( meta . fps ) . toBe ( expected ) ;
618+ } ) ;
619+ } ) ;
620+
621+ describe ( "extractPngMetadataFromBuffer cICP ordering" , ( ) => {
622+ it ( "does not emit color space until IHDR provides width and height" , ( ) => {
623+ const ihdr = pngChunk ( "IHDR" , [ 0 , 0 , 0 , 1 , 0 , 0 , 0 , 1 , 16 , 2 , 0 , 0 , 0 ] ) ;
624+ const cicp = pngChunk ( "cICP" , [ 9 , 16 , 0 , 1 ] ) ;
625+ const iend = pngChunk ( "IEND" , [ ] ) ;
626+
627+ // cICP before IHDR is invalid PNG ordering; make sure we don't return
628+ // zero-sized metadata in that case.
629+ const malformed = buildPngWithChunks ( [ cicp , ihdr , iend ] ) ;
630+ expect ( extractPngMetadataFromBuffer ( malformed ) ) . toEqual ( {
631+ width : 1 ,
632+ height : 1 ,
633+ colorSpace : {
634+ colorPrimaries : "bt2020" ,
635+ colorTransfer : "smpte2084" ,
636+ colorSpace : "gbr" ,
637+ } ,
638+ } ) ;
639+
640+ // Without any IHDR, a cICP alone should not produce a result.
641+ const onlyCicp = buildPngWithChunks ( [ cicp , iend ] ) ;
642+ expect ( extractPngMetadataFromBuffer ( onlyCicp ) ) . toBeNull ( ) ;
643+ } ) ;
644+ } ) ;
0 commit comments