💡 一句话总结:本地视频索引不是 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 内存)跑通了一整年视频素材的本地索引流程。
核心数字:
- 3 年视频素材,共 ~7TB
- 峰值 50.89GB swap,但系统没崩
- 月成本从 SaaS 的 $140 降到 $22
- 质量在大多数片段上匹配 Sonnet 4.6
这个流程的可贵在于:它不是单纯的 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?
- 太少(1-2 帧)→ 错过场景切换
- 太多(10+ 帧)→ VLM 推理成本指数上升
- 1920px → Gemma 4 的视觉编码器最佳输入分辨率(再大不会更准)
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 上:
- whisper large-v3 转写:约 3 分钟
- 词级对齐:约 30 秒
- 说话人分离:约 1 分钟
- 总计:约 4.5 分钟
对比原版 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 的最好时机。
参考资料: