포스트

한 번에 끝내는 Video Understanding/Generation: “프레임 분석 파이프라인” 설계 패턴

비디오 AI를 프로젝트에 붙일 때 제일 먼저 부딪히는 문제는 “모델이 똑똑하냐”가 아니라 입력 비디오를 어떤 단위(shot/clip/frame)로 쪼개고, 무엇을 저장하며, 어떻게 재질의(re-query)할지입니다.

한 번에 끝내는 Video Understanding/Generation: “프레임 분석 파이프라인” 설계 패턴

들어가며

비디오 AI를 프로젝트에 붙일 때 제일 먼저 부딪히는 문제는 “모델이 똑똑하냐”가 아니라 입력 비디오를 어떤 단위(shot/clip/frame)로 쪼개고, 무엇을 저장하며, 어떻게 재질의(re-query)할지입니다. 긴 비디오에서 uniform sampling만으로는 중요한 구간을 놓치고(정보 손실), 반대로 프레임을 많이 넣으면 컨텍스트/비용이 터집니다. 이 때문에 2026년에는 “video LLM” 자체보다 프레임 선택 + 타임스탬프 기반 증거(evidence) 구성 + temporal grounding이 핵심 기술로 굳어지는 흐름입니다. (예: long video temporal reasoning/grounding 계열)1

언제 쓰면 좋나:

  • 긴 비디오(수십 분~수시간) QA/검색/요약, 공정/제조/교육 영상처럼 “특정 순간을 정확히 찾아야” 하는 도메인(시간 축이 본질)1
  • 감사/컴플라이언스처럼 “모델 답변”보다 근거 구간(타임스탬프) 제시가 중요한 제품2

언제 쓰면 안 되나:

  • 10~30초 짧은 클립만 다루고, 지연/비용이 매우 민감하며, “대충” 이해해도 되는 경우: 과한 파이프라인은 운영 복잡도만 올립니다.
  • 생성(T2V/I2V/V2V)에서 “품질”이 절대 기준인데, 인프라/비용 제약이 심한 경우: 최신 연구는 consistency를 위해 메모리/계층 latent/멀티샷 구조 등을 쓰며 계산량이 커지기 쉽습니다.3

🔧 핵심 개념

1) “프레임”이 아니라 Shot/Clip/Evidence로 다루는 이유

2026년 long-video 쪽은 공통적으로:

  • 비디오를 짧은 clip으로 나눠 로컬 특징을 만들고
  • 이를 겹치는 윈도우/전역 집계로 long-range를 만든 뒤
  • 최종적으로 LLM이 답을 만들되 시간 축을 명시적으로 취급합니다. (Temporal reasoning/long video 모델 설계)1

여기서 실무 핵심은 “모델 입력 토큰”이 아니라 증거 단위(evidence unit) 입니다.

  • evidence = {shot_id, start/end_ts, keyframes, transcript span, extracted objects/actions, embeddings}
  • 질의는 evidence를 먼저 좁히고 → 좁혀진 구간만 고해상도로 다시 분석합니다.

2) Temporal Grounding: “정답”보다 정답 구간이 제품을 살린다

Video Temporal Grounding(VTG)은 “이 질문의 답이 나오는 시간이 언제냐”를 맞히는 문제고, 멀티모달 LLM이 이를 강하게 밀고 있습니다.2
실전에서 VTG를 넣으면 좋은 점:

  • hallucination이 나와도 근거 구간을 함께 리턴하게 강제 가능
  • 사용자 경험: “요약”보다 “클릭하면 그 장면으로 점프”가 훨씬 강함

구현 패턴: 1) 1차 패스: 저비용으로 shot boundary + 거친 keyframe + ASR 2) 2차 패스: 질문 기반 retrieval(embedding)로 후보 shot top-k 3) 3차 패스: 후보 shot만 고밀도 프레임/객체/행동 추출 + LLM 답변(+timestamp)

3) Frame Selection: uniform sampling의 시대는 끝

오픈소스 쪽에서도 scene-change-aware sampling(ffmpeg scdet 등)로 shot을 자르고, 그 안에서만 프레임을 뽑아 구조화 JSON을 만드는 패턴이 보입니다.4
또한 “학습 없이(inference-only) long video LVLM 입력을 최적화”하는 frame selection 프레임워크도 2026 CVPR/ECCV 라인에서 강하게 나옵니다.5

