ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ffmpeg concat이 Seedance 클립 두 번째부터 깨졌다 — video duration이 진실의 원천
    IT 2026. 5. 24. 22:00
    ffmpeg concat이 Seedance 클립 두 번째부터 깨졌다 — video duration이 진실의 원천

    🔧 이 글은 BytePlus Seedance 2.0 fast로 만든 영상 클립들을 ffmpeg로 자동 합본하는 사용자를 위한 글입니다. Seedance가 클립마다 다른 audio spec(mono/stereo)을 출력해서 발생하는 시나리오라 AI 영상 자동화 환경에서 특히 자주 만나지만, 함정 자체와 해결책은 heterogeneous mp4를 다루는 모든 영상 파이프라인에 적용됩니다.

    멀티 stream을 다루다 보면 만나는 두 함정

    영상 후처리 파이프라인을 만들다 보면 ffmpeg의 멀티 stream 다루기에서 거의 모든 사람이 한 번씩은 빠지는 두 함정이 있습니다.

    • mux 함정: 영상과 음성을 합치려고 -shortest 플래그를 썼더니 영상까지 잘려나간다.
    • concat 함정: 여러 클립을 합치려고 -c copy를 썼더니 두 번째 클립부터 음성이 깨진다.

    두 함정은 서로 달라 보이지만, 사실 같은 뿌리에서 옵니다 — "어느 stream이 진실의 원천인가를 명시하지 않은 ffmpeg pipeline". 한 번 이 시각을 갖고 보면, 두 함정에 동일한 해결책이 적용됩니다.

    함정 1 — -shortest는 영상까지 자른다

    reaction 효과음을 5초짜리 영상에 입히려고 했습니다. 음성은 짧은 "음" 소리 1.6초짜리. 자연스럽게 떠오른 패턴은 이거였습니다.

    ffmpeg -i video.mp4 -i reaction.mp3 \
           -c:v copy -map 0:v -map 1:a \
           -shortest output.mp4
    

    -shortest라는 이름 그대로 "가장 짧은 stream에 맞춰 종료"입니다. 결과를 보니 5초였던 영상이 1.57초로 잘려 있었습니다. 음성 1.6초에 맞춰 영상까지 잘린 거죠. 의도는 "1.6초 음성이 끝나면 영상은 무음 상태로 5초까지 계속"이었는데, 실제 동작은 정반대였습니다.

    해결책은 단순합니다. video duration을 진실의 원천으로 명시하면 됩니다.

    # 1. video duration 측정
    video_dur = float(subprocess.check_output([
        "ffprobe", "-v", "error", "-show_entries", "format=duration",
        "-of", "default=nw=1:nk=1", "video.mp4"
    ]))
    
    # 2. -t로 출력 길이를 video duration에 고정
    subprocess.run([
        "ffmpeg", "-y",
        "-i", "video.mp4", "-i", "reaction.mp3",
        "-c:v", "copy", "-map", "0:v", "-map", "1:a",
        "-t", f"{video_dur:.3f}",          # ← 진실의 원천을 명시
        "output.mp4",
    ])
    

    이렇게 하면 audio가 짧으면 자연스러운 무음 padding이 붙고, 길면 잘립니다. -shortest는 둘 중 짧은 쪽에 맞추는 게 아니라 항상 video에 맞춘다는 명시적 의도가 코드에 박힙니다.

    함정 2 — -c copy concat이 두 번째 클립부터 깨진다

    AI로 만든 11개 클립을 한 영상으로 합본할 때 더 큰 함정을 만났습니다. concat demuxer 방식이 가장 흔히 추천되는 패턴입니다.

    # clips.txt
    file 'clip_01.mp4'
    file 'clip_02.mp4'
    file 'clip_03.mp4'
    ...
    
    # ffmpeg
    ffmpeg -f concat -safe 0 -i clips.txt -c copy concat.mp4
    

    -c copy로 stream을 그대로 복사하니 빠르고 무손실일 거라 기대했지만, 결과는 참담했습니다. 첫 클립은 정상인데 두 번째 클립부터 음성이 안 들립니다. 어떤 player에선 그냥 무음, 어떤 player에선 노이즈, 어떤 player에선 video와 audio가 어긋난 sync.

    원인은 mp4 클립마다 audio spec이 미묘하게 다르다는 데 있었습니다.

    클립 audio 형식 길이
    clip_01 mono, 1ch 5.71s
    clip_02 stereo, 2ch 5.19s
    clip_03 stereo, 2ch 4.92s
    ... ... ...

    AI 모델이 클립별로 어떤 모드(silent + post-mux vs lip-sync joint generation)로 생성됐는지에 따라 mono / stereo가 달라지고, audio·video duration도 0.5초까지 어긋납니다. -c copy 모드의 concat demuxer는 첫 클립의 audio spec을 컨테이너에 stream parameter로 박아 버려서, 그 다음 클립들의 다른 spec audio가 잘못 디코딩됩니다.

    "그럼 audio를 stereo로 통일해서 인코딩하면 되겠네?"라며 1-step에 flag만 추가한 시도도 실패했습니다.

    ffmpeg -f concat -safe 0 -i clips.txt \
           -c copy -c:a aac -ac 2 -ar 44100 output.mp4
    

    이건 더 끔찍한 결과를 만들었습니다. concat demuxer가 모든 클립을 첫 클립의 spec(mono) 으로 PCM 디코딩한 다음, 그 mono PCM을 stereo로 인코딩한 거죠. 결과는 전체 클립에서 노이즈만 들리는 영상이 됐습니다.

    해결 — 2-step normalize → copy

    진짜 해결은 두 단계로 나누는 거였습니다.

    diagram

    다이어그램이 보여주는 흐름이 핵심입니다. Step 1은 각 클립을 통일된 spec으로 normalize(audio를 stereo aac로 재인코딩, video는 copy로 보존)하고, Step 2는 통일된 클립들을 단순 -c copy로 concat 합니다. 두 단계의 분리가 중요합니다 — Step 2의 demuxer는 이제 모든 입력이 같은 spec이라는 사실을 보장받고 동작합니다.

    # Step 1: 각 클립 normalize
    for clip in clips:
        video_dur = _ffprobe_duration(clip.path, stream="v")
        out = clip.path.with_suffix(".norm.mp4")
        subprocess.run([
            "ffmpeg", "-y", "-i", str(clip.path),
            "-c:v", "copy",                                # video 보존
            "-c:a", "aac", "-ac", "2", "-ar", "44100",     # audio stereo로 통일
            "-af", f"apad,atrim=0:{video_dur:.3f}",        # audio를 video 길이로 정확 매치
            str(out),
        ], check=True)
    
    # Step 2: 통일된 클립들 단순 concat
    with open("clips.txt", "w") as f:
        for clip in clips:
            f.write(f"file '{clip.path.with_suffix('.norm.mp4').name}'\n")
    
    subprocess.run([
        "ffmpeg", "-f", "concat", "-safe", "0",
        "-i", "clips.txt",
        "-c", "copy",                                       # 동일 spec이므로 copy로 충분
        "concat.mp4",
    ])
    

    여기서 가장 미묘한 한 줄은 -af "apad,atrim=0:{video_dur:.3f}"입니다. audio를 video 길이에 정확히 맞춥니다. apad는 audio 끝에 silence를 무한히 padding하고, atrim=0:<v_dur>는 그걸 video duration까지 잘라냅니다. 결과적으로 audio가 짧으면 silence padding, 길면 잘려서 video duration과 정확히 같아집니다.

    여기서도 apad만 쓰고 -shortest로 끝낼 수는 없습니다. apad + -shortest + -c:v copy 조합은 ffmpeg가 video duration을 제대로 인식하지 못해 무한히 audio를 생성하다 timeout이 납니다. 명시적 duration 지정 = atrim이 답입니다.

    본문에 등장한 ffmpeg/ffprobe 아규먼트 풀이

    이 글에 등장한 모든 명령어 아규먼트를 하나씩 풀어봅니다. ffmpeg는 옵션이 <카테고리>:<stream specifier> 형태로 자주 쓰이는데, 콜론 앞은 옵션 카테고리(c=codec, b=bitrate, ac=audio channels …), 콜론 뒤는 어느 stream에 적용할지(v=video, a=audio)를 가리킵니다. 콜론 뒤가 없으면 모든 stream에 적용.

    도구 자체

    명령어 풀이
    ffmpeg 영상·음성 멀티미디어 파일을 디코딩·필터링·인코딩하는 핵심 CLI 도구
    ffprobe ffmpeg와 같은 라이브러리를 쓰는 읽기 전용 도구. 파일의 메타데이터(duration, codec, stream 등)만 출력

    모든 ffmpeg 호출에 공통

    아규먼트 풀이
    -y output 파일이 이미 있어도 "덮어쓰기" 자동 확인(yes). 스크립트에서 멈추지 않도록

    입력/스트림 매핑 관련

    아규먼트 풀이
    -i video.mp4 input 파일 1번째 (인덱스 0)
    -i reaction.mp3 input 파일 2번째 (인덱스 1). 여러 -i를 쓰면 0,1,2 순서로 인덱싱
    -map 0:v output에 포함할 스트림 지정. 0:v = "0번 input의 video 스트림". -map 없으면 ffmpeg가 default 추론(input마다 video/audio 1개씩)
    -map 1:a "1번 input의 audio 스트림"을 output에 포함
    -f concat input 파일을 어떤 format으로 해석할지 강제 지정 (자세한 설명 아래)
    -safe 0 concat list 안의 파일 경로 안전 검사를 끔 (자세한 설명 아래)

    -f concat 이 왜 필요한가

    보통 ffmpeg는 input 파일이 어떤 format인지 자동 감지합니다. 두 가지 단서를 봅니다.

    1. 파일 확장자: video.mp4 → mp4, audio.mp3 → mp3.
    2. 파일 안의 magic bytes: 파일 첫 몇 바이트의 시그니처(예: mp4는 ftyp, png는 \x89PNG). 확장자 없거나 잘못 붙어도 시그니처로 추론.

    그런데 우리가 concat에 쓰는 list 파일은 이런 모양입니다.

    file 'clip_01.mp4'
    file 'clip_02.mp4'
    file 'clip_03.mp4'
    

    이건 그냥 평범한 텍스트 파일입니다. 확장자가 .txt이거나 아예 없을 수도 있고, 안에는 magic bytes도 없습니다. ffmpeg가 이걸 받으면 "어떤 멀티미디어 format인지 모르겠다"며 실패합니다.

    그래서 우리가 직접 알려줘야 합니다. -f concat 은 "이 input은 평문 텍스트지만 concat demuxer 라는 가상 format으로 해석해라" 라는 명시 지시입니다. 그러면 ffmpeg가 내장된 concat demuxer를 사용해서 텍스트 줄들을 파싱하고, 각 줄의 file '...' 경로를 차례로 읽어 마치 한 파일처럼 처리합니다.

    요약: concat list는 자동 감지가 안 되는 특수 format이라 -f로 명시해야 합니다. 다른 format(예: mp4 input)에는 안 필요합니다 — 확장자와 시그니처로 자동 감지되니까.

    -safe 0 이 왜 필요한가

    concat demuxer가 list 파일을 읽을 때, ffmpeg는 default로 보안 검사를 켜둡니다. list 안의 파일 경로가 다음 중 하나면 거절합니다.

    • 절대 경로 (/home/user/clip.mp4)
    • 상위 디렉토리 참조 (../parent/clip.mp4)
    • URL (http://..., file://...)

    이유는 보안입니다. 만약 누군가 악의적인 list 파일을 만들어 ffmpeg에게 주면, list 안에 /etc/passwd 같은 시스템 파일이나 외부 URL을 적어 ffmpeg가 그걸 읽도록 유도할 수 있습니다. 그래서 default safe mode는 "list와 같은 디렉토리에 있는, 평범한 상대 경로 파일만 허용"으로 엄격하게 잠가둡니다.

    우리 use case에선 list 파일을 우리 스크립트가 직접 생성합니다. 안에 들어가는 경로도 우리가 통제한 mp4 클립 절대 경로이고, 악의적 input이 들어올 여지가 없습니다. 그래서 -safe 0 으로 검사를 꺼야 절대 경로 사용이 허용됩니다. 안 끄면 [concat] Unsafe file name ... 에러로 실패합니다.

    요약: -safe 0 = "이 list는 내가 직접 만든 거니까 절대 경로·상위 참조 다 OK" 라고 ffmpeg에게 명시. 외부에서 받은 list 파일을 처리할 때는 절대 끄지 말 것 — 보안 구멍.

    코덱 (-c: 시리즈)

    -c 는 codec의 약어. 콜론 뒤에 어느 stream인지 붙입니다.

    아규먼트 풀이
    -c copy 모든 스트림(video·audio·subtitle)을 재인코딩하지 않고 그대로 복사. 빠르고 무손실, 대신 input 간 spec 차이 그대로 둠
    -c:v copy "video stream만 copy". audio는 다른 설정으로 처리할 때
    -c:a copy "audio stream만 copy" (이 글엔 안 쓰임)
    -c:a aac "audio를 AAC 코덱으로 재인코딩". AAC는 mp4 호환성이 가장 좋은 audio codec

    오디오 형식 통일 옵션

    re-encode할 때 함께 명시해서 모든 클립의 audio를 동일 spec으로 맞춥니다.

    아규먼트 풀이
    -ac 2 audio channels = 2 (stereo). 1이면 mono. audio channels의 약어
    -ar 44100 audio sample rate = 44100 Hz (CD 품질). 영상에 표준적으로 쓰이는 값

    출력 길이 제어

    아규먼트 풀이
    -shortest "가장 짧은 stream에 맞춰" output을 잘라냄. 직관적 이름이지만 video까지 잘릴 수 있어 함정
    -t {video_dur:.3f} output total duration을 명시한 초 단위 값으로 고정. {video_dur:.3f}는 파이썬 f-string으로 소수점 3자리(0.001초 정확도) 포맷팅. 이 글의 답은 "video duration이 진실"이라 이 값으로 강제

    오디오 필터 (-af)

    -afaudio filter의 약어. 콤마(,)로 필터를 chain합니다.

    아규먼트 풀이
    -af apad audio 끝에 silence를 무한히 padding하는 필터
    -af "apad,atrim=0:{video_dur:.3f}" apad로 무한 silence padding → atrim=0:<v_dur>로 0초부터 v_dur초까지만 자르기. 결과: audio가 정확히 video 길이가 됨 (짧으면 silence padding, 길면 cut)
    atrim=0:N audio trim 필터. start:end 형식이라 0:5.71이면 0초~5.71초 구간만 남김

    ffprobe 읽기 옵션

    아규먼트 풀이
    -v error log verbosity를 error 레벨로(경고·정보 출력 억제). 스크립트에서 stdout을 깨끗하게 받기 위함
    -show_entries format=duration 출력할 메타데이터 엔트리 선택. format=duration이면 "파일 format 섹션의 duration 필드"만
    -of default=nw=1:nk=1 output format 지정. default는 plain text, nw=1(no wrapper, 헤더 제거), nk=1(no key, key 이름 제거) → duration 값 한 줄만 출력. 파이썬 float()로 바로 파싱 가능

    concat list 파일 안의 문법

    file 'clip_01.mp4'
    file 'clip_02.mp4'
    
    토큰 풀이
    file concat demuxer의 directive. "이 줄은 input 파일 경로다" 의미
    '...' 경로를 작은따옴표로 감싸기 — 공백·특수문자 안전. 경로 안에 '이 있으면 '\''로 이스케이프

    이 표가 있으면 "왜 이 옵션이 거기 있는지"를 항상 본문과 매칭해서 읽을 수 있습니다. 처음 ffmpeg를 접하면 -c:v copy만 봐도 어디서 끊어 읽어야 할지 모호한데, 카테고리(c) + stream specifier(v) + value(copy)의 3-part 분해를 한 번 익히면 그 후 새 옵션이 나와도 같은 방식으로 해석됩니다.

    두 함정의 공통 뿌리

    두 사례에서 같은 패턴이 보입니다.

    • mux에서 -shortest가 잘못 동작한 건 "어느 stream이 진실인지" 명시하지 않아서.
    • concat에서 -c copy가 깨진 것도 "모든 클립의 spec이 다르다"는 사실을 ffmpeg에게 알리지 않아서.

    두 함정 모두 ffmpeg가 직관적으로 보이는 default 동작을 골랐고, 그게 우리 use case에 맞지 않은 경우입니다. 해법도 같은 방향입니다 — video duration을 진실의 원천으로 명시하고, audio는 그에 맞춰 보정(padding·trim)하기.

    합본 후 결과의 duration을 input duration 합과 비교하면 drift가 누적됐는지 빠르게 확인할 수 있습니다. 11클립 합본에서 input 합이 113.31초인데 결과가 112.98초였다면, 그 사이에서 약 0.33초의 drift가 정정됐다는 신호입니다. drift가 잡혔다는 건 normalize가 제대로 작동했다는 뜻이죠.

    결과 — production-grade pipeline

    이 2-step normalize 패턴을 default로 잡은 뒤로 합본은 한 번에 깨끗하게 됩니다.

    • 11클립이 시간 손실 없이 합쳐집니다. drift가 누적되지 않고 sync가 보존됩니다.
    • audio re-encode 비용은 클립당 0.5~1초 정도입니다. 11개 합쳐도 10초 안에 끝나는 normalize 단계라 시간 비용이 작습니다.
    • 새 클립이 추가돼도 같은 코드 경로입니다. lip-sync로 만든 stereo 클립이든, silent + post-mux로 만든 mono 클립이든, normalize 단계에서 통일됩니다.

    마지막으로 한 줄 정리. ffmpeg flag의 의미를 추측하지 말고 실측해야 합니다. -shortest는 직관적인 이름이지만 use case에 따라 destructive하고, apad + -shortest + -c:v copy 조합은 직관적으로 동작할 것 같지만 무한 loop으로 빠집니다. 멀티 stream 작업에서는 "어느 stream이 진실의 원천인가"를 매번 처음에 결정하고, 그걸 코드에 명시적으로 박아둬야 합니다. 그 한 줄 결정이 그 다음 모든 처리의 안정성을 결정합니다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.