castad-pre-v0.3/castad-data/services/ffmpegService.ts

137 lines
6.8 KiB
TypeScript

import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
let ffmpeg: FFmpeg | null = null; // FFmpeg 인스턴스를 저장할 변수
/**
* FFmpeg WASM 모듈을 로드하는 함수입니다.
* 한 번 로드된 FFmpeg 인스턴스는 재사용됩니다.
* FFmpeg worker 스크립트의 상대 경로 문제를 해결하기 위한 패치 로직을 포함합니다.
* @returns {Promise<FFmpeg>} - 로드된 FFmpeg 인스턴스
*/
const loadFFmpeg = async () => {
if (ffmpeg) return ffmpeg; // 이미 로드되어 있다면 기존 인스턴스 반환
ffmpeg = new FFmpeg(); // 새로운 FFmpeg 인스턴스 생성
// FFmpeg 코어 및 관련 파일의 CDN 기본 URL
const coreBaseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
const ffmpegBaseURL = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm';
/**
* FFmpeg worker 스크립트의 상대 경로 import를 절대 경로로 패치하는 헬퍼 함수입니다.
* `@ffmpeg/ffmpeg` 라이브러리의 worker.js 파일은 내부적으로 `./classes.js`와 같은 상대 경로를 사용하는데,
* Blob URL로 로드될 경우 이 상대 경로를 해석하지 못하는 문제가 발생합니다.
* 따라서 worker.js 내용을 동적으로 가져와 절대 경로로 수정한 후 Blob URL로 만들어 사용합니다.
* @returns {Promise<string>} - 패치된 worker 스크립트의 Blob URL
*/
const getPatchedWorkerBlob = async () => {
try {
// 원본 worker.js 스크립트를 CDN에서 가져옵니다.
const response = await fetch(`${ffmpegBaseURL}/worker.js`);
let text = await response.text();
console.log("원본 Worker 스크립트 길이:", text.length);
// 상대 경로 임포트(예: from "./classes.js")를 절대 경로로 교체합니다.
// 정규식을 사용하여 "./classes.js" 또는 './classes.js' 패턴을 찾아 교체합니다.
const patchedText = text.replace(
/from\s*["']\.\/classes\.js["']/g,
`from "${ffmpegBaseURL}/classes.js"`
);
console.log("패치된 Worker 스크립트 길이:", patchedText.length);
// 패치된 스크립트 내용을 Blob으로 만들고, 이를 위한 URL을 생성합니다.
const blob = new Blob([patchedText], { type: 'text/javascript' });
const blobUrl = URL.createObjectURL(blob);
console.log("생성된 Worker Blob URL:", blobUrl);
return blobUrl;
} catch (e) {
console.error("Worker 스크립트 패치 실패:", e);
throw e;
}
};
// FFmpeg의 코어, WASM, worker 스크립트를 로드합니다.
// workerURL은 위에서 패치된 Blob URL을 사용합니다.
const workerBlobUrl = await getPatchedWorkerBlob(); // 패치된 worker 스크립트 URL을 먼저 생성
await ffmpeg.load({
coreURL: await toBlobURL(`${coreBaseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${coreBaseURL}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: workerBlobUrl, // 패치된 worker URL 사용
});
return ffmpeg;
};
/**
* 비디오와 오디오 파일을 클라이언트 사이드에서 FFmpeg WASM을 이용하여 병합합니다.
* 이 함수는 주로 미리보기 기능의 '빠른 저장'에 사용됩니다.
* @param {string} videoUrl - 병합할 비디오 파일의 URL
* @param {string} audioUrl - 병합할 오디오 파일의 URL
* @param {(msg: string) => void} onProgress - 진행 상황을 업데이트하는 콜백 함수
* @returns {Promise<string>} - 병합된 비디오의 Blob URL
*/
export const mergeVideoAndAudio = async (
videoUrl: string,
audioUrl: string,
onProgress: (msg: string) => void
): Promise<string> => {
try {
onProgress("FFmpeg 엔진 로딩 중...");
const ffmpeg = await loadFFmpeg(); // FFmpeg 인스턴스 로드
onProgress("비디오/오디오 파일 다운로드 중...");
// 비디오와 오디오 파일을 FFmpeg의 가상 파일 시스템(MEMFS)에 씁니다.
await ffmpeg.writeFile('input_video.mp4', await fetchFile(videoUrl));
// 오디오 파일의 확장자를 감지하여 올바른 파일명으로 저장합니다.
const isWav = audioUrl.endsWith('wav') || audioUrl.startsWith('blob:'); // Blob URL은 대부분 WAV임
const audioExt = isWav ? 'wav' : 'mp3';
const audioFilename = `input_audio.${audioExt}`;
await ffmpeg.writeFile(audioFilename, await fetchFile(audioUrl));
onProgress("비디오/오디오 병합 및 렌더링 중 (무한 루프 & 오디오 길이 맞춤)...");
// FFmpeg 명령어 실행
// -stream_loop -1 : 비디오를 무한 반복 재생합니다. (오디오가 끝나면 멈추도록 -shortest와 함께 사용)
// -i input_video.mp4 : 첫 번째 입력 파일 (비디오)
// -i input_audio.wav : 두 번째 입력 파일 (오디오)
// -shortest : 가장 짧은 스트림(여기서는 오디오)의 길이에 맞춰 출력을 중단합니다.
// -map 0:v:0 : 첫 번째 입력(비디오)의 비디오 스트림을 출력에 매핑합니다.
// -map 1:a:0 : 두 번째 입력(오디오)의 오디오 스트림을 출력에 매핑합니다.
// -c:v libx264 : 비디오 코덱을 H.264로 재인코딩합니다 (호환성 및 압축).
// -preset ultrafast : 인코딩 속도를 최우선으로 설정합니다 (빠른 미리보기용).
// -c:a aac : 오디오 코덱을 AAC로 재인코딩합니다 (MP4 표준 오디오 코덱).
// -strict experimental : 실험적인 기능(예: AAC 인코더) 사용을 허용합니다.
await ffmpeg.exec([
'-stream_loop', '-1',
'-i', 'input_video.mp4',
'-i', audioFilename,
'-shortest',
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-c:a', 'aac',
'-strict', 'experimental',
'output.mp4'
]);
onProgress("최종 파일 생성 중...");
// FFmpeg 가상 파일 시스템에서 결과 파일(output.mp4)을 읽어옵니다.
const data = await ffmpeg.readFile('output.mp4');
// 읽어온 데이터를 Blob으로 만들고, 이를 위한 URL을 생성하여 반환합니다.
const blob = new Blob([data], { type: 'video/mp4' });
return URL.createObjectURL(blob);
} catch (error: any) {
console.error("FFmpeg 병합 오류 발생:", error);
throw new Error(`영상 합성 중 오류가 발생했습니다: ${error.message}`);
}
};