영상을 생성하는 AI는 많지만, 내가 필요한 영상으 원하는 퀄리티로 만들어주는 AI는 아직 없는것같다.
이미지들을 받으면 문구와 합쳐서 기깔난 광고영상을 만드는게 목표였지만 현재 나와있는 AI 모델들/서비스들은 아직 까지 내가 원하는 퀄리티의 영상이 나오지 않거나, 너무 비싸거나, 영상이 너무 짧았다.
특히 많은 AI들이 중국회사에서 만들어진 만큼 한글 프롬프트를 입력서 문구를 생성해서 넣어주면 중국말로 나오거나, 아에 처음보는 언어로 문구를 제작해서 사용하기 어려웠다.
그래서 해결 방안을 이미지들로 5초 영상을 만들고, 해당 영상 마다 문구를 넣어서 최종적으로 영상들을 병합하면 하나의 퀄리티 있는 광고영상으로 제작 가능했다.
초반에는 모든걸 AI가 만들게 하고 싶었지만 아직까지 내가 상상한대로 만들어주는 모델이 부족하고, 내가 직접 모델링을 하는 작업도 기간안에 해결하지 못하기 때문에 해당 방법을 택했다.
영상을 편집하기 위해서 사용한 프레임 워크로, 영상/오디오 편집, 변환, 스트리밍 까지 가능한 웹어셈블리어로 만들어진 도구이다.
FFmpeg는 총 4가지 레이어로 나누어졌는데
예: drawtext, scale, xfade, tpad, fps, format
카메라, 오디오 인터페이스 등 실시간 캡처 지원
규격 및 리사이징
원하는 영상의 규격, 용량, 코덱 과 같은 영상의 정보를 읽고 필요시에 리사이징 및 변환을 해서 내가 원하는 포멧의 영상으로 만들어야한다
영상에 문구 삽입
영상 마다 문구, 폰트, 사이즈 등 커스텀 문구를 삽입
영상들 병합
영상들을 최종적으로 자연스럽게 병합
ffmpeg 에서도 fluent-ffmpeg 패키지를 사용하면 Node.js 환경에서 쉽게 다룰수있다
const MAX_WIDTH = 1920;
const MAX_HEIGHT = 1080;
const MIN_WIDTH = 640;
const MIN_HEIGHT = 480;
const MAX_FILE_SIZE_BYTES = 1000 * 1024 * 1024; // 1000MB
const MIN_FILE_DURATION_SECONDS = 5;
const MAX_FILE_DURATION_SECONDS = 300;
const MIN_FRAME_RATE = 24;
const MAX_FRAME_RATE = 60;
const ALLOWED_VIDEO_CODECS = ['h264', 'vp8', 'vp9', 'av1', 'mpeg4', 'hevc'];
const ALLOWED_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
const metadata = await new Promise<ffmpeg.FfprobeData>(
(resolve, reject) => {
ffmpeg.ffprobe(videoUrl, (err, data) => {
if (err) reject(new Error(`영상 메타데이터 분석 실패: ${err.message}`));
else resolve(data);
});
},
);
//비디오 스트림과 오디오 스트림 추출
const videoStream = metadata.streams.find(
(stream) => stream.codec_type === 'video',
);
const audioStream = metadata.streams.find(
(stream) => stream.codec_type === 'audio',
);
//해상도, 코덱 ㅡ 프레임레이트 등 필요한 값들 추출
const { width, height, codec_name: videoCodec, avg_frame_rate, duration } = videoStream;
const audioCodec = audioStream?.codec_name;
이제 추출 된 값들로 규격에 맞는지 확인하고 검사할수있다
async function runFFmpeg(args: string[], label?: string) {
return new Promise<void>((resolve, reject) => {
console.log(`[ffmpeg] Running: ${label || 'command'} ${args.join(' ')}`);
const ffmpeg = spawn('ffmpeg', args);
ffmpeg.stderr.on('data', (d) =>
console.log(`[ffmpeg ${label || ''}]`, d.toString()),
);
ffmpeg.on('close', (code) =>
code === 0
? (console.log(`[ffmpeg ${label || ''}] Success`), resolve())
: (console.log(`[ffmpeg ${label || ''}] Failed with code ${code}`),
reject(new Error('ffmpeg failed'))),
);
});
}
async function downloadVideo(url: string, dest: string) {
console.log(`[download] Start downloading ${url}`);
const response = await axios.get(url, { responseType: 'arraybuffer' });
fs.writeFileSync(dest, Buffer.from(response.data));
console.log(`[download] Finished downloading ${url}`);
}
기존의 영상들이 S3또눈 Storage에 저장되어있기 때문에 다운로드를 진행해야한다.
function buildXfadeFilter(
files: string[],
singleLength = 5,
transitionDuration = 0.5,
) {
let filter = '';
// 1️⃣ 각 입력 영상에 tpad 적용
for (let i = 0; i < files.length; i++) {
filter += `[${i}:v]tpad=stop_duration=${singleLength}[v${i}];`;
}
// 2️⃣ xfade 체인 (라벨 충돌 방지)
let prevLabel = 'v0';
for (let i = 1; i < files.length; i++) {
const offset = i * singleLength - transitionDuration;
const outLabel = i === files.length - 1 ? 'vout' : `xf${i}`;
filter += `[${prevLabel}][v${i}]xfade=transition=fade:duration=${transitionDuration}:offset=${offset}[${outLabel}];`;
prevLabel = outLabel;
}
return filter.slice(0, -1); // 마지막 ; 제거
}
4, 영상 처리
const processedFiles: string[] = [];
const singleLength = 5;
const transitionDuration = 0.5;
for (let i = 0; i < texts.length; i++) {
const inputPath = path.join('/tmp', `input_${i}.mp4`);
const outputPath = path.join('/tmp', `processed_${i}.mp4`);
if (urls[i]) {
await downloadVideo(urls[i], inputPath);
}
await runFFmpeg(
[
'-i', inputPath,
'-vf', `
scale=1080:1920,fps=30,format=yuv420p,
drawtext=text='${texts[i]}':fontcolor=white:fontsize=40:x='(w-text_w)/2':y='(h-text_h)/2',
tpad=stop_duration=${transitionDuration}
`,
'-t', `${singleLength + transitionDuration}`,
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-pix_fmt', 'yuv420p',
'-y', outputPath,
],
`video-${i}`,
);
processedFiles.push(outputPath);
}
입력 영상 지정
-i inputPath
비디오 필터 체인
픽셀 포맷ㅡ 프레임레이트, 해상도 , 텍스트 오버레이 (drawtext), tpad 트랜지션 정지화면
-vf "
scale=1080:1920,
fps=30,
format=yuv420p,
drawtext=text='{transitionDuration}
"
출력 영상 길이 제한
-t ${singleLength + transitionDuration}
비디오 코덱 지정
-c:v libx264
인코딩 속도/압축률
-preset fast
영상 품질 설정
0 = 무손실, 18 = 고화질, 23 = 기본, 30 이상 = 저화질.
-crf 23
한번더 픽셀 포맷 강제
안 하면 libx264 기본값 때문에 일부 브라우저에서 안 나올 수 있음.
-pix_fmt yuv420p
출력 파일경로 지정
-y outputPath
비디오 코덱
H.264 (libx264) → 가장 범용적, 화질·용량 균형 좋음.
H.265 (HEVC, libx265) → 더 압축 효율 좋음, 하지만 인코딩 느리고 호환성 떨어짐.
VP9 / AV1 → 구글/오픈소스 계열, 유튜브/넷플릭스에서 많이 씀.
오디오 코덱
AAC (기본, 호환성 최고)
MP3 (구버전, 여전히 호환성 좋음)
Opus (화질·압축 우수, WebRTC/Discord에서 많이 씀)
비트레이트 (Bitrate): 용량 기반 인코딩
영상/음성이 초당 얼마나 많은 데이터로 표현되는지를 의미.
높으면: 화질·음질↑, 용량↑
낮으면: 화질·음질↓, 용량↓
CRF (Constant Rate Factor): 품질 기반 인코딩
낮으면: 화질·음질↑, 용량↑
높으면: 화질·음질↓, 용량↓
유튜브/틱톡/웹 업로드 → CRF 모드 선호 (자동으로 화질/용량 최적화).
방송/스트리밍 → 비트레이트 모드 (네트워크 대역폭 일정하게 유지).
초반에 진행한 방식은 concat으로 영상을 붙여서 하나의 영상으로 만들었지만 결과물을 보고서 trasition이 없이 이미지가 바뀌는건 너무 어색하고 부자연스러워서 transition을 넣고 싶었지만 concat으로는 불가능 했다
xfade라는 기술을 활용해서 transition을 주고 tpad를 사용해서 원하는 시간에 맞추어 계산해서 빈 프레임을 활용한 fade-in-out transition이 가능했다
문구의 색을 넣어줄때 RGB값으로 최대한 정확하게 삽입을 해주어도, 인코딩을 시키고 나면 색이 바뀌는 현상이 생겼다.
원인을 찾아보니 인코딩 할대 용량을 절약하기 위해서 yuv420p 라는 픽셀 포멧을 사용하는데 이러면 정밀도가 떨어져서 영상에서는 다른색 처럼 보인다는것이다.
솔직히 거의 불가능 했다, RGB 값을 유지하기 위해서는 다른 포멧 즉 데이터를 무결성을 유지한 영상을 인코딩하기위해서는 너무 오랜 시간이 걸리면서 실질적으로 사용하기에는 어려웠다, 따라서 노동을 통해 색이 살짝 어두어지기 때문에 똑같은 색은 아니더라도 기존에 원하는 RGB 값보다 조금 더 밝게 넣어주면 최선의 원하는 색이 나왔다.
사실 아직 해당 기술에 대해서 많은걸 알지는 못하지만 키워드,영상에 사용되는 용어등 새로운것을 배우기에 넘쳐났다.
시간이 부족한 관계로 그저 빨르게 테스트하고 실행한 결과만 찾기 윈해서 딥하게 공부하기보는 빠르게 해당 기술을 사용할수있게 경험을 쌓고 필요시에 더 깊게 공부할 생각이다, 특히 FFmpeg를 기반으로 웹에서 영상 편집기를 만든 사람들을 보고 추후에 더 활용해보고 싶다.
저도 실무에 영상 메타데이터를 가져와서 사운드 유무를 판단해야 할 경우가 있어 흥미롭게 읽었습니다.
video tag의 loadstart 속성으로 메타데이터 내의 사운드 정보를 판단하였는데 이런 라이브러리가 있다면 추후 개선 및 기능 추가시 검토해볼 수도 있을거같아요.