참석자 한 명만 얼굴을 가려야 했다 - 회식 영상을 코드로 편집하며 밟은 함정 여섯 개
동호회 점심 회식 소재 32개(영상 22 + 사진 10, 합쳐 145초)를 1분 3초로 줄였습니다. 조건이 하나 있었습니다. 참석자 한 명은 얼굴이 나오면 안 됩니다.
0:54 단체 인사 컷에서 왼쪽 네 번째가 스티커로 가려져 있습니다.
영상 한 편을 만드는 이야기가 아니라, 편집을 코드로 하면 무엇이 쉬워지고 무엇이 어려워지는지에 대한 기록입니다. 결론부터 말하면, 손으로 못 하던 게 가능해지는 대신 눈으로 1초에 알아채는 걸 놓칩니다. 그래서 열 번 넘게 다시 렌더했습니다.
이 글은 macOS / OpenCV 5.0 / Remotion 4 / ffmpeg 8.1 기준입니다.
편집을 JSON 으로 다룬다
전제부터. 이 파이프라인은 편집 결과를 EDL(Edit Decision List) 한 파일로 표현합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"meta": { "res": "1920x1080", "fps": 30, "duration": 62.8 },
"tracks": {
"visual": [
{ "id": "v05", "src": "_talkv_...JT.mp4", "in": 0.6, "out": 3.32,
"at": 7.7, "dur": 2.72, "label": "grill" }
],
"captions": [
{ "id": "c04", "text": "양등심", "style": "menu", "start": 7.9, "end": 10.3 }
],
"music": [{ "src": "...591738.mp3", "gain_db": -2.5, "duck": false }]
}
}
in/out 은 소스 안의 시간, at/dur 은 타임라인 위의 시간입니다. Remotion 이 이 JSON 을 읽어 렌더하니 “미리보기 == 최종” 이 성립합니다. 자막 문구를 고치는 건 문자열 하나 바꾸는 일이고, 컷 순서를 바꾸는 건 배열 원소를 옮기는 일입니다.
여기까지가 쉬워지는 쪽입니다. 이제 어려워지는 쪽.
함정 1. 얼굴 추적이 옆 사람으로 갈아탄다
특정 인물만 가리려면 “이 사람인가?” 를 프레임마다 판정해야 합니다. OpenCV 내장 API 두 개로 됩니다.
cv2.FaceDetectorYN(YuNet): 얼굴 검출cv2.FaceRecognizerSF(SFace): 128차원 임베딩, 코사인 유사도로 동일인 판정
참조 얼굴은 단체 사진에서 뽑았습니다. 대상 인물이 코사인 0.98, 나머지 7명이 0.21 이하로 갈렸습니다. 소재 32개를 전부 스캔해 등장하는 4개만 골라냈습니다.
왼쪽이 쓰인 스티커(투명 PNG), 오른쪽이 붙은 결과. 스티커 한 변은 얼굴 박스 긴 변의 1.55배로 잡았습니다. 사람이 손으로 붙였던 기존 결과물이 1.5배였고, 여기에 검출 지터 여유를 조금 더했습니다.
문제는 속도였습니다. 프레임마다 얼굴 전부를 임베딩하면 느려서, “직전 프레임 위치에 가까운 얼굴을 먼저 확인하고 느슨한 임계값(0.32)으로 받는” 최적화를 넣었습니다.
결과물에서 스티커가 엉뚱한 사람 얼굴로 옮겨가고, 정작 가려야 할 사람이 그대로 노출됐습니다.
프레임별 로그를 찍어 보니 이렇게 되어 있었습니다.
1
2
3
frame 검출(유사도, 박스) 평활 후 박스
331 0.507 [2901, 1337, 201, 275] ← 대상 본인 [2312, 996, 342, 490] ← 엉뚱한 곳
333 0.335 [2312, 996, 342, 490] ← 옆 사람 [2312, 996, 342, 490]
f331 에서 대상이 0.507 로 잡혀 있는데도 트랙은 옆 사람(0.335)에게 가 있습니다. 원인이 셋이었습니다.
- 근접만으로 완화하면 카메라가 스윙할 때 옆 사람이 “직전 위치 근처의 첫 후보”가 되어 트랙을 가로챕니다. 완화 임계값 0.32 는 SFace 의 동일인 권장선 0.363 보다 낮아 남을 통과시킵니다.
- 결측 구간을 보간할 때, 잘못된 구간의 앞뒤 패딩이 올바른 프레임의 박스까지 덮어썼습니다.
- 카메라 앞을 지나가는 사람은 얼굴 크기가 확 다른데 그걸 안 봤습니다.
고친 방식은 이렇습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 프레임의 모든 얼굴을 대조해 1위를 고른다.
# 근접 후보만 먼저 보는 최적화는 옆 사람으로 트랙이 갈아타는 사고를 냈다. 정확도를 택한다.
sims = [faces.best_sim(refs, faces.embed(small, c)) for c in cand]
k = int(np.argmax(sims))
is_near = False
if prev is not None and i - prev_i <= int(fps):
reach = max(prev[2], prev[3]) * 1.2 * max(1, i - prev_i)
ratio = max(boxes[k][2], boxes[k][3]) / max(prev[2], prev[3])
# 위치가 가깝고 얼굴 크기도 비슷해야 "같은 트랙"
is_near = (dist(boxes[k], prev) <= reach and 0.55 <= ratio <= 1.8)
if sims[k] >= (THR_LOOSE if is_near else THR_STRICT): # 0.36 / 0.42
hit = boxes[k]
근접은 “임계값을 낮출 자격”일 뿐이고 후보 선택의 근거가 아닙니다. 그리고 완화 임계값도 동일인 권장선 아래로는 내리지 않습니다.
그런데 정확도를 올려도 남는 문제가 있었습니다. 어떤 구간은 대상이 앞사람에 완전히 가려 검출 자체가 안 됩니다. 보간으로 메우면 스티커가 엉뚱한 데 떠 있고, 안 메우면 얼굴이 노출됩니다.
추적이 끊기는 구간은 편집에서 아예 쓰지 않는 것이 가장 안전한 해법이었습니다. 6초짜리 클립을 2.4초만, 12.6초짜리를 4.9초만 썼습니다. 기술로 못 풀면 편집으로 피합니다.
검증도 바꿨습니다. 프록시 전체를 검사하는 게 아니라 EDL 이 실제로 쓰는 구간만 봅니다. 안 쓰는 구간의 누락은 최종물에 안 나오고, 쓰는 구간의 누락은 반드시 잡아야 하니까요.
1
2
3
python -m avedit.mask edl
# IMG_8442.MOV [v16] 0.0~2.167s: OK
# IMG_8451.MOV [v25] 4.0~8.9s: ⚠ 후보 2프레임 — f233(0.361), f260(0.36)
마지막의 0.36 후보 둘은 다른 사람이었습니다. 프록시 화질에서는 남남도 0.36 근처에 걸립니다. 자동 검증은 “사람이 눈으로 볼 후보 목록”이지 판정이 아닙니다.
함정 2. 프록시에서 추적하면 인식이 무너진다
파이프라인은 4K 원본을 1080p 프록시로 줄여 씁니다. 처음엔 추적도 프록시에서 했습니다. 그런데 뒤에 앉아 반쯤 가려진 얼굴이 전혀 안 잡혔습니다.
같은 얼굴을 같은 검출 폭(1280px)으로 넣고 재봤습니다.
| 소스 | 코사인 유사도 |
|---|---|
| 4K 원본 | 0.42 ~ 0.56 |
| 1080p 프록시 (crf 23 재인코딩) | 0.25 이하 |
축소가 아니라 재인코딩이 문제입니다. 작은 얼굴의 디테일이 압축에 먹히면 임베딩이 무너집니다. 그래서 규칙을 이렇게 정했습니다.
추적은 원본에서, 합성은 프록시에서. 좌표는 선형이라
dst_width / src_width로 옮기면 됩니다. 최종 렌더가 프록시를 읽으니 4K 를 다시 인코딩할 필요도 없고, 평면 그래픽인 스티커는 프록시 크기로 바로 그리는 게 4K 로 그려서 축소하는 것보다 깨끗합니다.
함정 3. 전달받은 영상엔 촬영 시각이 없다
소재는 출처가 섞여 있었습니다. 본인이 찍은 IMG_####.MOV 와, 다른 참석자가 카카오톡으로 보내준 _talkv_*.mp4.
전자는 컨테이너 creation_time 이 있습니다. UTC 라 KST 로 환산해 정렬하면 끝입니다.
1
2
3
4
from datetime import datetime, timedelta, timezone
def utc2kst(s): # "2026-09-02T02:37:28.000000Z"
d = datetime.strptime(s.replace("Z", "+0000"), "%Y-%m-%dT%H:%M:%S.%f%z")
return d.astimezone(timezone(timedelta(hours=9)))
후자는 메타데이터가 아예 없습니다. 메신저를 거치며 재인코딩돼서 creation_time 이 지워집니다. 파일명의 해시는 전송 식별자일 뿐이고 알파벳 순서도 촬영 순서와 무관합니다.
여기서 사고가 났습니다. 8.8초짜리 클립의 앞머리 프레임 몇 장만 보고 “가게 도착·입장”으로 판단해 영상 맨 앞에 배치하고, 자막을 「먹기도 전에 페브리즈 방어 태세」로 달았습니다.
실제로는 전부 식사 후 나가는 장면이었습니다.
- 0.2초부터 이미 페브리즈 병을 손에 들고 있었고
- 카메라가 계산대(POS 단말기)를 지나 안쪽으로 팬했고
- 뒤쪽 테이블에는 이미 다 먹은 다른 손님이 있었습니다
「문 근처에 사람이 서 있다」를 도착으로 읽은 게 오류였습니다. 입장과 퇴장은 같은 문에서 찍힙니다. 도착과 퇴장을 가르는 건 문이 아니라 손에 든 물건·계산대·테이블 상태입니다.
시간축을 잘못 놓으면 자막까지 연쇄로 틀립니다. 그래서 전달받은 파일은 클립 전체를 0.5초 간격으로 프레임을 뽑아 한 시트로 보고, 배치 근거를 “0.2s 부터 페브리즈 소지 → 퇴장”처럼 문서에 적어 둡니다.
함정 4. 라이브포토 영상은 흔들린다, 옆에 원본 사진이 있다
완성본을 돌려 보니 오프닝 컷이 어지러웠습니다. 눈대중으로 「이 컷이 문제다」 하고 넘기지 않고 광학 흐름으로 재봤습니다.
1
2
3
4
5
6
7
8
9
10
11
import cv2, numpy as np
cap = cv2.VideoCapture(path); prev = None; m = []
while True:
ok, fr = cap.read()
if not ok: break
g = cv2.cvtColor(cv2.resize(fr, (320, 180)), cv2.COLOR_BGR2GRAY)
if prev is not None:
f = cv2.calcOpticalFlowFarneback(prev, g, None, 0.5, 3, 15, 3, 5, 1.2, 0)
m.append(float(np.linalg.norm(f, axis=2).mean()))
prev = g
print(np.mean(m), max(m)) # 0.1~0.2 = 안정 / 1.0 이상 = 눈에 띄는 흔들림
| 소재 | 프레임 간 평균 이동량 |
|---|---|
| IMG_8438.MOV (라이브포토 영상) | 3.91 px (최대 14.84) |
| IMG_8438.HEIC (원본 사진) | 0.09 px |
| 다른 안정된 컷들 | 0.12 ~ 0.18 px |
20배 넘게 흔들렸습니다. 그리고 여기서 놓쳤던 게 있습니다. IMG_####.MOV + IMG_####.HEIC 쌍은 아이폰 라이브포토입니다. MOV 는 셔터 전후를 담은 곁가지 영상이고, HEIC 가 실제로 찍은 12MP 사진입니다.
처음에 “라이브포토 정지컷은 MOV 와 내용이 중복”이라 판단해 HEIC 를 전부 버렸는데, 그 판단의 근거가 틀렸습니다.
ffprobe는 HEIC 의 임베드 썸네일만 보고합니다. 512×512 나 640×896 로 나와서 쓸모없는 파일처럼 보입니다.pillow_heif로 읽어야 실제 4032×3024 가 나옵니다.
deshake 필터는 기대만큼 안 들었습니다. 다른 흔들리는 컷(평균 1.15px)에 걸어 보니 1.12px 로, 거의 줄지 않았습니다. 대신 두 가지로 풀었습니다.
- 가장 심한 컷은 HEIC 원본 사진으로 교체. 3.91 → 0.09
- 1초짜리 라이브포토에 자막을 넣어야 하면 슬로모션.
speed 0.5로 늘리면 시간도 벌고 흔들림도 0.98 → 0.34 로 줄어듭니다
흔들려서 못 쓰는 클립에서 정지컷을 뽑을 때도 함정이 있었습니다. 처음엔 가장 선명한 프레임을 골랐는데, 카메라가 흐르는 클립은 프레임마다 피사체 위치가 다릅니다.
| 프레임 | 피사체 무게중심 | 선명도 |
|---|---|---|
| f0 | (0.510, 0.482) | 16.0 |
| f3 | (0.506, 0.478) | 19.3 |
| f33 (최선명) | (0.545, 0.402) | 17.1 |
| f48 | (0.595, 0.317) | 20.9 |
최선명 프레임이 하필 구도가 가장 치우친 시점이었습니다. 완성본에서 그 컷만 중심이 안 맞아 보였던 이유입니다. 선택 기준을 이렇게 바꿨습니다. 선명도 상위 구간만 후보로 남기고, 그중 피사체 중심이 가장 중앙인 프레임을 고릅니다. 피사체 중심은 채도×명도 가중 무게중심으로 어림했습니다(색이 분명한 피사체 전용이라, 결과는 눈으로 확인해야 합니다).
함정 5. 확대가 정수 픽셀로 떤다
사진 컷에 켄번스(느린 줌)를 넣었는데, 재생해 보니 부드럽지 않고 미세하게 떨렸습니다.
원인은 ffmpeg zoompan 이 크롭 사각형을 정수 픽셀로 계산하는 것이었습니다. 배율이 서서히 올라가는 동안 크롭 박스가 프레임마다 1px 씩 불규칙하게 튑니다.
프레임 간 속도 변화(저크)를 재서 비교했습니다. 작을수록 부드럽습니다.
| 방식 | 저크 평균 |
|---|---|
zoompan, 사전 캔버스 1.12배 | 0.0834 |
zoompan, 사전 캔버스 2배 | 0.0465 |
zoompan, 사전 캔버스 4배 | 0.0101 |
Remotion CSS transform: scale() | 0.0035 |
사전 캔버스를 키우면 반올림 오차의 상대 크기가 줄어 개선되지만 임시방편입니다. 애초에 CSS 변환은 서브픽셀이라 이 문제가 원리적으로 없습니다.
1
2
3
4
5
6
7
8
9
10
11
12
const PhotoBody: React.FC<{c: Clip; durF: number}> = ({c, durF}) => {
const f = useCurrentFrame();
const p = durF > 1 ? Math.min(1, f / (durF - 1)) : 0;
const zoom = c.kb?.zoom ?? 1.1;
const scale = c.kb?.direction === 'out' ? zoom - (zoom - 1) * p : 1 + (zoom - 1) * p;
// objectFit:cover 로 잘려 나가는 방향의 "보이는 위치". panTo 까지 주면 스크롤이 된다
const y0 = c.kb?.posY ?? 50;
const posY = c.kb?.panTo === undefined ? y0 : y0 + (c.kb.panTo - y0) * p;
return <Img src={photoSrc(c.src)} style={{
width: '100%', height: '100%', objectFit: 'cover',
objectPosition: `50% ${posY}%`, transform: `scale(${scale})`}} />;
};
정지 이미지를 mp4 로 미리 굽는 단계가 사라져서 세대 손실도 없어졌습니다. 24배 부드러워졌고 화질도 좋아졌습니다.
덤으로 하나 더 얻었습니다. 세로 사진을 16:9 에 넣을 때는 줌보다 “아래로 스크롤”이 낫습니다. 3840×5120 사진을 16:9 로 자르면 2960px 이 남는데, 위쪽은 불판 테두리고 아래로 갈수록 고기가 꽉 찼습니다. objectPosition 의 Y 를 52% → 88% 로 보간하면 브라우저가 초과분을 알아서 매핑해 줍니다. 이미지 종횡비를 몰라도 되고, 가로 사진에 줘도 초과분이 없으면 아무 일이 안 일어나 안전합니다.
함정 6. BGM 을 바꿨더니 검은 프레임 8개
컷 경계를 BGM 비트에 스냅했습니다. librosa.beat.beat_track 으로 비트 그리드를 뽑아 컷 끝을 가장 가까운 비트로 붙이는 방식입니다.
같은 곡을 네 편 연속으로 쓰는 게 싫어 BGM 을 교체했습니다. 그것만 바꿨는데 26컷 중 8곳에 1프레임 검은 화면이 났습니다.
| 곡 | BPM | 비트 간격 | 30fps 기준 |
|---|---|---|---|
| 이전 | 152 | 0.400초 | 정확히 12프레임 |
| 새 곡 | 112 | 0.545초 | 16.36프레임 |
이전 곡은 비트가 프레임의 정수배라 우연히 아무 문제가 없었습니다. 새 곡에서는 at 과 dur 을 각각 반올림할 때
1
round(at * fps) + round(dur * fps) < round((at + dur) * fps)
가 되는 컷이 생기고, 그 자리에 1프레임 구멍이 뚫립니다. 렌더러 탓이 아니라 EDL 이 프레임 격자에 안 올라가 있던 게 문제입니다.
1
2
3
4
5
6
7
8
9
10
11
12
def to_frame(t, fps=FPS):
"""비트에 스냅한 시각을 다시 프레임 정수로 올린다."""
return round(round(t * fps) / fps, 6)
# 빌드 끝에서 단정. 다시 생기면 즉시 실패한다
pos = 0
for c in visual:
f0, fn = round(c["at"] * FPS), round(c["dur"] * FPS)
if f0 != pos:
raise AssertionError(f"{c['id']}: 프레임 불연속 at={f0} 기대={pos}")
pos += fn
assert pos == round(total * FPS)
1프레임 구멍은 프레임 시트를 띄엄띄엄 뽑아서는 절대 안 걸립니다. 렌더 후 전 프레임의 평균 밝기를 훑는 검사를 붙였습니다.
1
2
3
4
5
6
7
8
cap = cv2.VideoCapture(out); i = dark = 0
while True:
ok, fr = cap.read()
if not ok: break
if cv2.cvtColor(cv2.resize(fr, (160, 90)), cv2.COLOR_BGR2GRAY).mean() < 8:
dark += 1
i += 1
print(f"{i}프레임 / 어두운 프레임 {dark}개")
같은 부류로 하나 더 있었습니다. Remotion <OffthreadVideo> 의 startFrom/endAt 은 컴포지션 프레임 기준이고 playbackRate 를 반영하지 않습니다. 슬로모션 컷에 endAt = out * fps 를 주면 앞부분만 재생되고 나머지가 검게 나옵니다(2초 컷에서 1초 이후 전부 검은 화면). startFrom + dur * fps 가 맞습니다.
음원 출처는 되짚을 수 있게 남긴다
BGM 출처를 다시 확인해 보니, 첫 프로젝트에서 복사해 온 파일이었습니다. 크레딧은 두 달 전에 적어 둔 메모 한 줄뿐이었습니다. 게다가 파일에는 ID3 태그가 전혀 없었습니다. 원본 다운로드도 남아 있지 않아 그 메모가 맞는지 검증할 방법이 없었습니다.
곡 길이(123.24초 = 2:03)로 아티스트 목록을 대조해 특정은 했지만, 파일 지문 대조가 아니라 정황이었습니다. 그래서 규칙을 정했습니다.
BGM 은 곡 페이지 URL · 트랙 ID · 아티스트 · 다운로드 날짜 · 파일 md5 를 함께 적는다. 파일만 두면 몇 달 뒤 검증이 불가능해집니다. 기존 프로젝트에서 복사해 재사용할 때도 크레딧을 그대로 옮겨 적지 말고, 미검증이면 미검증이라고 씁니다.
그리고 알게 된 것 하나. 「No Copyright Music」은 마케팅 문구이고 법적 상태가 아닙니다. Pixabay 음원도 상당수가 YouTube Content ID 에 등록돼 있습니다. 다운로드 화면이 명시적으로 알려주고 라이선스 증명서도 제공하니, 공개 업로드 전에 증명서를 받아 두면 됩니다. 곡 목록의 방패 아이콘이 Content ID 등록 표시라, 아이콘 없는 곡을 고르면 클레임 자체를 피할 수 있습니다.
배운 것
여섯 개를 늘어놨는데, 성격이 둘로 갈립니다.
하나는 “코드로는 못 보는 것”입니다. 오프닝이 어지럽다, 확대가 떤다, 정지컷 중심이 안 맞는다, 자막이 근거 화면과 어긋난다. 이런 건 전부 완성본을 그냥 보다가 걸렸습니다. 그때까지 자동 검사는 전부 통과였고, 프레임 시트를 띄엄띄엄 뽑아 보면 아무 문제가 없어 보였습니다.
다른 하나는 “눈으로는 못 잡는 것”입니다. 1프레임 검은 구멍, 인식 임계값이 옆 사람을 통과시키는 것, 프록시 재인코딩이 임베딩을 무너뜨리는 것. 이런 건 숫자로 재야 보입니다.
그래서 순서가 이렇게 됩니다. 판단은 눈이 먼저, 확인은 측정으로. 그리고 한 번 밟은 함정은 검사로 굳혀 둡니다. 프레임 연속성은 빌드가 assert 로 막고, 검은 프레임은 렌더 후 전수 검사가 잡고, 얼굴 마스킹은 EDL 이 쓰는 구간만 재검증합니다.
마지막으로, 기술로 못 풀리는 건 편집으로 피하는 게 정답이었습니다. 추적이 끊기는 구간을 억지로 보간하는 대신 그 구간을 안 쓰는 것. 6초짜리에서 2.4초만 쓰고 나머지를 버리는 판단이, 정교한 알고리즘보다 안전했습니다.


