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",
"version": "0.4.9",
"version": "0.4.10",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js",
"scripts": {

View File

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