// storyboard2.ts // ported from https://github.com/Dibyakshu/go-video-storyboard-generator/blob/main/main.go import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { getNanoSpawn } from './nanoSpawn'; // Entry point (async function main() { if (process.argv.length < 5) { showUsage(); } const fps = parseInt(process.argv[2], 10); const inputFile = process.argv[3]; const outputDir = process.argv[4]; if (isNaN(fps) || fps <= 0) { console.error('Error: Invalid FPS value. Please provide a positive integer.'); process.exit(1); } ensureDirectoryExists(outputDir); const startTime = Date.now(); const thumbnailsDir = join(outputDir, 'thumbnails'); ensureDirectoryExists(thumbnailsDir); generateThumbnails(fps, inputFile, thumbnailsDir); const storyboardImage = join(outputDir, 'storyboard.jpg'); generateStoryboard(thumbnailsDir, storyboardImage); const vttFile = join(outputDir, 'storyboard.vtt'); generateVTT(fps, thumbnailsDir, vttFile); cleanup(thumbnailsDir); const elapsed = (Date.now() - startTime) / 1000; console.log(`Process completed in ${elapsed.toFixed(2)} seconds.`); })(); function showUsage(): never { console.log('Usage: ts-node storyboard.ts '); console.log('Example: ts-node storyboard.ts 1 example.mp4 ./output'); process.exit(1); } function ensureDirectoryExists(path: string) { if (!existsSync(path)) { mkdirSync(path, { recursive: true }); } } export async function generateThumbnails(fps: number, inputFile: string, thumbnailsDir: string) { const spawn = await getNanoSpawn() console.log('Generating thumbnails...'); try { spawn('ffmpeg', [ '-i', inputFile, '-vf', `fps=1/${fps},scale=384:160`, join(thumbnailsDir, 'thumb%05d.jpg'), ], { stdio: 'inherit' }); console.log('Thumbnails generated successfully.'); } catch (err) { console.error('Error generating thumbnails:', err); process.exit(1); } } export async function generateStoryboard(thumbnailsDir: string, storyboardImage: string) { const spawn = await getNanoSpawn() console.log('Creating storyboard.jpg...'); const thumbnails = readdirSync(thumbnailsDir).filter(f => f.endsWith('.jpg')); if (thumbnails.length === 0) { console.error('No thumbnails found. Exiting.'); process.exit(1); } const columns = 10; const rows = Math.ceil(thumbnails.length / columns); const tileFilter = `tile=${columns}x${rows}`; try { await spawn('ffmpeg', [ '-pattern_type', 'glob', '-i', join(thumbnailsDir, '*.jpg'), '-filter_complex', tileFilter, storyboardImage, ], { stdio: 'inherit' }); console.log('Storyboard image created successfully.'); } catch (err) { console.error('Error creating storyboard image:', err); process.exit(1); } } function generateVTT(fps: number, thumbnailsDir: string, vttFile: string) { console.log('Generating storyboard.vtt...'); const thumbnails = readdirSync(thumbnailsDir).filter(f => f.startsWith('thumb') && f.endsWith('.jpg')); const durationPerThumb = fps; let vttContent = 'WEBVTT\n\n'; for (let i = 0; i < thumbnails.length; i++) { const start = i * durationPerThumb * 1000; const end = start + durationPerThumb * 1000; const x = (i % 10) * 384; const y = Math.floor(i / 10) * 160; vttContent += `${formatDuration(start)} --> ${formatDuration(end)}\n`; vttContent += `https://insertlinkhere.com/storyboard.jpg#xywh=${x},${y},384,160\n\n`; } try { writeFileSync(vttFile, vttContent, 'utf8'); console.log('Storyboard VTT file generated successfully.'); } catch (err) { console.error('Error writing VTT file:', err); process.exit(1); } } function cleanup(thumbnailsDir: string) { console.log('Cleaning up temporary files...'); try { rmSync(thumbnailsDir, { recursive: true, force: true }); } catch (err) { console.error('Error cleaning up thumbnails:', err); process.exit(1); } } function formatDuration(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; const milliseconds = ms % 1000; return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}`; } function pad(n: number, width = 2): string { return n.toString().padStart(width, '0'); }