ffmpeg로 영상을 자르고 붙여보자

서론

이 글에서 쓰인 코드는 조잡하지만
ffmpeg-testbed 에서 찾아볼 수 있다.

video-editor 를 만들고 싶다고 해보자.
가장 기본적으로 있어야 하는 기능이 무엇일까?

위 2가지 단위가 기본이 될 것이다.

환경 및 라이브러리

영상 준비

yt-dlp 를 사용해서 영상을 다운로드 받는다.
나의 경우 bruno mars의 skate라는 노래를 다운 받았다.

# yt-dlp를 alias를 통해 ytdlp로 설정했다
ytdlp -f bestvideo+bestaudio https://www.youtube.com/watch?v=CEw-7cMnBDY
mv "downloaded_filename" skate.webm

Cut (영상 자르기)

설명을 간결히 하기 위해 클립 2개를 쪼개서 붙이는 상황을 가정하자.

input파일인 skate.webm에서
각각 5초길이의 1.webm2.webm을 얻어올 것이다.

ffmpeg -i input.mp4 -ss 00:00:00 -t 00:00:05 -c copy 1.webm
ffmpeg -i input.mp4 -ss 00:00:08 -t 00:00:13 -c copy 2.webm

이를 typescript로 표현해보자.

import { $ } from "execa";
import ffmpegPath from "ffmpeg-static";
import ffprobePath from "ffprobe-static";
 
const ffmpeg = ffmpegPath as string;
const ffprobe = ffprobePath["path"] as string;
 
const cut = async(
    video_path: string,
    start: string,
    end: string,
    output_file_path: string
) => {
	const res = await $` ${ffmpeg} -i ${video_path}
        -ss ${start}
        -to ${end}
        -c copy ${output_file_path}
    `;
	return res['stdout']
};
 
async(() => {
    await cut('skate.webm', '00:00:00', '00:00:05', '1.webm');
    await cut('skate.webm', '00:00:08', '00:00:13', '2.webm');
})();

execa가 child_process를 실행할때 현재 project root에서 실행하기 때문에
root 바로 아래에 1.webm, 2.webm 파일이 생성된다.

Concat (영상 붙이기)

이제 1.webm과 2.webm을 붙여보자.
붙일 때는 out.webm이라는 파일이름으로 붙일 것이다.

ffmpeg -f concat -i input.txt -c copy out.webm

input.txt ?

ffmpeg 문서 syntax 를 살펴보자.
여기서 영상을 합친 방식은 알려진 방식들 중에서 Concat demuxer에 해당한다.

이는 어떤 파일들을 합칠 것인지 알아야 하므로
input.txt 파일을 만들어야 한다.
파일이름은 상관없다.

# input.txt
file '1.webm'
file '2.webm'

'#' directive의 경우 무시된다.
cut과 concat을 포함해서 typescript로 표현해보자.

import { $, execa } from "execa";
import ffmpegPath from "ffmpeg-static";
import ffprobePath from "ffprobe-static";
 
const ffmpeg = ffmpegPath as string;
const ffprobe = ffprobePath["path"] as string;
 
const cut = async(
    video_path: string,
    start: string,
    end: string,
    output_file_path: string
) => {
	const res = await $`${ffmpeg} -i ${video_path}
        -ss ${start}
        -to ${end}
        -c copy ${output_file_path}
    `;
	return res['stdout']
};
 
/**
 * 기존의 파일이 존재하면 pending이 되므로 시작할때마다 초기화해준다
 * unix 기반 기준이므로 윈도우의 경우 알맞게 조정하자
 */
const init = async() => {
	await execa("rm", ["-rf", "1.webm", "2.webm", "out.webm"]);
};
 
/**
 * input.txt 파일을 만들어준다
 */
const write_file = async(file_path: string, data: string) => {
	return new Promise((resolve, reject) => {
		fs.writeFile(file_path, data, (err) => {
			if (err) reject(err);
			else resolve(data);
		})
	});
};
 
/**
 * input.txt 파일을 만들고 concat을 실행한다
 */
const concat = async(output_file_path: string = "out.webm") => {
	const input = "file '1.webm'\nfile '2.webm'";
	await write_file(input_file, input);
	const res = await $`${ffmpeg} -f concat -i ${input_file} -c copy ${output_file_path}`;
	return res['stdout']
};
 
