fix(file-transcription): provision ffmpeg before converting so clean installs stop failing with ENOENT

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent c5b3bdedb6
commit d96601a283
3 changed files with 329 additions and 58 deletions

View file

@ -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<string> {
const resolved = getFfmpegPath()
if (resolved !== 'ffmpeg') return resolved
private _ensureFfmpeg(): Promise<string> {
return resolveFfmpegExecutable(
{
locate: () => this._ffmpegLocator.locate(),
provision: () => {
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
return getRuntimeProvisioner().ensure('ffmpeg')
return this._ffmpegLocator.provision()
},
},
(err) => {
logger.warn('ffmpeg 자동 다운로드 실패 — 시스템 PATH의 ffmpeg으로 시도합니다:', err)
},
)
}
/**
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
*/
private _convertToWav(inputPath: string, outputPath: string): Promise<void> {
private _convertToWav(ffmpegPath: string, inputPath: string, outputPath: string): Promise<void> {
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<number> {
private _probeDuration(ffmpegPath: string, wavPath: string): Promise<number> {
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<Buffer> {
private _extractChunk(
ffmpegPath: string,
wavPath: string,
startSec: number,
durationSec: number,
): Promise<Buffer> {
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[] = []

View file

@ -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<string>
}
/**
* 스폰할 ffmpeg 경로를 정한다.
* 로컬 사본이 있으면 그대로 쓰고, 없으면 내려받는다. 다운로드가 실패하면
* 시스템 PATH의 ffmpeg으로 폴백한다 (오프라인이지만 ffmpeg을 직접 설치한 사용자).
*/
export async function resolveFfmpegExecutable(
locator: FfmpegLocator,
onProvisionFailed?: (err: unknown) => void,
): Promise<string> {
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)
}

View file

@ -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)
})
})