@@ -2,11 +2,16 @@ import { Request, Response } from 'express';
22import { GitHubClient } from '../utils/github-client.js' ;
33import { GraphRenderer } from '../components/graph-renderer.js' ;
44import sharp from 'sharp' ;
5+ import { spawn } from 'child_process' ;
6+ import { mkdir , writeFile , readFile , stat } from 'fs/promises' ;
7+ import { join , dirname } from 'path' ;
8+ import { fileURLToPath } from 'url' ;
59import { createRequire } from 'module' ;
6- import { PNG } from 'pngjs' ;
710const _require = createRequire ( import . meta. url ) ;
8- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9- const GIFEncoder = _require ( 'gifencoder' ) as any ;
11+ const ffmpegPath = _require ( 'ffmpeg-static' ) as string ;
12+ const __filename = fileURLToPath ( import . meta. url ) ;
13+ const __dirname = dirname ( __filename ) ;
14+ const publicDir = join ( __dirname , '..' , '..' , 'public' ) ;
1015
1116export class GraphController {
1217 private static githubClient : GitHubClient ;
@@ -21,6 +26,7 @@ export class GraphController {
2126 'animate' ,
2227 'size' ,
2328 'as' ,
29+ 'format' ,
2430 'show_title' ,
2531 'show_total_contribution' ,
2632 'show_background' ,
@@ -41,7 +47,7 @@ export class GraphController {
4147
4248 static async getSvg ( req : Request , res : Response ) {
4349 try {
44- const { username, theme = 'default' , year, animate, size, as : outputFormat , show_title, show_total_contribution, show_background, bgColor, borderColor, textColor, titleColor } = req . query ;
50+ const { username, theme = 'default' , year, animate, size, as : outputFormat , format : formatParam , show_title, show_total_contribution, show_background, bgColor, borderColor, textColor, titleColor } = req . query ;
4551
4652 if ( ! username || typeof username !== 'string' ) {
4753 return res . status ( 400 ) . send ( 'Username is required' ) ;
@@ -69,7 +75,7 @@ export class GraphController {
6975 displayYear = `${ oneYearAgo . getFullYear ( ) } -${ now . getFullYear ( ) } ` ;
7076 }
7177
72- const format = typeof outputFormat === 'string' ? outputFormat . toLowerCase ( ) : 'svg' ;
78+ const format = typeof outputFormat === 'string' ? outputFormat . toLowerCase ( ) : typeof formatParam === 'string' ? formatParam . toLowerCase ( ) : 'svg' ;
7379 const cacheKey = `graph-${ username } -${ theme } -${ cacheKeyExtra } -${ animate || '' } -${ size || '' } -${ show_title ?? '' } -${ show_total_contribution ?? '' } -${ show_background ?? '' } -${ bgColor || '' } -${ borderColor || '' } -${ textColor || '' } -${ titleColor || '' } ` ;
7480
7581 const contributions = await GraphController . githubClient . fetchUserContributions ( username , from , to , cacheKeyExtra ) ;
@@ -96,7 +102,7 @@ export class GraphController {
96102 return svg ;
97103 } ;
98104
99- if ( format === 'webp' || format === 'png' || format === 'gif' ) {
105+ if ( format === 'webp' || format === 'png' ) {
100106 const rasterCacheKey = `${ cacheKey } |${ format } ` ;
101107 const cachedRaster = ( GraphController as any ) . _rasterCache ?. get ( rasterCacheKey ) ;
102108 if ( cachedRaster && Date . now ( ) - cachedRaster . timestamp < GraphController . CACHE_DURATION ) {
@@ -112,50 +118,75 @@ export class GraphController {
112118 const svgData = await getSvg ( ) ;
113119 buffer = await sharp ( Buffer . from ( svgData ) ) . png ( ) . toBuffer ( ) ;
114120 } else {
115- // Animated GIF or WebP — generate frames via per-frame opacity snapshots
121+ // Animated WebP — generate frames and encode with FFmpeg
116122 const FRAME_COUNT = 20 ;
117123 const FRAME_DELAY_MS = 80 ; // ~12 fps
118124
119- // Rasterize all frames in parallel
120- const pngFrames = await Promise . all (
121- Array . from ( { length : FRAME_COUNT } , ( _ , i ) => {
122- const frameSvg = GraphRenderer . generateGraphCard ( graphData , cardOptions , i / FRAME_COUNT ) ;
123- return sharp ( Buffer . from ( frameSvg ) ) . png ( ) . toBuffer ( ) ;
124- } )
125- ) ;
126-
127- // Get canvas dimensions from first frame
128- const { width : fw , height : fh } = await sharp ( pngFrames [ 0 ] ) . metadata ( ) ;
129-
130- // Assemble animated GIF using createReadStream (stream mode is required)
131- const encoder = new GIFEncoder ( fw ! , fh ! ) ;
132- const gifChunks : Buffer [ ] = [ ] ;
133- const gifBuffer = await new Promise < Buffer > ( ( resolve , reject ) => {
134- const readStream = encoder . createReadStream ( ) ;
135- readStream . on ( 'data' , ( chunk : Buffer ) => gifChunks . push ( Buffer . from ( chunk ) ) ) ;
136- readStream . on ( 'end' , ( ) => resolve ( Buffer . concat ( gifChunks ) ) ) ;
137- readStream . on ( 'error' , reject ) ;
138-
139- encoder . start ( ) ;
140- encoder . setRepeat ( 0 ) ; // loop forever
141- encoder . setDelay ( FRAME_DELAY_MS ) ;
142- encoder . setQuality ( 10 ) ;
143- for ( const pngBuf of pngFrames ) {
144- const decoded = PNG . sync . read ( pngBuf ) ;
145- encoder . addFrame ( decoded . data as unknown as CanvasRenderingContext2D ) ;
146- }
147- encoder . finish ( ) ;
148- } ) ;
149-
150- buffer = format === 'webp'
151- // sharp can convert animated GIF → animated WebP natively
152- ? await sharp ( gifBuffer , { animated : true } ) . webp ( { loop : 0 } ) . toBuffer ( )
153- : gifBuffer ;
125+ if ( ! ffmpegPath ) throw new Error ( 'ffmpeg binary not found' ) ;
126+
127+ const tmpDir = join ( publicDir , 'user' , username ) ;
128+ await mkdir ( tmpDir , { recursive : true } ) ;
129+
130+ const outFile = join ( tmpDir , 'output.webp' ) ;
131+
132+ // Re-use the previously rendered file if it's still within cache window
133+ const reuse = await stat ( outFile )
134+ . then ( s => Date . now ( ) - s . mtimeMs < GraphController . CACHE_DURATION )
135+ . catch ( ( ) => false ) ;
136+
137+ if ( ! reuse ) {
138+ // Rasterize all frames in parallel
139+ const pngFrames = await Promise . all (
140+ Array . from ( { length : FRAME_COUNT } , ( _ , i ) => {
141+ const frameSvg = GraphRenderer . generateGraphCard ( graphData , cardOptions , i / FRAME_COUNT ) ;
142+ return sharp ( Buffer . from ( frameSvg ) ) . png ( ) . toBuffer ( ) ;
143+ } )
144+ ) ;
145+
146+ // Write frames with zero-padded names (no %-pattern issues on Windows)
147+ await Promise . all (
148+ pngFrames . map ( ( buf , i ) =>
149+ writeFile ( join ( tmpDir , `frame${ String ( i ) . padStart ( 3 , '0' ) } .png` ) , buf )
150+ )
151+ ) ;
152+
153+ // Build an explicit concat file to avoid shell-expanding % on Windows
154+ const concatLines = pngFrames
155+ . map ( ( _ , i ) => `file '${ join ( tmpDir , `frame${ String ( i ) . padStart ( 3 , '0' ) } .png` ) . replace ( / \\ / g, '/' ) } '\nduration ${ FRAME_DELAY_MS / 1000 } ` )
156+ . join ( '\n' ) ;
157+ const concatFile = join ( tmpDir , 'concat.txt' ) ;
158+ await writeFile ( concatFile , concatLines + '\n' ) ;
159+
160+ await new Promise < void > ( ( resolve , reject ) => {
161+ const stderr : Buffer [ ] = [ ] ;
162+ const proc = spawn ( ffmpegPath , [
163+ '-y' ,
164+ '-f' , 'concat' ,
165+ '-safe' , '0' ,
166+ '-i' , concatFile . replace ( / \\ / g, '/' ) ,
167+ '-c:v' , 'libwebp_anim' ,
168+ '-lossless' , '0' ,
169+ '-q:v' , '75' ,
170+ '-compression_level' , '4' ,
171+ '-loop' , '0' ,
172+ '-an' ,
173+ outFile . replace ( / \\ / g, '/' ) ,
174+ ] ) ;
175+ proc . stderr ?. on ( 'data' , ( chunk : Buffer ) => stderr . push ( chunk ) ) ;
176+ proc . on ( 'close' , ( code : number ) =>
177+ code === 0
178+ ? resolve ( )
179+ : reject ( new Error ( `ffmpeg exited ${ code } : ${ Buffer . concat ( stderr ) . toString ( ) . slice ( - 400 ) } ` ) )
180+ ) ;
181+ } ) ;
182+ }
183+
184+ buffer = await readFile ( outFile ) ;
154185 }
155186
156187 if ( ! ( GraphController as any ) . _rasterCache ) ( GraphController as any ) . _rasterCache = new Map ( ) ;
157188 ( GraphController as any ) . _rasterCache . set ( rasterCacheKey , { data : buffer , timestamp : Date . now ( ) } ) ;
158- res . setHeader ( 'Content-Type' , `image/${ format === 'gif' ? 'gif' : format === ' webp' ? 'webp' : 'png' } ` ) ;
189+ res . setHeader ( 'Content-Type' , `image/${ format === 'webp' ? 'webp' : 'png' } ` ) ;
159190 res . setHeader ( 'Cache-Control' , 'public, max-age=600' ) ;
160191 return res . send ( buffer ) ;
161192 }
0 commit comments