Workshop

在 5 年前 MacBook 上本地索引一年视频:Gemma 4 31B + WhisperX + ffmpeg 全流程

4 min read ·

💡 一句话总结:本地视频索引不是 SaaS 的廉价替代品,而是隐私、成本、可控性全面碾压的新默认——前提是你有一台 64GB 内存的 Mac 或一张 24GB 显存的 GPU。

灵感来源:HN 270 分爆款

5 月 21 日 Hacker News 上一篇 Indexing a year of video locally on a 2021 MacBook with Gemma4-31B 拿到 270 分,作者用 2021 年的 16-inch M1 Max MBP(64GB 内存)跑通了一整年视频素材的本地索引流程。

核心数字:

这个流程的可贵在于:它不是单纯的 demo,是真的在 5 年前的硬件上端到端跑通了几千段视频。本文把这套 pipeline 用代码完整还原,并标出可以替换/优化的环节。

Pipeline 全景图

                ┌──────────────────────┐
   video.mp4 →  │ Stage 1: 元数据提取   │  → ffprobe + exiftool
                │  - GPS / EXIF        │
                │  - duration / codec  │
                └─────────┬────────────┘


                ┌──────────────────────┐
                │ Stage 2: 地理编码     │  → Nominatim (免费)
                │  GPS → "上海陆家嘴"   │
                └─────────┬────────────┘


                ┌──────────────────────┐
                │ Stage 3: 帧采样       │  → ffmpeg 5 frames @ 1920px
                └─────────┬────────────┘


              ┌───────────┴────────────┐
              │                        │
              ▼                        ▼
   ┌──────────────────┐    ┌──────────────────┐
   │ Stage 4: 转写     │    │ Stage 5: 人脸     │
   │  WhisperX        │    │  insightface     │
   │  + diarization   │    │  + 512d embed    │
   └─────────┬────────┘    └─────────┬────────┘
             │                        │
             └───────────┬────────────┘


              ┌──────────────────────┐
              │ Stage 6: VLM 分析     │  → Gemma 4 31B Q4
              │  structured YAML +   │     via LM Studio
              │  prose description   │
              └──────────┬───────────┘


              ┌──────────────────────┐
              │ video.description.md  │
              │  (sidecar file)      │
              └──────────────────────┘

每个 stage 都可以独立跑、并行跑、断点恢复。

Stage 1: 元数据提取

import subprocess, json

def extract_metadata(video_path: str) -> dict:
    # ffprobe 拿 codec/duration/resolution
    cmd = [
        "ffprobe", "-v", "quiet",
        "-print_format", "json",
        "-show_format", "-show_streams",
        video_path,
    ]
    probe = json.loads(subprocess.check_output(cmd))

    # exiftool 拿 GPS、camera model、拍摄时间
    cmd2 = ["exiftool", "-json", "-c", "%.6f", video_path]
    exif = json.loads(subprocess.check_output(cmd2))[0]

    return {
        "duration": float(probe["format"]["duration"]),
        "codec": probe["streams"][0]["codec_name"],
        "resolution": f"{probe['streams'][0]['width']}x{probe['streams'][0]['height']}",
        "gps": (exif.get("GPSLatitude"), exif.get("GPSLongitude")),
        "captured_at": exif.get("CreateDate"),
        "camera": exif.get("Model"),
    }

500 段视频跑完 Stage 1 约 90 秒。

Stage 2: GPS 反向地理编码

import requests, time

def reverse_geocode(lat: float, lng: float) -> dict:
    if not (lat and lng):
        return {"location": None}
    
    url = "https://nominatim.openstreetmap.org/reverse"
    params = {"lat": lat, "lon": lng, "format": "json", "zoom": 16}
    headers = {"User-Agent": "local-video-indexer/1.0"}
    
    resp = requests.get(url, params=params, headers=headers, timeout=10)
    time.sleep(1.1)  # Nominatim 公共服务限速 1 QPS
    
    if resp.status_code != 200:
        return {"location": None}
    
    data = resp.json()
    return {
        "location": data.get("display_name"),
        "country": data["address"].get("country"),
        "city": data["address"].get("city"),
    }

注意 Nominatim 是公共免费服务,限速 1 QPS。如果你有几万段视频,建议自建(Docker mediagis/nominatim 镜像,单机本地解析数十万次/秒)。

Stage 3: ffmpeg 帧采样

import subprocess
from pathlib import Path

def sample_frames(video_path: str, out_dir: str, n: int = 5, width: int = 1920) -> list[str]:
    Path(out_dir).mkdir(parents=True, exist_ok=True)
    duration = float(subprocess.check_output(
        ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
         "-of", "csv=p=0", video_path]
    ))
    
    timestamps = [duration * i / (n + 1) for i in range(1, n + 1)]
    frame_paths = []
    
    for i, ts in enumerate(timestamps):
        out_path = f"{out_dir}/frame_{i:02d}.jpg"
        subprocess.run([
            "ffmpeg", "-y", "-loglevel", "quiet",
            "-ss", str(ts), "-i", video_path,
            "-vframes", "1",
            "-vf", f"scale={width}:-1",
            "-q:v", "3",
            out_path,
        ], check=True)
        frame_paths.append(out_path)
    
    return frame_paths

为什么是 5 帧 + 1920px?

500 段视频共 2500 帧,M1 Max 约 8 分钟。

Stage 4: WhisperX 多语种转写 + 说话人分离

pip install whisperx pyannote-audio
# 需要 HuggingFace token 下载 pyannote 模型
import whisperx, gc, torch

device = "mps" if torch.backends.mps.is_available() else "cuda"
batch_size = 16
compute_type = "float16"

def transcribe_with_diarization(audio_path: str, hf_token: str) -> dict:
    # 1. 转写
    model = whisperx.load_model("large-v3", device, compute_type=compute_type)
    audio = whisperx.load_audio(audio_path)
    result = model.transcribe(audio, batch_size=batch_size)
    detected_lang = result["language"]
    del model; gc.collect()
    
    # 2. 词级对齐
    align_model, metadata = whisperx.load_align_model(
        language_code=detected_lang, device=device,
    )
    result = whisperx.align(
        result["segments"], align_model, metadata, audio, device,
    )
    del align_model; gc.collect()
    
    # 3. 说话人分离
    diarize_model = whisperx.DiarizationPipeline(use_auth_token=hf_token, device=device)
    diarize_segments = diarize_model(audio)
    result = whisperx.assign_word_speakers(diarize_segments, result)
    
    return {
        "language": detected_lang,
        "segments": [
            {
                "speaker": seg.get("speaker", "UNKNOWN"),
                "start": seg["start"], "end": seg["end"],
                "text": seg["text"],
            }
            for seg in result["segments"]
        ],
    }

实测 1 小时音频在 M1 Max 上:

对比原版 OpenAI Whisper:约 25 分钟,且不带说话人分离。

Stage 5: 人脸 embedding

from insightface.app import FaceAnalysis
import cv2, numpy as np

app = FaceAnalysis(name="buffalo_l", providers=["CoreMLExecutionProvider"])
app.prepare(ctx_id=0, det_size=(640, 640))

def extract_faces(frame_paths: list[str]) -> list[dict]:
    all_faces = []
    for path in frame_paths:
        img = cv2.imread(path)
        faces = app.get(img)
        for face in faces:
            all_faces.append({
                "frame": path,
                "bbox": face.bbox.tolist(),
                "embedding": face.normed_embedding.tolist(),  # 512d
                "age": int(face.age) if hasattr(face, "age") else None,
                "gender": "M" if face.gender == 1 else "F",
            })
    return all_faces

输出的 512 维 embedding 可以塞进 Qdrant 做跨视频人脸搜索:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

client = QdrantClient(":memory:")
client.create_collection(
    collection_name="faces",
    vectors_config=VectorParams(size=512, distance=Distance.COSINE),
)

points = [
    PointStruct(id=i, vector=face["embedding"], payload=face)
    for i, face in enumerate(all_faces)
]
client.upsert(collection_name="faces", points=points)

# 查询:找出所有出现过这张脸的视频
query_face = extract_faces(["query_photo.jpg"])[0]
results = client.search(
    collection_name="faces",
    query_vector=query_face["embedding"],
    limit=20,
)

Stage 6: Gemma 4 31B 结构化分析

LM Studio 是最方便的本地 LLM 服务器。下载 gemma-4-31b-it-Q4_K_M.gguf,启动 server 后暴露 OpenAI 兼容的 REST API on 127.0.0.1:1234

import openai, base64, yaml
from pathlib import Path

client = openai.OpenAI(base_url="http://127.0.0.1:1234/v1", api_key="not-needed")

