perf(installer-rp): parallel music download (3 concurrent) + fragmented chunks

- yt-dlp 인자에 --concurrent-fragments 5 추가 (HLS/DASH 청크 병렬 다운로드)
- yt-dlp 인자에 --newline 추가 (진행률 라인 안정화)
- 음악 다운로드 루프를 단일 순차 → worker pool 3개 동시 처리로 전환
- state.currentChild (단일) → state.activeChildren (Set) 으로 확장,
  취소 시 실행 중인 모든 자식 프로세스 kill
- UI 는 카드 그리드라 병렬 진행 상태가 그대로 표시됨

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 00:19:29 +09:00
parent 9f9cffffeb
commit 861e5678fc
2 changed files with 66 additions and 40 deletions

View File

@@ -23,10 +23,13 @@ interface RpInstallerState {
selectedKey: string | null
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
cancelRequested: boolean
/** 현재 실행 중인 외부 프로세스(yt-dlp/ffmpeg). 취소 시 kill 대상. */
currentChild: ChildProcess | null
/** 현재 실행 중인 외부 프로세스(yt-dlp/ffmpeg). 취소 시 모두 kill. */
activeChildren: Set<ChildProcess>
}
/** 동시 yt-dlp 프로세스 수. 너무 높이면 유튜브가 throttle. */
const MUSIC_CONCURRENCY = 3
const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
const state: RpInstallerState = {
@@ -35,7 +38,7 @@ const state: RpInstallerState = {
packs: new Map(),
selectedKey: null,
cancelRequested: false,
currentChild: null
activeChildren: new Set()
}
let mainWindow: BrowserWindow | null = null
@@ -196,47 +199,65 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
throwIfCancelled()
// 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
// 2-2. 음악 다운로드 (MUSIC_CONCURRENCY 개씩 병렬, ogg 변환)
const musicDir = path.join(tempRoot, 'music')
await fsp.mkdir(musicDir, { recursive: true })
sendLog(`음악 다운로드 시작 (${musicTotal}곡)`)
for (let i = 0; i < musicTotal; i++) {
throwIfCancelled()
const entry = pack.list.music[i]
const idx = i + 1
sendLog(`${idx}번 노래 다운로드 중…`)
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: idx,
url: entry.url,
log: sendLog,
onChild: (c) => { state.currentChild = c },
onProgress: (pct) => {
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
sendProgress({
phase: 'item', kind: 'music', index: idx, total: musicTotal,
percent: Math.min(90, pct * 0.9), status: 'running'
})
sendLog(`음악 다운로드 시작 (${musicTotal}, 동시 ${MUSIC_CONCURRENCY})`)
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music
let nextIndex = 0
async function musicWorker(): Promise<void> {
while (true) {
if (state.cancelRequested) return
const i = nextIndex++
if (i >= musicTotal) return
const entry = musicList[i]
const idx = i + 1
sendLog(`${idx}번 노래 다운로드 시작`)
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: idx,
url: entry.url,
log: sendLog,
onChild: (c) => {
child = c
state.activeChildren.add(c)
},
onProgress: (pct) => {
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
sendProgress({
phase: 'item', kind: 'music', index: idx, total: musicTotal,
percent: Math.min(90, pct * 0.9), status: 'running'
})
}
})
if (child) state.activeChildren.delete(child)
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
} catch (err) {
if (child) state.activeChildren.delete(child)
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
return
}
})
state.currentChild = null
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
} catch (err) {
state.currentChild = null
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
throwIfCancelled()
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
}
}
const workerCount = Math.min(MUSIC_CONCURRENCY, musicTotal)
const workers: Promise<void>[] = []
for (let w = 0; w < workerCount; w++) workers.push(musicWorker())
await Promise.all(workers)
throwIfCancelled()
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })
@@ -296,9 +317,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true
sendLog('취소 요청됨. 행 중 작업을 중단합니다…')
if (state.currentChild && !state.currentChild.killed) {
state.currentChild.kill()
sendLog(`취소 요청됨. 행 중 프로세스 ${state.activeChildren.size}개 중단…`)
for (const child of state.activeChildren) {
if (!child.killed) child.kill()
}
})