@@ -8,13 +8,48 @@ import {
88 type VisionCaptionOutcome ,
99} from "./contentExtractor.js" ;
1010
11- const { generateContentMock, clientOptions } = vi . hoisted ( ( ) => ( {
11+ const { generateContentMock, clientOptions, sharpState } = vi . hoisted ( ( ) => ( {
1212 generateContentMock : vi . fn ( ) ,
1313 // How the SDK client was constructed is the whole difference between the Vertex and API-key
1414 // paths, so the Vertex cases assert on it rather than on the request.
1515 clientOptions : [ ] as Record < string , unknown > [ ] ,
16+ // A native abort inside libvips cannot be caught, so the only defence is never running two
17+ // renders at once. That is a property of the loop, and this records it.
18+ sharpState : {
19+ inFlight : 0 ,
20+ maxInFlight : 0 ,
21+ concurrencyCalls : [ ] as number [ ] ,
22+ renders : [ ] as string [ ] ,
23+ } ,
1624} ) ) ;
1725
26+ vi . mock ( "sharp" , ( ) => {
27+ const pipeline = ( filePath : string ) => {
28+ const chain = {
29+ resize : ( ) => chain ,
30+ flatten : ( ) => chain ,
31+ png : ( ) => chain ,
32+ toBuffer : async ( ) => {
33+ sharpState . inFlight += 1 ;
34+ sharpState . maxInFlight = Math . max ( sharpState . maxInFlight , sharpState . inFlight ) ;
35+ sharpState . renders . push ( filePath ) ;
36+ // Yield, so an overlapping caller would be observed rather than serialized by luck.
37+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) ) ;
38+ sharpState . inFlight -= 1 ;
39+ return Buffer . from ( [ 0x89 , 0x50 , 0x4e , 0x47 ] ) ;
40+ } ,
41+ } ;
42+ return chain ;
43+ } ;
44+ const sharp = Object . assign ( pipeline , {
45+ concurrency : ( value : number ) => {
46+ sharpState . concurrencyCalls . push ( value ) ;
47+ return value ;
48+ } ,
49+ } ) ;
50+ return { default : sharp } ;
51+ } ) ;
52+
1853vi . mock ( "@google/genai" , ( ) => ( {
1954 GoogleGenAI : class {
2055 models = { generateContent : generateContentMock } ;
@@ -574,3 +609,113 @@ describe("captionImagesWithGemini — Vertex AI provider", () => {
574609 expect ( generateContentMock ) . not . toHaveBeenCalled ( ) ;
575610 } ) ;
576611} ) ;
612+
613+ describe ( "captionImagesWithGemini — SVG rasterization" , ( ) => {
614+ const dirs : string [ ] = [ ] ;
615+
616+ beforeEach ( ( ) => {
617+ sharpState . inFlight = 0 ;
618+ sharpState . maxInFlight = 0 ;
619+ sharpState . concurrencyCalls . length = 0 ;
620+ sharpState . renders . length = 0 ;
621+ clientOptions . length = 0 ;
622+ } ) ;
623+
624+ afterEach ( ( ) => {
625+ generateContentMock . mockReset ( ) ;
626+ vi . unstubAllGlobals ( ) ;
627+ vi . unstubAllEnvs ( ) ;
628+ for ( const d of dirs ) rmSync ( d , { recursive : true , force : true } ) ;
629+ dirs . length = 0 ;
630+ } ) ;
631+
632+ function makeProjectWithSvgs ( count : number ) : string {
633+ const dir = mkdtempSync ( join ( tmpdir ( ) , "hf-svg-" ) ) ;
634+ mkdirSync ( join ( dir , "assets" , "svgs" ) , { recursive : true } ) ;
635+ for ( let i = 0 ; i < count ; i ++ ) {
636+ writeFileSync (
637+ join ( dir , "assets" , "svgs" , `logo-${ i } .svg` ) ,
638+ '<svg xmlns="http://www.w3.org/2000/svg"><path fill="#000" d="M0 0h8v8H0z"/></svg>' ,
639+ ) ;
640+ }
641+ return dir ;
642+ }
643+
644+ it ( "never runs two libvips renders at once" , async ( ) => {
645+ // Production aborted here with `free(): unaligned chunk detected in tcache 2` (SIGABRT),
646+ // twice in fourteen days, losing the whole capture. A native abort cannot be caught by the
647+ // surrounding try/catch, so the concurrency has to be absent rather than handled — which
648+ // makes "one at a time" the assertion, not "errors are reported".
649+ const dir = makeProjectWithSvgs ( 6 ) ;
650+ dirs . push ( dir ) ;
651+ vi . stubEnv ( "OPENROUTER_API_KEY" , "or-key" ) ;
652+ vi . stubGlobal (
653+ "fetch" ,
654+ vi . fn ( async ( ) => {
655+ // Slower than a render, so overlapping renders would be the easy way to go faster —
656+ // the point is that the loop does not take it.
657+ await new Promise ( ( resolve ) => setTimeout ( resolve , 15 ) ) ;
658+ return new Response (
659+ JSON . stringify ( { choices : [ { message : { content : "A dark glyph." } } ] } ) ,
660+ {
661+ status : 200 ,
662+ headers : { "content-type" : "application/json" } ,
663+ } ,
664+ ) ;
665+ } ) ,
666+ ) ;
667+
668+ const captions = await captionImagesWithGemini ( dir , ( ) => { } , [ ] ) ;
669+
670+ expect ( sharpState . renders ) . toHaveLength ( 6 ) ;
671+ expect ( sharpState . maxInFlight ) . toBe ( 1 ) ;
672+ expect ( Object . keys ( captions ) ) . toHaveLength ( 6 ) ;
673+ } ) ;
674+
675+ it ( "bounds libvips' own worker pool as well" , async ( ) => {
676+ // Left at its default the pool sizes itself to the host's core count, so serializing the
677+ // loop alone still leaves one render fanning out across every core.
678+ const dir = makeProjectWithSvgs ( 1 ) ;
679+ dirs . push ( dir ) ;
680+ vi . stubEnv ( "OPENROUTER_API_KEY" , "or-key" ) ;
681+ vi . stubGlobal (
682+ "fetch" ,
683+ vi . fn (
684+ async ( ) =>
685+ new Response ( JSON . stringify ( { choices : [ { message : { content : "A glyph." } } ] } ) , {
686+ status : 200 ,
687+ headers : { "content-type" : "application/json" } ,
688+ } ) ,
689+ ) ,
690+ ) ;
691+
692+ await captionImagesWithGemini ( dir , ( ) => { } , [ ] ) ;
693+
694+ expect ( sharpState . concurrencyCalls ) . toEqual ( [ 1 ] ) ;
695+ } ) ;
696+
697+ it ( "keeps captioning the rest when one SVG cannot be rasterized" , async ( ) => {
698+ const dir = makeProjectWithSvgs ( 3 ) ;
699+ dirs . push ( dir ) ;
700+ vi . stubEnv ( "OPENROUTER_API_KEY" , "or-key" ) ;
701+ vi . stubGlobal (
702+ "fetch" ,
703+ vi . fn (
704+ async ( ) =>
705+ new Response ( JSON . stringify ( { choices : [ { message : { content : "A glyph." } } ] } ) , {
706+ status : 200 ,
707+ headers : { "content-type" : "application/json" } ,
708+ } ) ,
709+ ) ,
710+ ) ;
711+ // One file is unreadable, which is how an exotic SVG behaves through sharp.
712+ rmSync ( join ( dir , "assets" , "svgs" , "logo-1.svg" ) ) ;
713+ mkdirSync ( join ( dir , "assets" , "svgs" , "logo-1.svg" ) ) ;
714+
715+ const warnings : string [ ] = [ ] ;
716+ const captions = await captionImagesWithGemini ( dir , ( ) => { } , warnings ) ;
717+
718+ expect ( Object . keys ( captions ) . sort ( ) ) . toEqual ( [ "svgs/logo-0.svg" , "svgs/logo-2.svg" ] ) ;
719+ expect ( sharpState . maxInFlight ) . toBe ( 1 ) ;
720+ } ) ;
721+ } ) ;
0 commit comments