143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
// 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 <fps> <input_file> <output_dir>');
|
|
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');
|
|
}
|