From 5e3a42ff4fcd8110ad405299c16814d3dd25235d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 12 May 2026 15:23:01 +0900 Subject: [PATCH] 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 to produce /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 --- src/installer-rp/ffmpeg.ts | 143 +++++++++++++++++++++++++++++++++++++ src/installer-rp/main.ts | 42 +++++++++-- src/installer-rp/music.ts | 69 ++++++++++++++++++ 3 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 src/installer-rp/ffmpeg.ts create mode 100644 src/installer-rp/music.ts diff --git a/src/installer-rp/ffmpeg.ts b/src/installer-rp/ffmpeg.ts new file mode 100644 index 0000000..778f153 --- /dev/null +++ b/src/installer-rp/ffmpeg.ts @@ -0,0 +1,143 @@ +import { spawn } from 'node:child_process' +import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs' +import path from 'node:path' +import https from 'node:https' +import http from 'node:http' +import { getMcCustomDir } from '../shared/paths.js' + +// extract-zip 은 CommonJS 기본 export 라 require 로 받음. +const extractZip: (source: string, options: { dir: string }) => Promise = require('extract-zip') + +/** + * 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용. + * 경로: %appdata%/.mc_custom/ffmpeg.exe + */ +export function getFfmpegExePath(): string { + return path.join(getMcCustomDir(), 'ffmpeg.exe') +} + +/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */ +const FFMPEG_ZIP_URL = + 'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip' + +let installPromise: Promise | null = null + +/** + * %appdata%/.mc_custom/ffmpeg.exe 가 없거나 실행 불가하면 BtbN 빌드 zip 에서 + * ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다. + */ +export async function ensureFfmpegExe( + log?: (line: string) => void +): Promise { + const target = getFfmpegExePath() + if (await canExecute(target)) { + log?.(`ffmpeg.exe 이미 있음: ${target}`) + return target + } + if (installPromise) return installPromise + + installPromise = (async () => { + const dir = getMcCustomDir() + const zipPath = path.join(dir, '.tmp_ffmpeg.zip') + const extractDir = path.join(dir, '.tmp_ffmpeg') + try { + await fs.mkdir(dir, { recursive: true }) + // 이전 시도의 임시 파일/폴더 정리 + await fs.rm(zipPath, { force: true }) + await fs.rm(extractDir, { recursive: true, force: true }) + + log?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`) + await downloadToFile(FFMPEG_ZIP_URL, zipPath) + log?.('ffmpeg zip 압축 해제 중…') + await extractZip(zipPath, { dir: extractDir }) + + const found = await findFile(extractDir, 'ffmpeg.exe') + if (!found) { + throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.') + } + // 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback. + try { + await fs.rename(found, target) + } catch { + await fs.copyFile(found, target) + } + + const ok = await probeVersion(target) + if (!ok) throw new Error('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.') + log?.(`ffmpeg.exe 준비 완료: ${target}`) + return target + } catch (err) { + try { await fs.unlink(target) } catch { /* noop */ } + throw new Error( + 'ffmpeg.exe 자동 설치 실패: ' + + (err instanceof Error ? err.message : String(err)) + ) + } finally { + // 임시 파일/폴더 정리 + await fs.rm(zipPath, { force: true }).catch(() => {}) + await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {}) + installPromise = null + } + })() + return installPromise +} + +async function canExecute(filePath: string): Promise { + try { await fs.access(filePath, fsConst.F_OK) } catch { return false } + return probeVersion(filePath) +} + +function probeVersion(bin: string): Promise { + return new Promise((resolve) => { + const child = spawn(bin, ['-version'], { stdio: ['ignore', 'pipe', 'pipe'] }) + let ok = false + child.stdout.on('data', () => { ok = true }) + child.on('error', () => resolve(false)) + child.on('close', (code) => resolve(ok && code === 0)) + }) +} + +async function findFile(root: string, name: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }) + for (const e of entries) { + const full = path.join(root, e.name) + if (e.isFile() && e.name.toLowerCase() === name.toLowerCase()) return full + if (e.isDirectory()) { + const inner = await findFile(full, name) + if (inner) return inner + } + } + return null +} + +/** GitHub Releases latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */ +function downloadToFile(url: string, dest: string, redirects = 0): Promise { + return new Promise((resolve, reject) => { + if (redirects > 8) { + reject(new Error('redirect 가 너무 많습니다.')) + return + } + const lib = url.startsWith('https://') ? https : http + const req = lib.get(url, { + headers: { 'user-agent': 'mc-music-quiz-rp-installer' } + }, (res) => { + const code = res.statusCode || 0 + if (code >= 300 && code < 400 && res.headers.location) { + res.resume() + downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject) + return + } + if (code !== 200) { + res.resume() + reject(new Error(`HTTP ${code} (${url})`)) + return + } + const out = createWriteStream(dest) + res.pipe(out) + out.on('finish', () => out.close((err) => err ? reject(err) : resolve())) + out.on('error', reject) + res.on('error', reject) + }) + req.on('error', reject) + }) +} diff --git a/src/installer-rp/main.ts b/src/installer-rp/main.ts index c667677..67ba98c 100644 --- a/src/installer-rp/main.ts +++ b/src/installer-rp/main.ts @@ -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 { diff --git a/src/installer-rp/music.ts b/src/installer-rp/music.ts new file mode 100644 index 0000000..489fe2e --- /dev/null +++ b/src/installer-rp/music.ts @@ -0,0 +1,69 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { promises as fs } from 'node:fs' +import path from 'node:path' + +export interface DownloadMusicOptions { + ytdlpExe: string + ffmpegExe: string + /** %appdata%/.mc_custom/.temp/ 같은 작업 폴더. */ + tempDir: string + /** 1부터 시작하는 곡 번호 (파일명 zero-pad 에 사용). */ + index: number + /** 유튜브 영상 주소. */ + url: string + log?: (line: string) => void + /** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */ + onChild?: (child: ChildProcess) => void +} + +/** + * yt-dlp 로 유튜브 영상에서 오디오만 추출해 vorbis(.ogg) 로 변환한다. + * 결과 파일 경로를 돌려준다. 실패하면 reject. + * + * 호출자는 onChild 콜백으로 받은 ChildProcess 에 .kill() 을 호출해 취소할 수 있다. + */ +export function downloadMusicTrack(opts: DownloadMusicOptions): Promise { + const padded = String(opts.index).padStart(2, '0') + const outBase = path.join(opts.tempDir, padded) + const outPath = outBase + '.ogg' + return new Promise((resolve, reject) => { + const args = [ + '--no-warnings', + '--no-playlist', + '--extract-audio', + '--audio-format', 'vorbis', + '--audio-quality', '0', + '--ffmpeg-location', opts.ffmpegExe, + '-o', outBase + '.%(ext)s', + opts.url + ] + const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + opts.onChild?.(child) + let stderr = '' + child.stdout?.on('data', (chunk: Buffer) => { + const line = chunk.toString('utf8').trimEnd() + if (line) opts.log?.(`yt-dlp> ${line}`) + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.on('error', (err) => reject(err)) + child.on('close', async (code, signal) => { + if (signal) { + reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`)) + return + } + if (code !== 0) { + reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`)) + return + } + // .ogg 가 실제로 생성됐는지 확인 + try { + await fs.access(outPath) + resolve(outPath) + } catch { + reject(new Error(`예상 출력파일이 없음: ${outPath}`)) + } + }) + }) +}