175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
// 회귀: 설치본에는 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)
|
|
})
|
|
})
|