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 → 병합
|
||||
|
||||
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
|
||||
|
||||
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
|
||||
return getRuntimeProvisioner().ensure('ffmpeg')
|
||||
private _ensureFfmpeg(): Promise<string> {
|
||||
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<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[] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue