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 인스턴스 */ 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} - 패치된 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} - 병합된 비디오의 Blob URL */ export const mergeVideoAndAudio = async ( videoUrl: string, audioUrl: string, onProgress: (msg: string) => void ): Promise => { 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}`); } };