fix(file-transcription): provision ffmpeg before converting so clean installs stop failing with ENOENT
This commit is contained in:
parent
c5b3bdedb6
commit
d96601a283
3 changed files with 329 additions and 58 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
|
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
@ -12,6 +13,16 @@ import { getHistoryService } from './HistoryService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
import { getFfmpegPath } from '../utils/paths'
|
import { getFfmpegPath } from '../utils/paths'
|
||||||
import { getRuntimeProvisioner } from './RuntimeProvisioner'
|
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 { getMainWindow } from '../windows/WindowManager'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
@ -37,7 +48,17 @@ const SUPPORTED_EXTENSIONS = new Set([
|
||||||
'.mp4', '.mkv', '.webm', '.avi', '.mov',
|
'.mp4', '.mkv', '.webm', '.avi', '.mov',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** 기본 ffmpeg 위치 포트: 로컬 사본 조회 + 런타임 feed 다운로드 */
|
||||||
|
const defaultFfmpegLocator: FfmpegLocator = {
|
||||||
|
locate: () => getFfmpegPath(),
|
||||||
|
provision: () => getRuntimeProvisioner().ensure('ffmpeg'),
|
||||||
|
}
|
||||||
|
|
||||||
class FileTranscriptionService extends EventEmitter {
|
class FileTranscriptionService extends EventEmitter {
|
||||||
|
constructor(private readonly _ffmpegLocator: FfmpegLocator = defaultFfmpegLocator) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
private _state: FileTranscriptionState = 'idle'
|
private _state: FileTranscriptionState = 'idle'
|
||||||
private _jobId: string | null = null
|
private _jobId: string | null = null
|
||||||
private _cancelled = false
|
private _cancelled = false
|
||||||
|
|
@ -98,18 +119,22 @@ class FileTranscriptionService extends EventEmitter {
|
||||||
const fileName = path.basename(filePath)
|
const fileName = path.basename(filePath)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: ffmpeg 변환 → PCM WAV
|
// Phase 1: ffmpeg 확보(설치본에는 없으므로 필요 시 다운로드) → PCM WAV 변환
|
||||||
this._setState('converting')
|
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')
|
const wavPath = path.join(this._tempDir, 'audio.wav')
|
||||||
await this._convertToWav(filePath, wavPath)
|
await this._convertToWav(ffmpegPath, filePath, wavPath)
|
||||||
|
|
||||||
if (this._cancelled) {
|
if (this._cancelled) {
|
||||||
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오디오 길이 확인
|
// 오디오 길이 확인
|
||||||
const totalDurationSec = await this._probeDuration(wavPath)
|
const totalDurationSec = await this._probeDuration(ffmpegPath, wavPath)
|
||||||
const totalChunks = Math.ceil(totalDurationSec / CHUNK_DURATION_SEC)
|
const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC)
|
||||||
|
|
||||||
// Phase 2: 청크별 STT
|
// Phase 2: 청크별 STT
|
||||||
this._setState('transcribing')
|
this._setState('transcribing')
|
||||||
|
|
@ -123,7 +148,7 @@ class FileTranscriptionService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const startSec = i * CHUNK_DURATION_SEC
|
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 sttService = getSTTManager()
|
||||||
const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
||||||
|
|
@ -255,30 +280,27 @@ class FileTranscriptionService extends EventEmitter {
|
||||||
* ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로
|
* ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로
|
||||||
* 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요).
|
* 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요).
|
||||||
*/
|
*/
|
||||||
private async _ensureFfmpeg(): Promise<string> {
|
private _ensureFfmpeg(): Promise<string> {
|
||||||
const resolved = getFfmpegPath()
|
return resolveFfmpegExecutable(
|
||||||
if (resolved !== 'ffmpeg') return resolved
|
{
|
||||||
|
locate: () => this._ffmpegLocator.locate(),
|
||||||
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
|
provision: () => {
|
||||||
return getRuntimeProvisioner().ensure('ffmpeg')
|
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
|
||||||
|
return this._ffmpegLocator.provision()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
logger.warn('ffmpeg 자동 다운로드 실패 — 시스템 PATH의 ffmpeg으로 시도합니다:', err)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
|
* 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { spawn } = require('child_process') as typeof import('child_process')
|
const args = buildConvertToWavArgs(inputPath, outputPath)
|
||||||
const ffmpegPath = getFfmpegPath()
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-i', inputPath,
|
|
||||||
'-ar', '16000',
|
|
||||||
'-ac', '1',
|
|
||||||
'-sample_fmt', 's16',
|
|
||||||
'-y',
|
|
||||||
outputPath,
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
|
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
|
||||||
|
|
||||||
|
|
@ -307,17 +329,11 @@ class FileTranscriptionService extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
* ffprobe(ffmpeg)로 오디오 길이 측정 (초)
|
* ffprobe(ffmpeg)로 오디오 길이 측정 (초)
|
||||||
*/
|
*/
|
||||||
private _probeDuration(wavPath: string): Promise<number> {
|
private _probeDuration(ffmpegPath: string, wavPath: string): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { spawn } = require('child_process') as typeof import('child_process')
|
|
||||||
const ffmpegPath = getFfmpegPath()
|
|
||||||
// ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나,
|
// ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나,
|
||||||
// @ffmpeg-installer는 ffmpeg만 제공 → -i로 duration 추출
|
// 내려받는 런타임은 ffmpeg만 제공 → -i로 duration 추출
|
||||||
const args = [
|
const args = buildProbeDurationArgs(wavPath)
|
||||||
'-i', wavPath,
|
|
||||||
'-f', 'null',
|
|
||||||
'-',
|
|
||||||
]
|
|
||||||
|
|
||||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
|
|
@ -327,21 +343,13 @@ class FileTranscriptionService extends EventEmitter {
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.on('close', () => {
|
proc.on('close', () => {
|
||||||
// "Duration: HH:MM:SS.ms" 패턴 파싱
|
const parsed = parseFfmpegDuration(stderr)
|
||||||
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/)
|
if (parsed !== null) {
|
||||||
if (match) {
|
resolve(parsed)
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
// WAV 파일 크기로 폴백 추정 (16kHz 16bit mono = 32000 bytes/sec)
|
// WAV 파일 크기로 폴백 추정 (16kHz 16bit mono = 32000 bytes/sec)
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(wavPath)
|
resolve(estimateWavDurationSec(fs.statSync(wavPath).size))
|
||||||
const headerSize = 44
|
|
||||||
const bytesPerSec = 16000 * 2 * 1
|
|
||||||
resolve(Math.max(0, (stat.size - headerSize) / bytesPerSec))
|
|
||||||
} catch {
|
} catch {
|
||||||
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, 'Cannot determine audio duration'))
|
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, 'Cannot determine audio duration'))
|
||||||
}
|
}
|
||||||
|
|
@ -353,21 +361,14 @@ class FileTranscriptionService extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
* WAV 파일에서 특정 구간을 PCM16 Buffer로 추출
|
* 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { spawn } = require('child_process') as typeof import('child_process')
|
const args = buildExtractChunkArgs(wavPath, startSec, durationSec)
|
||||||
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 proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue