From d96601a283f6095e98ebe55f58b389c145533b54 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:43 +0900 Subject: [PATCH] fix(file-transcription): provision ffmpeg before converting so clean installs stop failing with ENOENT --- .../main/services/FileTranscriptionService.ts | 117 ++++++------ .../file-transcription/ffmpeg-media.ts | 95 ++++++++++ .../file-transcription-redteam-r1-5.test.ts | 175 ++++++++++++++++++ 3 files changed, 329 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/main/services/file-transcription/ffmpeg-media.ts create mode 100644 apps/desktop/tests/main/services/file-transcription-redteam-r1-5.test.ts diff --git a/apps/desktop/src/main/services/FileTranscriptionService.ts b/apps/desktop/src/main/services/FileTranscriptionService.ts index 93c2d6c..efce68c 100644 --- a/apps/desktop/src/main/services/FileTranscriptionService.ts +++ b/apps/desktop/src/main/services/FileTranscriptionService.ts @@ -3,6 +3,7 @@ // 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합 import { EventEmitter } from 'events' +import { spawn } from 'child_process' import path from 'path' import fs from 'fs' import { app } from 'electron' @@ -12,6 +13,16 @@ import { getHistoryService } from './HistoryService' import { configGet } from './ConfigService' import { getFfmpegPath } from '../utils/paths' import { getRuntimeProvisioner } from './RuntimeProvisioner' +import { + buildConvertToWavArgs, + buildExtractChunkArgs, + buildProbeDurationArgs, + countChunks, + estimateWavDurationSec, + parseFfmpegDuration, + resolveFfmpegExecutable, + type FfmpegLocator, +} from './file-transcription/ffmpeg-media' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -37,7 +48,17 @@ const SUPPORTED_EXTENSIONS = new Set([ '.mp4', '.mkv', '.webm', '.avi', '.mov', ]) +/** 기본 ffmpeg 위치 포트: 로컬 사본 조회 + 런타임 feed 다운로드 */ +const defaultFfmpegLocator: FfmpegLocator = { + locate: () => getFfmpegPath(), + provision: () => getRuntimeProvisioner().ensure('ffmpeg'), +} + class FileTranscriptionService extends EventEmitter { + constructor(private readonly _ffmpegLocator: FfmpegLocator = defaultFfmpegLocator) { + super() + } + private _state: FileTranscriptionState = 'idle' private _jobId: string | null = null private _cancelled = false @@ -98,18 +119,22 @@ class FileTranscriptionService extends EventEmitter { const fileName = path.basename(filePath) try { - // Phase 1: ffmpeg 변환 → PCM WAV + // Phase 1: ffmpeg 확보(설치본에는 없으므로 필요 시 다운로드) → PCM WAV 변환 this._setState('converting') + const ffmpegPath = await this._ensureFfmpeg() + if (this._cancelled) { + throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled') + } const wavPath = path.join(this._tempDir, 'audio.wav') - await this._convertToWav(filePath, wavPath) + await this._convertToWav(ffmpegPath, filePath, wavPath) if (this._cancelled) { throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled') } // 오디오 길이 확인 - const totalDurationSec = await this._probeDuration(wavPath) - const totalChunks = Math.ceil(totalDurationSec / CHUNK_DURATION_SEC) + const totalDurationSec = await this._probeDuration(ffmpegPath, wavPath) + const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC) // Phase 2: 청크별 STT this._setState('transcribing') @@ -123,7 +148,7 @@ class FileTranscriptionService extends EventEmitter { } const startSec = i * CHUNK_DURATION_SEC - const chunkBuffer = await this._extractChunk(wavPath, startSec, CHUNK_DURATION_SEC) + const chunkBuffer = await this._extractChunk(ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC) const sttService = getSTTManager() const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto' @@ -255,30 +280,27 @@ class FileTranscriptionService extends EventEmitter { * ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로 * 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요). */ - private async _ensureFfmpeg(): Promise { - const resolved = getFfmpegPath() - if (resolved !== 'ffmpeg') return resolved - - logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다') - return getRuntimeProvisioner().ensure('ffmpeg') + private _ensureFfmpeg(): Promise { + return resolveFfmpegExecutable( + { + locate: () => this._ffmpegLocator.locate(), + provision: () => { + logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다') + return this._ffmpegLocator.provision() + }, + }, + (err) => { + logger.warn('ffmpeg 자동 다운로드 실패 — 시스템 PATH의 ffmpeg으로 시도합니다:', err) + }, + ) } /** * ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환 */ - private _convertToWav(inputPath: string, outputPath: string): Promise { + private _convertToWav(ffmpegPath: string, inputPath: string, outputPath: string): Promise { return new Promise((resolve, reject) => { - const { spawn } = require('child_process') as typeof import('child_process') - const ffmpegPath = getFfmpegPath() - - const args = [ - '-i', inputPath, - '-ar', '16000', - '-ac', '1', - '-sample_fmt', 's16', - '-y', - outputPath, - ] + const args = buildConvertToWavArgs(inputPath, outputPath) logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`) @@ -307,17 +329,11 @@ class FileTranscriptionService extends EventEmitter { /** * ffprobe(ffmpeg)로 오디오 길이 측정 (초) */ - private _probeDuration(wavPath: string): Promise { + private _probeDuration(ffmpegPath: string, wavPath: string): Promise { return new Promise((resolve, reject) => { - const { spawn } = require('child_process') as typeof import('child_process') - const ffmpegPath = getFfmpegPath() // ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나, - // @ffmpeg-installer는 ffmpeg만 제공 → -i로 duration 추출 - const args = [ - '-i', wavPath, - '-f', 'null', - '-', - ] + // 내려받는 런타임은 ffmpeg만 제공 → -i로 duration 추출 + const args = buildProbeDurationArgs(wavPath) const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) let stderr = '' @@ -327,21 +343,13 @@ class FileTranscriptionService extends EventEmitter { }) proc.on('close', () => { - // "Duration: HH:MM:SS.ms" 패턴 파싱 - const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/) - if (match) { - const hours = parseInt(match[1], 10) - const minutes = parseInt(match[2], 10) - const seconds = parseInt(match[3], 10) - const ms = parseInt(match[4], 10) / 100 - resolve(hours * 3600 + minutes * 60 + seconds + ms) + const parsed = parseFfmpegDuration(stderr) + if (parsed !== null) { + resolve(parsed) } else { // WAV 파일 크기로 폴백 추정 (16kHz 16bit mono = 32000 bytes/sec) try { - const stat = fs.statSync(wavPath) - const headerSize = 44 - const bytesPerSec = 16000 * 2 * 1 - resolve(Math.max(0, (stat.size - headerSize) / bytesPerSec)) + resolve(estimateWavDurationSec(fs.statSync(wavPath).size)) } catch { reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, 'Cannot determine audio duration')) } @@ -353,21 +361,14 @@ class FileTranscriptionService extends EventEmitter { /** * WAV 파일에서 특정 구간을 PCM16 Buffer로 추출 */ - private _extractChunk(wavPath: string, startSec: number, durationSec: number): Promise { + private _extractChunk( + ffmpegPath: string, + wavPath: string, + startSec: number, + durationSec: number, + ): Promise { return new Promise((resolve, reject) => { - const { spawn } = require('child_process') as typeof import('child_process') - const ffmpegPath = getFfmpegPath() - - const args = [ - '-ss', String(startSec), - '-t', String(durationSec), - '-i', wavPath, - '-ar', '16000', - '-ac', '1', - '-f', 's16le', - '-acodec', 'pcm_s16le', - 'pipe:1', - ] + const args = buildExtractChunkArgs(wavPath, startSec, durationSec) const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) const chunks: Buffer[] = [] diff --git a/apps/desktop/src/main/services/file-transcription/ffmpeg-media.ts b/apps/desktop/src/main/services/file-transcription/ffmpeg-media.ts new file mode 100644 index 0000000..96e00dd --- /dev/null +++ b/apps/desktop/src/main/services/file-transcription/ffmpeg-media.ts @@ -0,0 +1,95 @@ +// src/main/services/file-transcription/ffmpeg-media.ts +// 파일 전사의 ffmpeg 관련 순수 정책: 실행 파일 확보 순서, 인자 구성, 길이 파싱/추정, 청크 분할. +// IO(프로세스 실행, 다운로드, 파일 조회)는 호출자가 포트로 주입한다. + +/** 로컬에서 ffmpeg을 찾지 못했을 때 paths.getFfmpegPath()가 돌려주는 PATH 폴백 이름 */ +export const SYSTEM_FFMPEG = 'ffmpeg' + +/** 전사 입력 PCM 포맷: 16kHz, 16bit, mono */ +export const PCM_SAMPLE_RATE = 16000 +const PCM_BYTES_PER_SAMPLE = 2 +const PCM_CHANNELS = 1 +const WAV_HEADER_BYTES = 44 + +/** + * ffmpeg 실행 파일을 찾고, 없으면 내려받는 포트. + * - locate: 번들/내려받은 사본/npm 패키지 순으로 찾고, 없으면 SYSTEM_FFMPEG을 돌려준다. + * - provision: 런타임 feed에서 내려받아 설치된 경로를 돌려준다. + */ +export interface FfmpegLocator { + locate(): string + provision(): Promise +} + +/** + * 스폰할 ffmpeg 경로를 정한다. + * 로컬 사본이 있으면 그대로 쓰고, 없으면 내려받는다. 다운로드가 실패하면 + * 시스템 PATH의 ffmpeg으로 폴백한다 (오프라인이지만 ffmpeg을 직접 설치한 사용자). + */ +export async function resolveFfmpegExecutable( + locator: FfmpegLocator, + onProvisionFailed?: (err: unknown) => void, +): Promise { + const local = locator.locate() + if (local !== SYSTEM_FFMPEG) return local + + try { + return await locator.provision() + } catch (err) { + onProvisionFailed?.(err) + return SYSTEM_FFMPEG + } +} + +/** 미디어 파일 → PCM16 16kHz mono WAV 변환 인자 */ +export function buildConvertToWavArgs(inputPath: string, outputPath: string): string[] { + return [ + '-i', inputPath, + '-ar', String(PCM_SAMPLE_RATE), + '-ac', String(PCM_CHANNELS), + '-sample_fmt', 's16', + '-y', + outputPath, + ] +} + +/** 길이 측정 인자 (ffprobe 없이 ffmpeg -i 의 stderr Duration 을 읽는다) */ +export function buildProbeDurationArgs(wavPath: string): string[] { + return ['-i', wavPath, '-f', 'null', '-'] +} + +/** WAV의 [startSec, startSec+durationSec) 구간을 raw PCM16 으로 stdout 에 내보내는 인자 */ +export function buildExtractChunkArgs(wavPath: string, startSec: number, durationSec: number): string[] { + return [ + '-ss', String(startSec), + '-t', String(durationSec), + '-i', wavPath, + '-ar', String(PCM_SAMPLE_RATE), + '-ac', String(PCM_CHANNELS), + '-f', 's16le', + '-acodec', 'pcm_s16le', + 'pipe:1', + ] +} + +/** ffmpeg stderr 의 "Duration: HH:MM:SS.cc" 를 초로 바꾼다. 없으면 null. */ +export function parseFfmpegDuration(stderr: string): number | null { + const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/) + if (!match) return null + const hours = parseInt(match[1], 10) + const minutes = parseInt(match[2], 10) + const seconds = parseInt(match[3], 10) + const fraction = parseInt(match[4], 10) / 100 + return hours * 3600 + minutes * 60 + seconds + fraction +} + +/** PCM16 16kHz mono WAV 파일 크기로 길이를 추정한다 (초). */ +export function estimateWavDurationSec(fileSizeBytes: number): number { + const bytesPerSec = PCM_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * PCM_CHANNELS + return Math.max(0, (fileSizeBytes - WAV_HEADER_BYTES) / bytesPerSec) +} + +/** 전체 길이를 청크 길이로 나눈 청크 수 */ +export function countChunks(totalDurationSec: number, chunkDurationSec: number): number { + return Math.ceil(totalDurationSec / chunkDurationSec) +} diff --git a/apps/desktop/tests/main/services/file-transcription-redteam-r1-5.test.ts b/apps/desktop/tests/main/services/file-transcription-redteam-r1-5.test.ts new file mode 100644 index 0000000..1c2a7da --- /dev/null +++ b/apps/desktop/tests/main/services/file-transcription-redteam-r1-5.test.ts @@ -0,0 +1,175 @@ +// 회귀: 설치본에는 ffmpeg이 없으므로 파일 전사는 시작 시 런타임 ffmpeg을 확보해야 한다. +// 이전에는 _ensureFfmpeg()가 호출되지 않아 bare 'ffmpeg'을 스폰 → spawn ENOENT 로 전사가 전부 실패했다. +import { EventEmitter } from 'events' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SYSTEM_FFMPEG, + buildConvertToWavArgs, + buildExtractChunkArgs, + buildProbeDurationArgs, + countChunks, + estimateWavDurationSec, + parseFfmpegDuration, + resolveFfmpegExecutable, +} from '../../../src/main/services/file-transcription/ffmpeg-media' + +const hoisted = vi.hoisted(() => ({ + spawn: vi.fn(), + getFfmpegPath: vi.fn(), + ensure: vi.fn(), + transcribe: vi.fn(), +})) + +vi.mock('child_process', () => ({ spawn: hoisted.spawn })) + +vi.mock('../../../src/main/utils/paths', () => ({ + getFfmpegPath: hoisted.getFfmpegPath, +})) + +vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ + getRuntimeProvisioner: () => ({ ensure: hoisted.ensure }), +})) + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn(() => undefined), +})) + +vi.mock('../../../src/main/services/stt/STTManager', () => ({ + getSTTManager: () => ({ transcribe: hoisted.transcribe }), +})) + +vi.mock('../../../src/main/services/HistoryService', () => ({ + getHistoryService: () => ({ create: vi.fn() }), +})) + +vi.mock('../../../src/main/services/LicenseService', () => ({ + getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }), +})) + +vi.mock('../../../src/main/windows/WindowManager', () => ({ + getMainWindow: () => null, +})) + +type FakeProcess = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } + +/** 실행 파일이 'ffmpeg'(PATH 폴백)이면 ENOENT, 그 밖에는 정상 ffmpeg 처럼 동작하는 가짜 프로세스 */ +function fakeSpawn(command: string, args: string[]): FakeProcess { + const proc = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }) + queueMicrotask(() => { + if (command === SYSTEM_FFMPEG) { + proc.emit('error', new Error('spawn ffmpeg ENOENT')) + return + } + if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:45.00')) + if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm')) + proc.emit('close', 0) + }) + return proc +} + +describe('FileTranscriptionService ffmpeg provisioning', () => { + let inputFile: string + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + hoisted.spawn.mockImplementation(fakeSpawn) + hoisted.transcribe.mockResolvedValue({ text: 'hello', segments: [] }) + inputFile = path.join(os.tmpdir(), `d3ro-ft-redteam-${Date.now()}.mp3`) + fs.writeFileSync(inputFile, 'x') + }) + + afterEach(() => { + fs.rmSync(inputFile, { force: true }) + }) + + it('downloads ffmpeg on a clean install and spawns the provisioned binary', async () => { + hoisted.getFfmpegPath.mockReturnValue(SYSTEM_FFMPEG) + hoisted.ensure.mockResolvedValue('/runtime/ffmpeg/ffmpeg.exe') + + const mod = await import('../../../src/main/services/FileTranscriptionService') + mod.resetFileTranscriptionServiceForTests() + const result = await mod.getFileTranscriptionService().startTranscription(inputFile, 'en') + + expect(hoisted.ensure).toHaveBeenCalledTimes(1) + expect(hoisted.ensure).toHaveBeenCalledWith('ffmpeg') + const commands = hoisted.spawn.mock.calls.map((call) => call[0]) + // convert + probe + 2 chunks(45초 / 30초) + expect(commands).toEqual([ + '/runtime/ffmpeg/ffmpeg.exe', + '/runtime/ffmpeg/ffmpeg.exe', + '/runtime/ffmpeg/ffmpeg.exe', + '/runtime/ffmpeg/ffmpeg.exe', + ]) + expect(result.fullText).toBe('hello hello') + expect(result.totalDurationSec).toBe(45) + }) + + it('uses a locally available ffmpeg without downloading', async () => { + hoisted.getFfmpegPath.mockReturnValue('/bundled/ffmpeg.exe') + + const mod = await import('../../../src/main/services/FileTranscriptionService') + mod.resetFileTranscriptionServiceForTests() + await mod.getFileTranscriptionService().startTranscription(inputFile, 'en') + + expect(hoisted.ensure).not.toHaveBeenCalled() + expect(hoisted.spawn.mock.calls.every((call) => call[0] === '/bundled/ffmpeg.exe')).toBe(true) + }) +}) + +describe('ffmpeg-media policy', () => { + it('resolveFfmpegExecutable prefers the local copy', async () => { + const provision = vi.fn() + await expect(resolveFfmpegExecutable({ locate: () => '/a/ffmpeg', provision })).resolves.toBe('/a/ffmpeg') + expect(provision).not.toHaveBeenCalled() + }) + + it('resolveFfmpegExecutable provisions when only the PATH fallback is known', async () => { + const provision = vi.fn().mockResolvedValue('/rt/ffmpeg') + await expect(resolveFfmpegExecutable({ locate: () => SYSTEM_FFMPEG, provision })).resolves.toBe('/rt/ffmpeg') + }) + + it('resolveFfmpegExecutable falls back to the system ffmpeg when the download fails', async () => { + const failure = new Error('offline') + const onFail = vi.fn() + const provision = vi.fn().mockRejectedValue(failure) + await expect( + resolveFfmpegExecutable({ locate: () => SYSTEM_FFMPEG, provision }, onFail), + ).resolves.toBe(SYSTEM_FFMPEG) + expect(onFail).toHaveBeenCalledWith(failure) + }) + + it('builds the same ffmpeg arguments as before', () => { + expect(buildConvertToWavArgs('in.mp4', 'out.wav')).toEqual([ + '-i', 'in.mp4', '-ar', '16000', '-ac', '1', '-sample_fmt', 's16', '-y', 'out.wav', + ]) + expect(buildProbeDurationArgs('a.wav')).toEqual(['-i', 'a.wav', '-f', 'null', '-']) + expect(buildExtractChunkArgs('a.wav', 30, 30)).toEqual([ + '-ss', '30', '-t', '30', '-i', 'a.wav', '-ar', '16000', '-ac', '1', + '-f', 's16le', '-acodec', 'pcm_s16le', 'pipe:1', + ]) + }) + + it('parses ffmpeg duration and estimates WAV length', () => { + expect(parseFfmpegDuration(' Duration: 01:02:03.50, start')).toBe(3723.5) + expect(parseFfmpegDuration('no duration here')).toBeNull() + expect(estimateWavDurationSec(44 + 32000 * 2)).toBe(2) + expect(estimateWavDurationSec(10)).toBe(0) + }) + + it('counts chunks by rounding up', () => { + expect(countChunks(45, 30)).toBe(2) + expect(countChunks(60, 30)).toBe(2) + expect(countChunks(0, 30)).toBe(0) + }) +})