// 회귀: 설치본에는 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) }) })