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

View File

@@ -32,6 +32,11 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
const args = [ const args = [
'--no-warnings', '--no-warnings',
'--no-playlist', '--no-playlist',
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
// 일반 progressive 다운로드에는 영향 없음.
'--concurrent-fragments', '5',
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
'--newline',
'--extract-audio', '--extract-audio',
'--audio-format', 'vorbis', '--audio-format', 'vorbis',
'--audio-quality', '0', '--audio-quality', '0',