@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
66import {
77 extractMediaMetadata ,
88 extractPngMetadataFromBuffer ,
9+ parseFrameRate ,
910 pixelFormatHasAlpha ,
1011} from "./ffprobe.js" ;
1112
@@ -578,47 +579,68 @@ describe("ffprobe option separator", () => {
578579 } ) ;
579580} ) ;
580581
581- describe ( "ffprobe frame rate parsing" , ( ) => {
582- afterEach ( ( ) => {
583- vi . resetModules ( ) ;
584- vi . doUnmock ( "child_process" ) ;
582+ describe ( "parseFrameRate" , ( ) => {
583+ // Direct against the exported function. The previous table drove this
584+ // through extractMediaMetadata behind a spawn mock, which cost a
585+ // vi.resetModules() plus a dynamic re-import of core's 238-file barrel per
586+ // row (74.9 ms vs 0.094 ms) — and 4 of its 7 rows produced identical values
587+ // against the pre-fix implementation, so it could not fail for the bugs it
588+ // was written to catch.
589+ it . each ( [
590+ [ "30/1" , 30 ] ,
591+ [ "30000/1001" , 29.97 ] ,
592+ [ "24000/1001" , 23.98 ] ,
593+ [ "60" , 60 ] ,
594+ [ "25.5" , 25.5 ] ,
595+ ] ) ( "parses %s as %s" , ( input , expected ) => {
596+ expect ( parseFrameRate ( input ) ) . toBe ( expected ) ;
585597 } ) ;
586598
587599 it . each ( [
588- { r : "30/1" , avg : "30/1" , expected : 30 } ,
589- { r : "30000/1001" , avg : "30000/1001" , expected : 29.97 } ,
590- { r : "30/" , avg : undefined , expected : 0 } ,
591- { r : "30/0" , avg : undefined , expected : 0 } ,
592- { r : "0/0" , avg : undefined , expected : 0 } ,
593- { r : "abc/def" , avg : undefined , expected : 0 } ,
594- { r : "60" , avg : undefined , expected : 60 } ,
595- ] ) ( "parses r=$r avg=$avg as fps=$expected" , async ( { r, avg, expected } ) => {
596- const { spawn } = createSpawnSpy ( [
597- {
598- kind : "exit" ,
599- code : 0 ,
600- stdout : JSON . stringify ( {
601- streams : [
602- {
603- codec_type : "video" ,
604- codec_name : "h264" ,
605- width : 320 ,
606- height : 180 ,
607- r_frame_rate : r ,
608- avg_frame_rate : avg ,
609- } ,
610- ] ,
611- format : { duration : "1.5" } ,
612- } ) ,
613- } ,
614- ] ) ;
615- vi . resetModules ( ) ;
616- vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
600+ [ "30/" , 0 ] ,
601+ [ "30/0" , 0 ] ,
602+ [ "0/0" , 0 ] ,
603+ [ "abc/def" , 0 ] ,
604+ [ "" , 0 ] ,
605+ [ undefined , 0 ] ,
606+ ] ) ( "returns 0 for unusable input %s" , ( input , expected ) => {
607+ expect ( parseFrameRate ( input ) ) . toBe ( expected ) ;
608+ } ) ;
617609
618- const { extractMediaMetadata } = await import ( "./ffprobe.js" ) ;
619- const meta = await extractMediaMetadata ( "/tmp/frame-rate.mp4" ) ;
610+ // Finite operands, infinite quotient — the operand-only guard missed these.
611+ it . each ( [ "1e308/1e-10" , "2/1e-320" ] ) ( "returns 0 for overflowing quotient %s" , ( input ) => {
612+ expect ( parseFrameRate ( input ) ) . toBe ( 0 ) ;
613+ } ) ;
620614
621- expect ( meta . fps ) . toBe ( expected ) ;
615+ // Negatives were truthy, so `meta.fps || 30` did not rescue them and
616+ // buildEncoderArgs emitted `-r -30`.
617+ it . each ( [ "-30/1" , "30/-1" , "-60" ] ) ( "returns 0 for negative rate %s" , ( input ) => {
618+ expect ( parseFrameRate ( input ) ) . toBe ( 0 ) ;
619+ } ) ;
620+
621+ // Fell through to a bare parseFloat that stops at trailing garbage. The
622+ // rational operands had the same defect after the plain path was fixed.
623+ it . each ( [ "30/1/2" , "60fps" , "60fps/1" , "60/1fps" , "30garbage/1garbage" , "/" , "/1" , "30/" ] ) (
624+ "returns 0 for malformed input %s" ,
625+ ( input ) => {
626+ expect ( parseFrameRate ( input ) ) . toBe ( 0 ) ;
627+ } ,
628+ ) ;
629+
630+ // raw * 100 overflows for a finite-but-huge rate, so the rounded value was
631+ // Infinity even though the pre-round guard passed.
632+ it . each ( [ "1e307" , "1e307/1" , "1e308/0.5" ] ) ( "returns 0 when rounding overflows: %s" , ( input ) => {
633+ expect ( parseFrameRate ( input ) ) . toBe ( 0 ) ;
634+ } ) ;
635+
636+ // 2dp rounding collapsed these to 0, and the caller's `|| 30` then
637+ // re-encoded a 300-second timelapse as a ~1/30-second clip.
638+ it . each ( [
639+ [ "1/300" , 0.01 ] ,
640+ [ "1/1000" , 0.01 ] ,
641+ [ "1/200" , 0.01 ] ,
642+ ] ) ( "floors sub-0.005 rate %s to %s rather than 0" , ( input , expected ) => {
643+ expect ( parseFrameRate ( input ) ) . toBe ( expected ) ;
622644 } ) ;
623645} ) ;
624646
0 commit comments