Skip to content

Commit 279149e

Browse files
committed
feat: update graph rendering to use FFmpeg for animated WebP output
1 parent 91c3e41 commit 279149e

3 files changed

Lines changed: 84 additions & 58 deletions

File tree

docs/graph-params.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747

4848
| Param | Type | Default | Description |
4949
|---|---|---|---|
50-
| `as` | `svg` \| `webp` \| `png` \| `gif` | `svg` | Output format. Raster formats are converted server-side via sharp. |
50+
| `as` | `svg` \| `webp` \| `png` | `svg` | Output format. Raster formats are converted server-side via sharp / FFmpeg. |
5151

5252
---
5353

@@ -56,8 +56,7 @@
5656
| Value | MIME type | Animated | Notes |
5757
|---|---|---|---|
5858
| `svg` | `image/svg+xml` | ✅ native | Default — inline `<animate>` elements, all filters preserved |
59-
| `gif` | `image/gif` | ✅ 20 frames | Per-frame opacity snapshots assembled with gifencoder |
60-
| `webp` | `image/webp` | ✅ 20 frames | GIF frames converted to animated WebP via sharp |
59+
| `webp` | `image/webp` | ✅ 20 frames | Per-frame PNG snapshots encoded into animated WebP via FFmpeg |
6160
| `png` | `image/png` | ❌ static | Single raster snapshot, lossless |
6261

6362
---

src/components/graph-renderer.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,12 @@ export class GraphRenderer {
187187
const ANIM_CYCLE = 8; // seconds — covers all animation durations
188188
const isFrameExport = frameTime !== undefined;
189189

190-
// Pre-compute per-column glow animation strings — identical for every row in a column
190+
// Pre-compute per-column glow CSS animation strings — identical for every row in a column
191191
const glowAnimPerCol: string[] =
192192
(animateMode !== 'wave' && animateMode !== 'pulse')
193193
? Array.from({ length: weeksLen }, (_, x) => {
194194
const dur = (2.0 + (x % 4) * 0.5).toFixed(1);
195-
return `<animate attributeName="opacity" values="0.2;0.7;0.2" dur="${dur}s" begin="${xDelayFmt[x]}s" repeatCount="indefinite"/>`;
195+
return `style="animation:graph-glow ${dur}s ${xDelayFmt[x]}s infinite"`;
196196
})
197197
: [];
198198

@@ -250,19 +250,17 @@ export class GraphRenderer {
250250
// ── Live SVG animation ─────────────────────────────────────────
251251
if (animateMode === 'wave') {
252252
const d = (x * 0.05 + y * 0.02).toFixed(2);
253-
const anim = `<animate attributeName="opacity" values="0.1;0.8;0.1" dur="3s" begin="${d}s" repeatCount="indefinite"/>`;
254-
cellParts.push(`<g>${pfx} opacity="0.4" filter="url(#glowSmall)">${anim}</rect>${pfx} opacity="0.85"/></g>`);
253+
cellParts.push(`<g>${pfx} opacity="0.4" filter="url(#glowSmall)" style="animation:graph-wave 3s ${d}s infinite"/>${pfx} opacity="0.85"/></g>`);
255254
} else if (animateMode === 'pulse') {
256255
const seed = (x * 7 + y) * 1337 % 1000;
257256
if (seed % 22 !== 0) {
258257
cellParts.push(`${pfx} opacity="0.85"/>`);
259258
} else {
260-
const anim = `<animate attributeName="opacity" values="0.1;1;0.1" dur="${(1.5 + (seed % 10) * 0.1).toFixed(1)}s" begin="${(seed % 20 * 0.1).toFixed(1)}s" repeatCount="indefinite"/>`;
261-
cellParts.push(`<g>${pfx} opacity="0.4" filter="url(#glowSmall)">${anim}</rect>${pfx} opacity="0.85"/></g>`);
259+
cellParts.push(`<g>${pfx} opacity="0.4" filter="url(#glowSmall)" style="animation:graph-pulse ${(1.5 + (seed % 10) * 0.1).toFixed(1)}s ${(seed % 20 * 0.1).toFixed(1)}s infinite"/>${pfx} opacity="0.85"/></g>`);
262260
}
263261
} else {
264-
// glow (default) — animation string is pre-computed per column
265-
cellParts.push(`<g>${pfx} opacity="0.4" filter="url(#glowSmall)">${glowAnim}</rect>${pfx} opacity="0.85"/></g>`);
262+
// glow (default) — CSS animation style is pre-computed per column
263+
cellParts.push(`<g>${pfx} opacity="0.4" filter="url(#glowSmall)" ${glowAnim}/>${pfx} opacity="0.85"/></g>`);
266264
}
267265
}
268266
}
@@ -372,13 +370,11 @@ export class GraphRenderer {
372370
<stop offset="100%" stop-color="${theme.iconColor}" stop-opacity="0"/>
373371
</linearGradient>
374372
<filter id="textGlow" x="-20%" y="-60%" width="140%" height="220%">
375-
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur">
376-
<animate attributeName="stdDeviation" values="0.5;2.5;0.8;3;0.5;1.5;2;0.5" dur="9s" repeatCount="indefinite"/>
377-
</feGaussianBlur>
373+
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur"/>
378374
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
379375
</filter>
380376
</defs>
381-
<style>${fontFace} text{font-family:${fontFamily}}</style>
377+
<style>${fontFace} text{font-family:${fontFamily}} @keyframes graph-wave{0%,100%{opacity:.1}50%{opacity:.8}} @keyframes graph-glow{0%,100%{opacity:.2}50%{opacity:.7}} @keyframes graph-pulse{0%,100%{opacity:.1}50%{opacity:1}}</style>
382378
<rect width="100%" height="100%" fill="${showBackground ? 'url(#spaceGradient)' : 'none'}"/>
383379
${stars}
384380
${gridLines}

src/controllers/graph.ts

Lines changed: 74 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ import { Request, Response } from 'express';
22
import { GitHubClient } from '../utils/github-client.js';
33
import { GraphRenderer } from '../components/graph-renderer.js';
44
import 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';
59
import { createRequire } from 'module';
6-
import { PNG } from 'pngjs';
710
const _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

1116
export 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

Comments
 (0)