차이점 정리:

  • Uniform: 구현 쉬움 / 중요한 순간 누락 / 토큰 낭비
  • Scene boundary 기반: 정보 밀도 ↑ / 파이프라인 복잡도 ↑ / shot 내부 이벤트(짧은 동작) 놓칠 수 있음
  • Query-aware(tool-augmented, 재샘플링): 비용 제어가 가장 좋음 / 시스템 설계 난이도 ↑6

4) Generation(생성) 쪽: 2026년 키워드는 Consistency를 “구조적으로” 해결

비디오 생성은 이제 화질보다 semantic/identity/camera/multi-shot consistency가 병목이고, 이를 시스템적으로 분해(divide-and-conquer)하거나, 멀티샷 오토리그레시브/캐시/계층 latent로 푸는 접근이 활발합니다.7
편집(V2V)에서는 “멀티턴 편집에서 턴이 바뀔 때마다 일관성이 무너지는” cross-turn consistency를 메모리로 잡는 방향이 등장합니다.3

실무 관점 결론:

  • 생성 모델을 “그냥 호출”하면 일관성 요구사항에서 금방 깨집니다.
  • 이해(grounding) 파이프라인을 같이 깔아 “무엇이 유지돼야 하는지(캐릭터/배경/카메라)”를 구조화해두는 팀이 결국 이깁니다.

💻 실전 코드

아래는 “긴 비디오에서 질문 기반으로 근거 구간을 찾아 답변”하는 현실적인 파이프라인 스켈레톤입니다.

  • 전제: 로컬에서 비디오 파일을 처리
  • 구성: 1) ffmpeg로 shot boundary 탐지 2) shot마다 keyframe N장 추출 3) whisper로 ASR(타임스탬프 포함) → shot과 align 4) (선택) OpenAI-compatible vision model로 keyframe 캡션/객체를 JSON evidence로 저장 5) 질의 시 evidence embedding으로 top-k shot을 뽑고, 그 구간만 재분석

0) 의존성/설치

1
2
3
4
5
6
# macOS 예시
brew install ffmpeg

python -m venv .venv
source .venv/bin/activate
pip install -U faster-whisper opencv-python numpy pydantic rich faiss-cpu

1) Shot boundary + keyframe 추출 (scene-change-aware)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# video_index.py
import json, os, subprocess, math
from dataclasses import dataclass, asdict
from typing import List, Tuple

@dataclass
class Shot:
    shot_id: int
    start: float
    end: float
    keyframes: List[str]

def run(cmd: List[str]) -> str:
    p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=True)
    return p.stdout

def detect_shots(video_path: str, threshold: float = 0.35) -> List[Tuple[float, float]]:
    """
    ffmpeg scdet 기반. 출력 로그에서 pts_time 파싱 후 shot 구간 생성.
    """
    cmd = [
        "ffmpeg", "-hide_banner", "-i", video_path,
        "-vf", f"select='gt(scene,{threshold})',showinfo",
        "-an", "-f", "null", "-"
    ]
    out = run(cmd)
    times = []
    for line in out.splitlines():
        if "pts_time:" in line:
            # ... pts_time:12.345 ...
            t = float(line.split("pts_time:")[1].split()[0])
            times.append(t)
    # shot boundary = [0] + times + [duration]
    dur = probe_duration(video_path)
    boundaries = [0.0] + sorted(set([t for t in times if 0 < t < dur])) + [dur]
    shots = [(boundaries[i], boundaries[i+1]) for i in range(len(boundaries)-1) if boundaries[i+1] - boundaries[i] > 0.5]
    return shots

def probe_duration(video_path: str) -> float:
    cmd = [
        "ffprobe", "-v", "error", "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1", video_path
    ]
    out = run(cmd).strip()
    return float(out)

def extract_keyframes(video_path: str, start: float, end: float, out_dir: str, shot_id: int, fps: float = 0.5) -> List[str]:
    os.makedirs(out_dir, exist_ok=True)
    # shot 구간만 잘라서 저fps로 프레임 추출 (키프레임 "근사"용)
    pattern = os.path.join(out_dir, f"shot{shot_id:04d}_%03d.jpg")
    cmd = [
        "ffmpeg", "-hide_banner", "-y",
        "-ss", str(start), "-to", str(end), "-i", video_path,
        "-vf", f"fps={fps},scale=640:-1",
        pattern
    ]
    run(cmd)
    # 생성 파일 목록
    files = sorted([os.path.join(out_dir, f) for f in os.listdir(out_dir) if f.startswith(f"shot{shot_id:04d}_")])
    return files