SCHEMA_PROMPT = """You are analyzing 5 sampled frames from a video clip.

Output a YAML block with EXACTLY these fields, no extra:

```yaml
lighting: <one of: golden_hour | daytime | overcast | dusk | nighttime | indoor_artificial | mixed>
time_of_day: <one of: morning | afternoon | evening | night | unknown>
color_palette: <2-4 dominant colors as plain words>
weather: <one of: sunny | cloudy | rainy | snowy | indoor | unknown>
mood: <one of: cheerful | calm | tense | sad | energetic | neutral>
scene_type: <one of: outdoor_nature | outdoor_urban | indoor_home | indoor_public | vehicle | screen_recording | other>
subjects: <comma-separated list of main subjects, max 5>
```

Then a 2-3 sentence prose description in plain English.
"""

def analyze_frames(frame_paths: list[str]) -> dict:
    messages = [{"role": "user", "content": [{"type": "text", "text": SCHEMA_PROMPT}]}]
    for p in frame_paths:
        b64 = base64.b64encode(Path(p).read_bytes()).decode()
        messages[0]["content"].append({
            "type": "image_url",
            "image_url": {"url": f"data:image/jpeg;base64,{b64}"},
        })
    
    resp = client.chat.completions.create(
        model="gemma-4-31b-it",
        messages=messages,
        temperature=0.2,
        max_tokens=600,
    )
    text = resp.choices[0].message.content
    
    # 解析 YAML 块 + 散文描述
    yaml_block = text.split("```yaml")[1].split("```")[0]
    prose = text.split("```")[2].strip() if "```" in text else ""
    
    return {
        "structured": yaml.safe_load(yaml_block),
        "description": prose,
    }

关键设计:enum 枚举值约束输出。让模型选 golden_hour | nighttime 这样的固定值,比让它自由发挥更稳定,幻觉率从 8% 降到 0.5%。

实测 Gemma 4 31B Q4 在 M1 Max 上单段视频(5 帧)VLM 分析约 25-40 秒。

整合:生成 .description.md 边车文件

from datetime import datetime

def write_sidecar(video_path: str, all_data: dict):
    md_path = f"{video_path}.description.md"
    
    md = f"""# {Path(video_path).name}

## Metadata

- **Captured**: {all_data['metadata']['captured_at']}
- **Duration**: {all_data['metadata']['duration']:.1f}s
- **Resolution**: {all_data['metadata']['resolution']}
- **Camera**: {all_data['metadata']['camera']}
- **Location**: {all_data['location']['location']}

## Scene Analysis

```yaml
{yaml.dump(all_data['vlm']['structured'], allow_unicode=True)}
```

{all_data['vlm']['description']}

## Transcription ({all_data['transcript']['language']})

"""
    for seg in all_data["transcript"]["segments"]:
        md += f"- [{seg['start']:.1f}s] **{seg['speaker']}**: {seg['text']}\n"
    
    md += "\n## Faces Detected\n\n"
    for i, face in enumerate(all_data["faces"]):
        md += f"- Face {i+1}: ~{face['age']}y {face['gender']} at frame `{face['frame']}`\n"
    
    Path(md_path).write_text(md)

最终每个视频旁边都会有一个 <video>.mp4.description.md 文件,包含全部元数据 + 结构化分析 + 转写 + 人脸标记。

全文检索:三层方案

Tier 1 - ripgrep(<10K 文件):

rg "陆家嘴.*会议" --type md /Videos/
rg "lighting: golden_hour" --type md /Videos/ -l

Tier 2 - Tantivy(10K-1M 文件):

from tantivy import SchemaBuilder, Index, Document

schema_builder = SchemaBuilder()
schema_builder.add_text_field("body", stored=True, tokenizer_name="default")
schema_builder.add_text_field("path", stored=True, tokenizer_name="raw")
schema = schema_builder.build()

index = Index(schema, path="/Videos/.index")
writer = index.writer(50_000_000)

for md_path in Path("/Videos").rglob("*.description.md"):
    doc = Document()
    doc.add_text("body", md_path.read_text())
    doc.add_text("path", str(md_path))
    writer.add_document(doc)
writer.commit()

# 查询
searcher = index.searcher()
hits = searcher.search(index.parse_query("陆家嘴", ["body"]), limit=10)

Tier 3 - embedding + Qdrant(语义搜索):

from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5")

for md_path in Path("/Videos").rglob("*.description.md"):
    text = md_path.read_text()
    chunks = [text[i:i+512] for i in range(0, len(text), 384)]
    embeddings = embedder.encode(chunks)
    # store in Qdrant

