Add ffmpeg prep and music ogg download to rp installer

Add src/installer-rp/ffmpeg.ts that downloads BtbN/FFmpeg-Builds
win64-gpl zip into %appdata%/.mc_custom/, extracts ffmpeg.exe out
of bin/, drops it at %appdata%/.mc_custom/ffmpeg.exe and verifies
with `ffmpeg -version`. Reuses existing extract-zip dep.

Add src/installer-rp/music.ts that spawns yt-dlp with
--extract-audio --audio-format vorbis --ffmpeg-location <ffmpeg.exe>
to produce <tempDir>/NN.ogg per track. Streams yt-dlp stdout to
the log channel and reports stderr on non-zero exit.

Wire both into the install IPC handler: step 2-1 now preps both
binaries, step 2-2 iterates the music list and downloads each
track. Track the currently running child process in state so the
cancel button can kill it instead of waiting for it to finish.

Image / zip / place steps remain stubbed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 15:23:01 +09:00
parent 860c30fdfe
commit 5e3a42ff4f
3 changed files with 250 additions and 4 deletions

View File

@@ -5,10 +5,13 @@ import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import { URL } from 'node:url'
import type { ChildProcess } from 'node:child_process'
import type { Manifest, PackList } from '../shared/types.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
import type { RpFetchedPack } from './types.js'
import { ensureYtDlpExe } from './ytdlp.js'
import { ensureFfmpegExe } from './ffmpeg.js'
import { downloadMusicTrack } from './music.js'
interface RpInstallerState {
manifestUrl: string
@@ -17,6 +20,8 @@ interface RpInstallerState {
selectedKey: string | null
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
cancelRequested: boolean
/** 현재 실행 중인 외부 프로세스(yt-dlp/ffmpeg). 취소 시 kill 대상. */
currentChild: ChildProcess | null
}
const DEFAULT_MANIFEST_URL = process.env.MANIFEST_URL ?? 'http://127.0.0.1:3000/manifest.json'
@@ -26,7 +31,8 @@ const state: RpInstallerState = {
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
packs: new Map(),
selectedKey: null,
cancelRequested: false
cancelRequested: false,
currentChild: null
}
let mainWindow: BrowserWindow | null = null
@@ -142,17 +148,42 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
await fsp.mkdir(tempRoot, { recursive: true })
try {
// 2-1. yt-dlp 준비 (%appdata%/.mc_custom/yt-dlp.exe)
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog('yt-dlp 준비 중…')
const ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
throwIfCancelled()
sendLog('ffmpeg 준비 중…')
const ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
throwIfCancelled()
// 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡) … (TODO)`)
const musicDir = path.join(tempRoot, 'music')
await fsp.mkdir(musicDir, { recursive: true })
sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡)`)
for (let i = 0; i < pack.list.music.length; i++) {
throwIfCancelled()
sendLog(`${i + 1}번 노래 다운로드 중… (TODO)`)
const entry = pack.list.music[i]
sendLog(`${i + 1}번 노래 다운로드 중…`)
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: i + 1,
url: entry.url,
log: sendLog,
onChild: (c) => { state.currentChild = c }
})
state.currentChild = null
sendLog(`${i + 1}번 노래 완료: ${path.basename(outPath)}`)
} catch (err) {
state.currentChild = null
// 취소된 경우는 throwIfCancelled 가 일관된 메시지로 다시 던지게 함.
if (state.cancelRequested) throwIfCancelled()
throw new Error(`${i + 1}번 노래 다운로드 실패: ${(err as Error).message}`)
}
}
// 2-3. 사진 다운로드 + painting variant 정규화
@@ -185,6 +216,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()
}
})
function throwIfCancelled(): void {