Skip to content

Commit b5830fa

Browse files
committed
ci: perf
1 parent 8446e6d commit b5830fa

File tree

13 files changed

+2653
-4012
lines changed

13 files changed

+2653
-4012
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ tsconfig.tsbuildinfo
77
compilation-stats.json
88
.yarn/cache
99
/packagehash.txt
10+
src/assets-optimized/
11+
public-optimized/
1012
public/bit
1113
api/node_modules
1214
webpack/profiling

package-lock.json

Lines changed: 2357 additions & 4002 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
"scripts": {
4242
"devtools": "react-devtools",
4343
"prebuild": "npm run clean:dist",
44+
"optimize:media": "node scripts/optimize-media.mjs",
45+
"preprod:serve": "npm run optimize:media",
46+
"prebuild:production": "node -e \"const cp=require('child_process'); const isCI=process.env.CI==='true'||process.env.CI==='1'||process.env.GITHUB_ACTIONS==='true'||process.env.VERCEL==='1'; const force=process.env.OPTIMIZE_MEDIA==='1'||process.env.OPTIMIZE_MEDIA==='true'; if(isCI&&!force){console.log('[optimize:media] CI detected, skipping. Set OPTIMIZE_MEDIA=1 to force.'); process.exit(0);} cp.execSync('npm run optimize:media',{stdio:'inherit'});\"",
47+
"prebuild:production:zip": "node -e \"const cp=require('child_process'); const isCI=process.env.CI==='true'||process.env.CI==='1'||process.env.GITHUB_ACTIONS==='true'||process.env.VERCEL==='1'; const force=process.env.OPTIMIZE_MEDIA==='1'||process.env.OPTIMIZE_MEDIA==='true'; if(isCI&&!force){console.log('[optimize:media] CI detected, skipping. Set OPTIMIZE_MEDIA=1 to force.'); process.exit(0);} cp.execSync('npm run optimize:media',{stdio:'inherit'});\"",
4448
"pre:run": "install-changed",
4549
"ci:quality": "npm run lint:fix && npm run test",
4650
"prepare": "husky install",
@@ -157,6 +161,7 @@
157161
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.1",
158162
"@sentry/vite-plugin": "^4.6.1",
159163
"@sentry/webpack-plugin": "^4.6.1",
164+
"@squoosh/lib": "^0.3.1",
160165
"@storybook/addon-a11y": "^10.1.7",
161166
"@storybook/addon-docs": "^10.1.7",
162167
"@storybook/addon-webpack5-compiler-swc": "^4.0.2",
@@ -213,7 +218,7 @@
213218
"eslint-plugin-storybook": "^10.1.7",
214219
"eslint-plugin-unicorn": "^62.0.0",
215220
"eslint-webpack-plugin": "^5.0.2",
216-
"filemanager-webpack-plugin": "^6.0.0",
221+
"filemanager-webpack-plugin": "^9.0.1",
217222
"fork-ts-checker-webpack-plugin": "^9.1.0",
218223
"gh-pages": "^6.3.0",
219224
"glob": "^13.0.0",
@@ -223,6 +228,7 @@
223228
"html-webpack-plugin": "^5.6.5",
224229
"http-server": "^14.1.1",
225230
"husky": "^9.1.7",
231+
"image-minimizer-webpack-plugin": "^4.1.4",
226232
"inquirer": "^13.0.1",
227233
"install-pkg-lock": "^1.0.2",
228234
"isomorphic-fetch": "^3.0.0",
@@ -260,6 +266,7 @@
260266
"rollup-plugin-visualizer": "^6.0.5",
261267
"scripty": "^3.0.0",
262268
"serve": "^14.2.5",
269+
"sharp": "^0.34.5",
263270
"shelljs": "^0.10.0",
264271
"shiki": "^3.15.0",
265272
"sonar-scanner": "^3.1.0",

public/audio/hearty.mp3

-3.53 MB
Binary file not shown.

public/audio/longnight.mp3

-3.99 MB
Binary file not shown.

public/audio/yesterday.mp3

-3.86 MB
Binary file not shown.

public/images/he.png

-1.06 MB
Binary file not shown.

public/images/song.png

-1.03 MB
Binary file not shown.

public/images/xue.png

-909 KB
Binary file not shown.

scripts/optimize-media.mjs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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

Comments
 (0)