Compare commits

...

1 Commits

Author SHA1 Message Date
868bee7931 perf(rp): 사진 다운로드 병렬화로 속도 개선
한 장씩 순차+딜레이(0.8s)로 느리던 사진 다운로드를 워커 풀(동시 4~8, CPU 기반)로
병렬화. 장간 고정 딜레이 제거, 음악 단계 후 쿨다운 4s→1s 로 축소. 429 는 기존
지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패 시 새 작업 배정을 멈춘 뒤
워커가 드레인되면 그 오류를 던진다(이어받기·취소 동작 유지). 0.4.9→0.4.10.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-20 13:54:53 +09:00
2 changed files with 67 additions and 53 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.4.9", "version": "0.4.10",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {

View File

@@ -77,10 +77,10 @@ function pickMusicConcurrency(): number {
*/ */
const MUSIC_START_STAGGER_MS = 2000 const MUSIC_START_STAGGER_MS = 2000
/** 사진(썸네일) 다운로드 사이 최소 간격(ms). i.ytimg.com 429(rate limit) 를 유발하지 않도록 순차 요청을 살짝 벌린다. */ /** 사진(썸네일) 동시 다운로드 상한. 순차 대신 병렬로 받아 속도를 높인다. 429 는 images.ts 의 지수 백오프 재시도가 흡수한다. */
const IMAGE_REQUEST_INTERVAL_MS = 800 const IMAGE_CONCURRENCY_CAP = 8
/** 음악(yt-dlp) 단계 직후 유튜브 IP 를 일시 throttle 수 있어, 사진 단계 전 잠깐 쉬어 429 를 피한다. */ /** 음악(yt-dlp) 단계 직후 유튜브 IP throttle 이 남아 있을 수 있어, 사진 단계 전 아주 잠깐만 쉰다. */
const IMAGE_PHASE_COOLDOWN_MS = 4000 const IMAGE_PHASE_COOLDOWN_MS = 1000
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)) const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
@@ -516,58 +516,72 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
sendLog(t('log.imageCooldown', { secs: Math.round(IMAGE_PHASE_COOLDOWN_MS / 1000) })) sendLog(t('log.imageCooldown', { secs: Math.round(IMAGE_PHASE_COOLDOWN_MS / 1000) }))
await sleep(IMAGE_PHASE_COOLDOWN_MS) await sleep(IMAGE_PHASE_COOLDOWN_MS)
} }
let imageRequests = 0 // 여러 장을 동시에 받아 속도를 높인다(순차+딜레이 대신 워커 풀). 유튜브 429 는
for (let i = 0; i < imageTotal; i++) { // 각 다운로드의 지수 백오프 재시도(images.ts)가 흡수하고, 첫 하드 실패를 만나면
throwIfCancelled() // 새 작업 배정을 멈춘 뒤 워커가 모두 끝나면 그 오류를 던진다.
const entry = pack.list.images[i] const images = pack.list.images
const idx = i + 1 let imageNext = 0
// 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기). let imageError: Error | null = null
const coverPath = path.join(paintingDir, coverFileName(idx)) async function imageWorker(): Promise<void> {
if (await fileExists(coverPath)) { while (true) {
sendLog(t('log.imageSkip', { idx })) if (state.cancelRequested || imageError) return
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' }) const i = imageNext++
continue if (i >= imageTotal) return
} const idx = i + 1
// 실제 네트워크 요청을 하는 사진들 사이에만 간격을 둔다(건너뛴 것 사이엔 대기 없음). const entry = images[i]
if (imageRequests > 0) await sleep(IMAGE_REQUEST_INTERVAL_MS + Math.floor(Math.random() * 300)) const coverPath = path.join(paintingDir, coverFileName(idx))
imageRequests++ // 이전 시도에서 이미 정규화해둔 사진은 건너뛴다(이어받기).
sendLog(t('log.imageDownloading', { idx })) if (await fileExists(coverPath)) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' }) sendLog(t('log.imageSkip', { idx }))
let buf: Buffer sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
try { continue
buf = await downloadImage(entry.url, (info) => { }
const secs = Math.ceil(info.delayMs / 1000) sendLog(t('log.imageDownloading', { idx }))
sendLog(t('log.imageRetry', { idx, attempt: info.attempt, code: info.code ?? '-', secs })) sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
sendProgress({ let buf: Buffer
phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running', try {
message: t('progress.imageRetry', { code: info.code ?? '-', secs }) buf = await downloadImage(entry.url, (info) => {
const secs = Math.ceil(info.delayMs / 1000)
sendLog(t('log.imageRetry', { idx, attempt: info.attempt, code: info.code ?? '-', secs }))
sendProgress({
phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running',
message: t('progress.imageRetry', { code: info.code ?? '-', secs })
})
}) })
}) } catch (err) {
} catch (err) { // 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지).
// 부분 생성됐을 수 있는 커버 파일 제거(이어받기 시 완성본 오인 방지). await fsp.rm(coverPath, { force: true }).catch(() => {})
await fsp.rm(coverPath, { force: true }).catch(() => {}) const rawMsg = (err as Error).message
const rawMsg = (err as Error).message // 429(rate limit)는 유튜브가 IP 를 일시 차단한 것. 잠시 뒤 다시 시도하면
// 429(rate limit)는 유튜브가 IP 를 일시 차단한 것. 잠시 뒤 다시 시도하면 // 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다.
// 이미 받은 사진은 건너뛰고 이어받는다는 안내를 덧붙인다. const msg = /429/.test(rawMsg) ? `${rawMsg}${t('errors.imageRateLimitHint')}` : rawMsg
const msg = /429/.test(rawMsg) ? `${rawMsg}${t('errors.imageRateLimitHint')}` : rawMsg sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: msg })
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: msg }) if (!imageError) imageError = new Error(t('errors.imageDownloadFailed', { idx, message: msg }))
throw new Error(t('errors.imageDownloadFailed', { idx, message: msg })) return
}
if (state.cancelRequested || imageError) return
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
try {
await normalizeToCover(buf, coverPath)
} catch (err) {
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {})
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
if (!imageError) imageError = new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
return
}
sendLog(t('log.imageDone', { idx, name: path.basename(coverPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
} }
throwIfCancelled()
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
const outPath = coverPath
try {
await normalizeToCover(buf, outPath)
} catch (err) {
// 변환 중 부분 생성된 PNG 제거(이어받기 시 완성본 오인 방지).
await fsp.rm(coverPath, { force: true }).catch(() => {})
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
}
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
} }
const imageWorkerCount = Math.min(IMAGE_CONCURRENCY_CAP, Math.max(1, imageTotal), Math.max(concurrency, 4))
const imageWorkers: Promise<void>[] = []
for (let w = 0; w < imageWorkerCount; w++) imageWorkers.push(imageWorker())
await Promise.all(imageWorkers)
throwIfCancelled()
if (imageError) throw imageError
// 2-4. 베이스 리소스팩 다운로드 (있을 때만) // 2-4. 베이스 리소스팩 다운로드 (있을 때만)
throwIfCancelled() throwIfCancelled()
let baseZipPath: string | undefined let baseZipPath: string | undefined