async(() => {
    await init();
    await cut('skate.webm', '00:00:00', '00:00:05', '1.webm');
    await cut('skate.webm', '00:00:08', '00:00:13', '2.webm');
    await concat();
})();
simple concat
simple concat

중간에 누가봐도 명확히 끊긴다.
이건 우리가 원하는 방식의 concat이 아니다.

key_frame 과 pkt_pts_time

이런 현상이 발생하는 이유를 알기 위해서는 keyframe 에 대해 알아야한다.
일단 한번 다음 커맨드를 1.webm에 대해 실행해보자.

ffprobe -show_frames -select_streams v -print_format json=c=1 1.webm

뭔가 겁나 출력이 되는 것을 볼 수 있을 것이다.
하지만 이래서는 유용하게 정보를 포착할 수가 없다.
따라서 typescript로 표현해보자.

// ...
 
const show_frames = async(video_path: string) => {
	const res = await $`${ffprobe} -show_frames -select_streams v -print_format json=c=1 ${video_path}`;
	return res['stdout'];
};
 
async(() => {
    const res = await show_frames("1.webm");
    // Frame 타입은 따로 정의해둔거라 무시해도된다
	const frames: Frame[] = JSON.parse(res)['frames'];
    console.log(frames);
})();
frame.png
frame.png

정말 많은 것들이 출력될텐데, 이중 1개의 프레임만을 캡쳐해온 것이다.
우리가 집중해서 봐야할 값은 key_framepkt_pts_time 이다.

주의: 다음 설명들은 틀릴 수 있습니다.

key_frame은 영상이 시작하는 지점을 의미한다.

가장 하기 쉬운 고정관념이
어떤 영상의 모든 프레임은 시작점이 될 수 있다고 생각하는 것이다.
아니다. 오직 key_frame == 1인 프레임만이 시작점이 될 수 있다.

pkt_pts_time은 무엇이냐?
이것은 해당 프레임이 시작하기까지 걸리는 시간이다.

따라서 우리가 처음에 concat한 영상이
이상하게 나온 이유에 대해서 이제 설명을 해볼 수 있게 되었다.

한번 다음과 같이 2.webm 파일의 첫번째 프레임을 가져와보자.

async(() => {
    const res = await show_frames("2.webm");
	const frames: Frame[] = JSON.parse(res)['frames'];
    console.log(frames[0]);
})();
start with keyframe but pkt_pts_time is not zero
start with keyframe but pkt_pts_time is not zero

이럴 수가..
2.webm 은 첫번째 프레임이 keyframe이지만
시작하는 순간이 pkt_pts_time = 3.708000 초 뒤였던 것이다!

다시 말하면 concat을 할때 그냥 붙이면 안되고
3.708000 초 만큼 시작지점을 늘려준 뒤 붙여야 한다는 것이다.

그럼 이제 제대로된 concat을 해보자.

// ...
 
/**
 * js에서 string 끼리 더하는 연산은 상당히 직관적이다.
 */
const string_sum = (a: string, b: string) => {
	return (Number(a) + Number(b)).toFixed(6);
};
 
const cut_with_keyframe = async(next_keyframe: string) => {
	await execa("rm", ["-rf", "2.webm", "out.webm"]);
	await cut(video_dir + video, string_sum(`${step + interval}`, next_keyframe), `${2 * step + interval}`, "2.webm");
}
 
async(() => {
    await init();
    await cut('skate.webm', '00:00:00', '00:00:05', '1.webm');
    await cut('skate.webm', '00:00:08', '00:00:13', '2.webm');
    
    const res = await show_keyframes("2.webm");
	const frames: Frame[] = JSON.parse(res)['frames'];
	
    /**
     * 2.webm에서 맨처음 시작하는 key_frame을 찾아서
     * 그 프레임의 pkt_pts_time을 가져온다
     */
    let next_keyframe = undefined;
	for(let i = 0; i < frames.length; i++) {
		if (frames[i]['key_frame'] === 1) {
			next_keyframe = frames[i]['pkt_pts_time'];
			break;
		}
	};
 
    /**
     * concat 하기 전에 다시 올바르게 잘라준다
     */
    await cut_with_keyframe(next_keyframe!);
    await concat();
})();
concat with keyframe edit
concat with keyframe edit

자른게 맞나 싶을 정도로 스무스하게 진행되는 것을 볼 수 있다.