159 lines
4.8 KiB
JavaScript
159 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Compress sweets modal videos → uniform 640×480 (4:3), ≤ 200 KB, no audio.
|
||
* Usage:
|
||
* node scripts/optimize-sweets-videos.mjs
|
||
* node scripts/optimize-sweets-videos.mjs public/videos/badam-barfi.mp4
|
||
*/
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { spawnSync } from 'child_process';
|
||
import ffmpegPath from 'ffmpeg-static';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.join(__dirname, '..');
|
||
const VIDEOS_DIR = path.join(ROOT, 'public/videos');
|
||
const MENU_DATA = path.join(ROOT, 'infrastructure/menu/static-menu-data.ts');
|
||
const REPORT_PATH = path.join(__dirname, 'sweets-videos-optimize-report.json');
|
||
|
||
const MAX_BYTES = 200 * 1024;
|
||
const TARGET_WIDTH = 640;
|
||
const TARGET_HEIGHT = 480;
|
||
|
||
/** Sweets dish ids with video fields in menu data */
|
||
function getSweetsVideoFiles() {
|
||
const content = fs.readFileSync(MENU_DATA, 'utf8');
|
||
const sweetsIdx = content.indexOf('id: "sweets"');
|
||
const itemsStart = content.indexOf('items: [', sweetsIdx);
|
||
let depth = 0;
|
||
let itemsEnd = -1;
|
||
for (let i = itemsStart + 7; i < content.length; i++) {
|
||
if (content[i] === '[') depth++;
|
||
else if (content[i] === ']') {
|
||
depth--;
|
||
if (depth === 0) {
|
||
itemsEnd = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const block = content.slice(itemsStart, itemsEnd + 1);
|
||
return [...block.matchAll(/video:\s*"([^"]+\.mp4)"/g)].map((m) => m[1]);
|
||
}
|
||
|
||
function compressVideo(inputPath, outputPath, { crf, fps, width, height }) {
|
||
const vf = `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},fps=${fps}`;
|
||
const result = spawnSync(
|
||
ffmpegPath,
|
||
[
|
||
'-y',
|
||
'-i', inputPath,
|
||
'-an',
|
||
'-vf', vf,
|
||
'-c:v', 'libx264',
|
||
'-preset', 'medium',
|
||
'-profile:v', 'baseline',
|
||
'-level', '3.0',
|
||
'-pix_fmt', 'yuv420p',
|
||
'-movflags', '+faststart',
|
||
'-crf', String(crf),
|
||
outputPath,
|
||
],
|
||
{ stdio: 'pipe' },
|
||
);
|
||
return result.status === 0 && fs.existsSync(outputPath);
|
||
}
|
||
|
||
function optimizeOne(inputPath) {
|
||
if (!fs.existsSync(inputPath)) return { skipped: true, reason: 'missing' };
|
||
|
||
const temp = `${inputPath}.opt.tmp.mp4`;
|
||
const attempts = [
|
||
{ crf: 34, fps: 24, width: TARGET_WIDTH, height: TARGET_HEIGHT },
|
||
{ crf: 36, fps: 20, width: TARGET_WIDTH, height: TARGET_HEIGHT },
|
||
{ crf: 38, fps: 18, width: TARGET_WIDTH, height: TARGET_HEIGHT },
|
||
{ crf: 40, fps: 15, width: 560, height: 420 },
|
||
{ crf: 42, fps: 12, width: 480, height: 360 },
|
||
{ crf: 44, fps: 10, width: 400, height: 300 },
|
||
];
|
||
|
||
let best = null;
|
||
|
||
for (const opts of attempts) {
|
||
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
||
if (!compressVideo(inputPath, temp, opts)) continue;
|
||
|
||
const bytes = fs.statSync(temp).size;
|
||
const entry = { ...opts, bytes, kb: Number((bytes / 1024).toFixed(1)) };
|
||
|
||
if (bytes <= MAX_BYTES) {
|
||
fs.renameSync(temp, inputPath);
|
||
return { ok: true, ...entry, warning: null };
|
||
}
|
||
|
||
if (!best || bytes < best.bytes) best = entry;
|
||
}
|
||
|
||
if (best && fs.existsSync(temp)) {
|
||
fs.renameSync(temp, inputPath);
|
||
return { ok: true, ...best, warning: 'exceeds-200kb' };
|
||
}
|
||
|
||
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
||
return { ok: false, reason: 'encode-failed' };
|
||
}
|
||
|
||
function main() {
|
||
if (!ffmpegPath) throw new Error('ffmpeg-static not available');
|
||
|
||
const arg = process.argv[2];
|
||
const files = arg
|
||
? [path.basename(arg).endsWith('.mp4') ? path.basename(arg) : `${arg}.mp4`]
|
||
: getSweetsVideoFiles();
|
||
|
||
console.log('Shahi Kitchen — sweets video optimize');
|
||
console.log(`Target: ${TARGET_WIDTH}×${TARGET_HEIGHT}, ≤ ${MAX_BYTES / 1024} KB\n`);
|
||
|
||
const results = [];
|
||
|
||
for (const file of files) {
|
||
const fullPath = path.join(VIDEOS_DIR, file);
|
||
const before = fs.existsSync(fullPath) ? fs.statSync(fullPath).size : 0;
|
||
const r = optimizeOne(fullPath);
|
||
|
||
if (r.skipped) {
|
||
console.log(`⚠ skip ${file} (${r.reason})`);
|
||
results.push({ file, skipped: true, reason: r.reason });
|
||
continue;
|
||
}
|
||
if (!r.ok) {
|
||
console.log(`✗ ${file} (${r.reason})`);
|
||
results.push({ file, ok: false, reason: r.reason });
|
||
continue;
|
||
}
|
||
|
||
const after = fs.statSync(fullPath).size;
|
||
const warn = r.warning ? ' ⚠' : '';
|
||
console.log(
|
||
`✓ ${file}: ${(before / 1024).toFixed(0)} KB → ${(after / 1024).toFixed(1)} KB ` +
|
||
`(${r.width}×${r.height}, crf ${r.crf}, ${r.fps}fps)${warn}`,
|
||
);
|
||
results.push({
|
||
file,
|
||
ok: true,
|
||
beforeKb: Number((before / 1024).toFixed(1)),
|
||
afterKb: Number((after / 1024).toFixed(1)),
|
||
width: r.width,
|
||
height: r.height,
|
||
crf: r.crf,
|
||
fps: r.fps,
|
||
warning: r.warning,
|
||
});
|
||
}
|
||
|
||
fs.writeFileSync(REPORT_PATH, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2));
|
||
console.log(`\nReport: ${REPORT_PATH}`);
|
||
}
|
||
|
||
main(); |