137 lines
6.8 KiB
TypeScript
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}`);
|
|
}
|
|
}; |