|
| 1 | +import fs from 'node:fs/promises' |
| 2 | +import fsSync from 'node:fs' |
| 3 | +import path from 'node:path' |
| 4 | +import { spawnSync } from 'node:child_process' |
| 5 | + |
| 6 | +const projectRoot = process.cwd() |
| 7 | + |
| 8 | +const SRC_AUDIO_DIR = path.join(projectRoot, 'src', 'assets', 'audio') |
| 9 | +const SRC_VIDEO_DIR = path.join(projectRoot, 'src', 'assets', 'video') |
| 10 | +const SRC_PUBLIC_AUDIO_DIR = path.join(projectRoot, 'public', 'audio') |
| 11 | + |
| 12 | +const OUT_AUDIO_DIR = path.join(projectRoot, 'src', 'assets-optimized', 'audio') |
| 13 | +const OUT_VIDEO_DIR = path.join(projectRoot, 'src', 'assets-optimized', 'video') |
| 14 | +const OUT_PUBLIC_AUDIO_DIR = path.join(projectRoot, 'public-optimized', 'audio') |
| 15 | + |
| 16 | +const MP3_BITRATE = process.env.MP3_BITRATE || '128k' |
| 17 | +const MP4_CRF = process.env.MP4_CRF || '28' |
| 18 | +const MP4_PRESET = process.env.MP4_PRESET || 'medium' |
| 19 | +const MP4_AUDIO_BITRATE = process.env.MP4_AUDIO_BITRATE || '128k' |
| 20 | + |
| 21 | +const MIN_SAVINGS_PCT = Number(process.env.MEDIA_MIN_SAVINGS_PCT || '5') |
| 22 | +const SKIP_SMALL_KB = Number(process.env.MEDIA_SKIP_SMALL_KB || '256') |
| 23 | + |
| 24 | +function bytesToMiB(bytes) { |
| 25 | + return (bytes / 1024 / 1024).toFixed(2) |
| 26 | +} |
| 27 | + |
| 28 | +function runFfmpeg(args) { |
| 29 | + const result = spawnSync('ffmpeg', args, { |
| 30 | + stdio: 'inherit', |
| 31 | + windowsHide: true, |
| 32 | + }) |
| 33 | + if (result.error) { |
| 34 | + throw result.error |
| 35 | + } |
| 36 | + if (result.status !== 0) { |
| 37 | + throw new Error(`ffmpeg exited with code ${result.status}`) |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function isFileExists(filePath) { |
| 42 | + try { |
| 43 | + fsSync.accessSync(filePath) |
| 44 | + return true |
| 45 | + } catch { |
| 46 | + return false |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +async function ensureDir(dirPath) { |
| 51 | + await fs.mkdir(dirPath, { recursive: true }) |
| 52 | +} |
| 53 | + |
| 54 | +async function cleanDir(dirPath) { |
| 55 | + await fs.rm(dirPath, { recursive: true, force: true }) |
| 56 | + await ensureDir(dirPath) |
| 57 | +} |
| 58 | + |
| 59 | +async function* walkFiles(dirPath) { |
| 60 | + if (!isFileExists(dirPath)) return |
| 61 | + const entries = await fs.readdir(dirPath, { withFileTypes: true }) |
| 62 | + for (const entry of entries) { |
| 63 | + const fullPath = path.join(dirPath, entry.name) |
| 64 | + if (entry.isDirectory()) { |
| 65 | + yield* walkFiles(fullPath) |
| 66 | + } else if (entry.isFile()) { |
| 67 | + yield fullPath |
| 68 | + } |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +async function optimizeOne({ inputPath, inputBaseDir, outputBaseDir }) { |
| 73 | + const relPath = path.relative(inputBaseDir, inputPath) |
| 74 | + const outputPath = path.join(outputBaseDir, relPath) |
| 75 | + |
| 76 | + const ext = path.extname(inputPath).toLowerCase() |
| 77 | + if (ext !== '.mp3' && ext !== '.mp4') return { changed: false, reason: 'skip-ext' } |
| 78 | + |
| 79 | + const stat = await fs.stat(inputPath) |
| 80 | + const skipSmallBytes = SKIP_SMALL_KB * 1024 |
| 81 | + if (stat.size < skipSmallBytes) { |
| 82 | + await ensureDir(path.dirname(outputPath)) |
| 83 | + await fs.copyFile(inputPath, outputPath) |
| 84 | + return { changed: false, reason: 'skip-small-copied', before: stat.size, after: stat.size, outputPath } |
| 85 | + } |
| 86 | + |
| 87 | + await ensureDir(path.dirname(outputPath)) |
| 88 | + |
| 89 | + const dir = path.dirname(outputPath) |
| 90 | + const base = path.basename(outputPath) |
| 91 | + const stem = path.basename(outputPath, ext) |
| 92 | + const tmpPath = path.join(dir, `${stem}.tmp${ext}`) |
| 93 | + |
| 94 | + // Clean up stale temp files from previous runs |
| 95 | + try { |
| 96 | + await fs.unlink(tmpPath) |
| 97 | + } catch {} |
| 98 | + |
| 99 | + const commonArgs = ['-y', '-hide_banner', '-loglevel', 'error', '-i', inputPath, '-map_metadata', '0'] |
| 100 | + |
| 101 | + if (ext === '.mp3') { |
| 102 | + // Re-encode MP3 with target bitrate (libmp3lame) |
| 103 | + runFfmpeg([ |
| 104 | + ...commonArgs, |
| 105 | + '-f', |
| 106 | + 'mp3', |
| 107 | + '-vn', |
| 108 | + '-c:a', |
| 109 | + 'libmp3lame', |
| 110 | + '-b:a', |
| 111 | + MP3_BITRATE, |
| 112 | + '-id3v2_version', |
| 113 | + '3', |
| 114 | + tmpPath, |
| 115 | + ]) |
| 116 | + } else { |
| 117 | + // Re-encode MP4 (H.264 + AAC) and enable faststart |
| 118 | + runFfmpeg([ |
| 119 | + ...commonArgs, |
| 120 | + '-f', |
| 121 | + 'mp4', |
| 122 | + '-c:v', |
| 123 | + 'libx264', |
| 124 | + '-preset', |
| 125 | + MP4_PRESET, |
| 126 | + '-crf', |
| 127 | + String(MP4_CRF), |
| 128 | + '-pix_fmt', |
| 129 | + 'yuv420p', |
| 130 | + '-c:a', |
| 131 | + 'aac', |
| 132 | + '-b:a', |
| 133 | + MP4_AUDIO_BITRATE, |
| 134 | + '-movflags', |
| 135 | + '+faststart', |
| 136 | + tmpPath, |
| 137 | + ]) |
| 138 | + } |
| 139 | + |
| 140 | + const outStat = await fs.stat(tmpPath) |
| 141 | + |
| 142 | + // Only replace if we actually saved enough bytes |
| 143 | + const minKeepSize = Math.floor(stat.size * (1 - MIN_SAVINGS_PCT / 100)) |
| 144 | + if (outStat.size >= minKeepSize) { |
| 145 | + await fs.unlink(tmpPath) |
| 146 | + await fs.copyFile(inputPath, outputPath) |
| 147 | + return { changed: false, reason: 'no-savings-copied', before: stat.size, after: outStat.size, outputPath } |
| 148 | + } |
| 149 | + |
| 150 | + await fs.rename(tmpPath, outputPath) |
| 151 | + return { changed: true, reason: 'optimized', before: stat.size, after: outStat.size, outputPath } |
| 152 | +} |
| 153 | + |
| 154 | +function checkFfmpegAvailable() { |
| 155 | + const result = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true }) |
| 156 | + return result.status === 0 |
| 157 | +} |
| 158 | + |
| 159 | +async function main() { |
| 160 | + if (!checkFfmpegAvailable()) { |
| 161 | + console.error('[optimize:media] ffmpeg not found in PATH.') |
| 162 | + console.error('Install ffmpeg and ensure `ffmpeg` is available in your terminal, then re-run.') |
| 163 | + console.error('Windows quick path: https://www.gyan.dev/ffmpeg/builds/ (add bin to PATH)') |
| 164 | + process.exit(1) |
| 165 | + } |
| 166 | + |
| 167 | + const targets = [ |
| 168 | + { inputDir: SRC_AUDIO_DIR, outputDir: OUT_AUDIO_DIR }, |
| 169 | + { inputDir: SRC_VIDEO_DIR, outputDir: OUT_VIDEO_DIR }, |
| 170 | + { inputDir: SRC_PUBLIC_AUDIO_DIR, outputDir: OUT_PUBLIC_AUDIO_DIR }, |
| 171 | + ] |
| 172 | + |
| 173 | + let changedCount = 0 |
| 174 | + let scannedCount = 0 |
| 175 | + let savedBytes = 0 |
| 176 | + |
| 177 | + console.log( |
| 178 | + '[optimize:media] scanning:', |
| 179 | + targets.map((t) => path.relative(projectRoot, t.inputDir)).join(', ') |
| 180 | + ) |
| 181 | + console.log( |
| 182 | + '[optimize:media] output:', |
| 183 | + targets.map((t) => path.relative(projectRoot, t.outputDir)).join(', ') |
| 184 | + ) |
| 185 | + console.log( |
| 186 | + `[optimize:media] settings: MP3_BITRATE=${MP3_BITRATE}, MP4_CRF=${MP4_CRF}, MP4_PRESET=${MP4_PRESET}, MIN_SAVINGS_PCT=${MIN_SAVINGS_PCT}%, SKIP_SMALL_KB=${SKIP_SMALL_KB}KB` |
| 187 | + ) |
| 188 | + |
| 189 | + for (const target of targets) { |
| 190 | + await cleanDir(target.outputDir) |
| 191 | + for await (const inputPath of walkFiles(target.inputDir)) { |
| 192 | + scannedCount += 1 |
| 193 | + try { |
| 194 | + const result = await optimizeOne({ |
| 195 | + inputPath, |
| 196 | + inputBaseDir: target.inputDir, |
| 197 | + outputBaseDir: target.outputDir, |
| 198 | + }) |
| 199 | + if (result.changed) { |
| 200 | + changedCount += 1 |
| 201 | + savedBytes += result.before - result.after |
| 202 | + console.log( |
| 203 | + `[optimize:media] optimized ${path.relative(projectRoot, inputPath)} -> ${path.relative(projectRoot, result.outputPath)}: ${bytesToMiB(result.before)}MiB -> ${bytesToMiB(result.after)}MiB` |
| 204 | + ) |
| 205 | + } |
| 206 | + } catch (err) { |
| 207 | + console.error(`[optimize:media] failed: ${path.relative(projectRoot, inputPath)}`) |
| 208 | + console.error(err) |
| 209 | + process.exit(1) |
| 210 | + } |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + console.log( |
| 215 | + `[optimize:media] done. scanned=${scannedCount}, optimized=${changedCount}, saved=${bytesToMiB(savedBytes)}MiB` |
| 216 | + ) |
| 217 | +} |
| 218 | + |
| 219 | +main() |
0 commit comments