def build_index(video_path: str, out_dir: str = "index_out") -> List[Shot]:
    shots = detect_shots(video_path)
    shot_objs: List[Shot] = []
    frames_dir = os.path.join(out_dir, "frames")
    os.makedirs(out_dir, exist_ok=True)

    for i, (s, e) in enumerate(shots):
        kfs = extract_keyframes(video_path, s, e, frames_dir, i, fps=0.5)
        shot_objs.append(Shot(shot_id=i, start=s, end=e, keyframes=kfs))

    with open(os.path.join(out_dir, "shots.json"), "w", encoding="utf-8") as f:
        json.dump([asdict(x) for x in shot_objs], f, ensure_ascii=False, indent=2)
    return shot_objs

if __name__ == "__main__":
    import sys
    video_path = sys.argv[1]
    build_index(video_path)
    print("Wrote: index_out/shots.json")

예상 출력:

  • index_out/shots.json에 shot별 start/end
  • index_out/frames/shot0000_001.jpg ... 식으로 keyframe 이미지들

2) ASR + shot 정렬(타임스탬프 증거 만들기)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# asr_align.py
import json, os
from dataclasses import dataclass, asdict
from typing import List, Dict, Any
from faster_whisper import WhisperModel

@dataclass
class Utterance:
    start: float
    end: float
    text: str

def load_shots(path="index_out/shots.json") -> List[Dict[str, Any]]:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

def transcribe(video_path: str, model_size="small") -> List[Utterance]:
    model = WhisperModel(model_size, device="cpu", compute_type="int8")
    segments, _info = model.transcribe(video_path, vad_filter=True)
    uts = []
    for s in segments:
        uts.append(Utterance(start=float(s.start), end=float(s.end), text=s.text.strip()))
    return uts

def align_utterances_to_shots(shots, uts: List[Utterance]):
    # 단순 overlap 기준 (실무에선 더 정교하게)
    for sh in shots:
        s, e = sh["start"], sh["end"]
        sh["asr"] = [asdict(u) for u in uts if not (u.end < s or u.start > e)]
    return shots

if __name__ == "__main__":
    import sys
    video_path = sys.argv[1]
    shots = load_shots()
    uts = transcribe(video_path, model_size="small")
    shots = align_utterances_to_shots(shots, uts)

    os.makedirs("index_out", exist_ok=True)
    with open("index_out/evidence.json", "w", encoding="utf-8") as f:
        json.dump(shots, f, ensure_ascii=False, indent=2)
    print("Wrote: index_out/evidence.json")

3) 질의: evidence를 벡터 검색으로 좁히고(top-k), 그 구간만 고비용 분석

여기서는 “질의→후보 shot 리스트”까지만 구현합니다(실무에서는 이 top-k에 대해 vision model caption/objects/grounding을 추가 호출).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# query.py
import json
import numpy as np
import faiss

def load_evidence(path="index_out/evidence.json"):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

def shot_text(shot):
    asr = " ".join([u["text"] for u in shot.get("asr", [])])
    # keyframe 경로 자체도 힌트가 될 수 있어 함께 보관(실전에서는 frame caption을 넣는 게 핵심)
    return f"[{shot['start']:.2f}-{shot['end']:.2f}] {asr}"

# 데모용: 임베딩은 실제론 text embedding 모델로 교체.
# 여기선 해시 기반 pseudo-embedding(실행가능하지만 성능 목적 X)
def embed(text: str, dim=384):
    v = np.zeros(dim, dtype=np.float32)
    for w in text.lower().split():
        v[hash(w) % dim] += 1.0
    n = np.linalg.norm(v) + 1e-8
    return v / n

def build_faiss(shots):
    X = np.stack([embed(shot_text(s)) for s in shots])
    index = faiss.IndexFlatIP(X.shape[1])
    index.add(X)
    return index, X

def search(shots, index, query: str, topk=5):
    q = embed(query)[None, :]
    scores, ids = index.search(q, topk)
    out = []
    for score, i in zip(scores[0], ids[0]):
        sh = shots[int(i)]
        out.append({
            "shot_id": sh["shot_id"],
            "start": sh["start"],
            "end": sh["end"],
            "score": float(score),
            "preview": shot_text(sh)[:200]
        })
    return out