然后可以查询 “下雨天的户外片段”、“我笑得最开心的几次” 这类语义 query。

成本对比

方案月成本隐私可定制
Google Photos / iCloud$10-30
Frame.io / Magic AI$80-200
Anthropic Claude API$40-120
本地 + Claude 兜底$22
纯本地 Gemma 4$0极强

原文作者的 $22 是 Anthropic API(仅用于复杂边缘 case 兜底) + 电费。

一句话收尾

5 年前的 M1 Max MacBook 已经能跑通整个 pipeline——这意味着本地多模态 AI 在 2026 年成为消费级硬件的标准能力。如果你还在为了视频管理付 SaaS 订阅,今天就是搭一套自己的 pipeline 的最好时机。

参考资料:

Frequently asked questions

为什么不用云端 API(Gemini 2.5 Pro、GPT-4o)批量分析视频?
三个原因:(1) 隐私——家庭视频、医疗录像、内部会议录屏,不能上传第三方;(2) 成本——Gemini 2.5 Pro 分析 1000 个视频片段约 $80-120,本地 Gemma 4 31B 在 M1 Max 跑过夜不要钱;(3) 索引控制权——云 API 的输出格式时常变化,本地模型加上 schema 约束输出永远稳定。本地 31B 模型在 'structured prompt + enum 约束' 场景下,质量已经能匹配 Sonnet 4.6 在 80% 的常规片段上。剩下 20% 边缘 case(艺术风格、罕见物体),再批量送云 API 二次确认。
M1 Max 64GB 跑 Gemma 4 31B Q4 真的不会卡死系统吗?需要多少 swap?
原文作者实测峰值 50.89GB swap,内存压力进入黄色(yellow band)但不到红色。短期(一个周末批量处理几千段视频)可以接受,长期不建议作为日常工作机配置。如果你只有 32GB 内存,可以换 Gemma 4 14B Q4 (8GB) 或 Gemma 4 7B Q5 (5GB),质量会下降但仍可用。如果你有 128GB 的 Studio/Ultra,可以直接跑 Gemma 4 31B Q8 (32GB),质量再升一档。NVIDIA 用户:RTX 4090 (24GB) + 64GB RAM 跑 Gemma 4 31B Q5 是性价比最高的本地方案。
WhisperX 比 OpenAI 官方 Whisper 强在哪?为什么要选它?
三个关键功能:(1) **说话人分离**(speaker diarization)——基于 pyannote 自动识别 '谁在说话',输出 SPEAKER_01/SPEAKER_02 标签,对会议录屏特别有用;(2) **强制对齐**(word-level timestamp)——用 wav2vec2 做后处理,每个词都有精确到毫秒的时间戳,方便后续做高亮跳转;(3) **批量并行**——比官方 Whisper 快 5-10 倍,1 小时音频在 M1 Max 上约 3 分钟。代价:依赖 ffmpeg 和 pyannote 模型,首次配置稍复杂,但 conda 一次装好就是。
人脸 embedding 用 insightface 而不是 face_recognition 的原因?
(1) **精度**——insightface 的 buffalo_l 模型基于 ArcFace 损失训练,识别准确率比 dlib face_recognition 高约 6 个百分点(LFW 99.83% vs 99.38%);(2) **跨档案查询**——insightface 输出 512 维 embedding,可以直接存进 vector DB(Chroma/Qdrant/FAISS)做跨视频的'找出 X 出现的所有片段'查询;(3) **速度**——insightface ONNX 推理,M1 单帧 80ms,批量处理 1 万张人脸 5 分钟。dlib 在大批量时明显更慢。代价:模型授权严格(仅非商用),商用部署要替换为同类开源 InsightFace SCRFD + ArcFace 自训模型。
生成的 .description.md 边车文件怎么做全文检索?
三种方案按复杂度排序:(1) **ripgrep + glob**:最简单,rg "关键词" --type md /Videos/,秒级返回,适合 <10K 文件;(2) **Tantivy 索引**:用 Rust 写的 BM25 全文索引,适合 10K-1M 文件,单次索引 5 分钟,查询毫秒级;(3) **embedding + vector DB**:把每个 .description.md 切块用 bge-small 做 embedding 存进 Qdrant,支持语义搜索('下雨天的户外片段'这种 query),适合 100K+ 文件或者需要自然语言查询的场景。原文作者用方案 1 + 2 组合:日常查关键词用 ripgrep,复杂查询用 Tantivy。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.