if __name__ == "__main__":
    shots = load_evidence()
    index, _X = build_faiss(shots)

    q = "안전장비를 착용하지 않은 순간이 언제 나와?"
    results = search(shots, index, q, topk=5)
    print(json.dumps(results, ensure_ascii=False, indent=2))

여기까지의 “현실성” 포인트:

  • shot 단위 저장(나중에 timestamp jump/UI와 바로 연결)
  • 비용은 “top-k shot 재분석”으로 제어
  • 다음 단계로 Video Temporal Grounding 모델(또는 MLLM 기반 timestamp 출력)을 붙이면 제품이 됩니다. (VTG 흐름 자체가 2026년 핵심 축)2

⚡ 실전 팁 & 함정

Best Practice (2~3개)

1) Evidence JSON 스키마를 먼저 고정

  • {start/end, transcript span, keyframes, (optional) objects/actions, embeddings, provenance}를 “DB row”처럼 설계하세요.
  • 이게 있어야 모델을 바꿔도 파이프라인이 안 무너집니다.

2) 2-pass가 기본: cheap pass → expensive pass

  • cheap: scdet + low-fps frames + ASR
  • expensive: top-k 구간만 high-fps + detection/segmentation + caption + grounding
  • Tool-augmented 방식(필요할 때만 프레임을 더 샘플링)이 long video에서 특히 강합니다.6

3) Temporal Grounding을 “출력 계약(Output Contract)”으로

  • 답변 텍스트만 반환하지 말고, 항상 (start,end)와 근거 프레임/자막 span을 같이 반환하게 하세요.
  • VTG/TimeLens 계열이 말하는 방향은 결국 “시간을 맞히는 능력”이 장기적으로 중요하다는 겁니다.8

흔한 함정/안티패턴

  • uniform sampling 고정: 도메인에 따라 “중요 이벤트는 짧고 희소”합니다. 특히 제조/감시/스포츠는 치명적.
  • 프레임만 저장하고 transcript/타임스탬프 provenance를 안 남김: 나중에 디버깅/재현 불가.
  • 생성 모델을 먼저 붙이는 것: 편집/생성에서 consistency 이슈가 커서(멀티샷/멀티턴) 결국 이해/메모리 구조가 필요해집니다.3

비용/성능/안정성 트레이드오프

  • scdet threshold를 낮추면 shot이 잘게 쪼개져 recall은 좋아지지만 인덱싱/저장/후속 분석 비용이 증가
  • ASR 품질이 낮으면 retrieval이 흔들려서 top-k가 망가짐 → “텍스트 기반 retrieval”에 과도하게 의존하면 위험
  • 생성 파이프라인에서 consistency를 잡으려면(메모리/계층 latent/멀티샷) 대체로 VRAM과 latency를 먹습니다.9

🚀 마무리

2026년 8월 기준으로 video AI의 실전 승부처는 “모델 이름”이 아니라 프레임 분석 파이프라인입니다. 긴 비디오에서 성능/비용/신뢰성을 동시에 잡으려면:

  • (1) shot/clip 기반 evidence를 만들고
  • (2) query-aware로 top-k 구간만 고비용 분석하며
  • (3) 답변에는 timestamp grounding을 기본 포함시키는 설계 이 3가지를 먼저 고정하는 게 가장 ROI가 큽니다.1

도입 판단 기준(체크리스트):

  • “유저가 답을 믿으려면 근거 구간이 필요하다” → temporal grounding 필수
  • “비디오가 길고 이벤트가 희소하다” → scene/semantic boundary 기반 frame selection + 2-pass 필수5
  • “생성/편집에서 동일 인물/배경/카메라를 유지해야 한다” → consistency를 구조적으로 다루는 접근(메모리/멀티샷/분해형)이 필요3

다음 학습 추천:

  • Video Temporal Grounding/TimeLens류 논문 흐름으로 “timestamp 출력”을 제품 요구사항으로 바꾸는 연습8
  • long video reasoning에서 tool-augmented(필요 시 재샘플링) 패턴을 시스템으로 구현6
  • 생성은 consistency 논문(멀티샷/멀티턴 편집/계층 latent) 위주로 보는 게 실무에 바로 도움3
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.