客户有个三千多条视频的媒资库,检索方式基本靠"文件名 + 记忆"。
文件名长这样:
课程录制_最终版_final2.mp4、新建文件夹/未命名项目.mp4。想找"去年那场关于数据安全的培训",只能一个个打开看。我们做了一层元数据建档:
- 技术元数据(分辨率、编码、时长、码率、是否有音轨)→ 全自动抽取;
- 内容元数据(标题、描述、标签、人物、章节)→ 半自动(ASR/OCR/场景检测出候选,人工确认);
- 管理元数据(来源、授权、保留期、负责人)→ 人工录入 + 流程约束。
做完之后检索从"翻文件夹"变成了"查数据库"。这篇写完整方案。
TL;DR:元数据分三类:技术(ffprobe 全自动抽)、内容(ASR/OCR/场景检测出候选 + 人工确认)、管理(人工录入)。存储建议:内嵌基础信息(title/comment)+ sidecar JSON 存完整信息 + 数据库建索引。关于标准:不要照搬 EBUCore/PBCore 全套——它们是给广播/公共媒体设计的,字段多到你维护不动。取一个够用的子集 + 自己的扩展。标识用 UUID 或内容哈希,不要用文件名(会改名、会重复)。最后:元数据要跟着文件走(sidecar 放在同一个目录/归档包里),否则文件一搬家元数据就丢了。
目录
- 一、元数据的三类
- 二、技术元数据:全自动抽取
- 三、存哪:内嵌 vs sidecar vs 数据库
- 四、要不要用 EBUCore / PBCore
- 五、内容元数据:半自动
- 六、管理元数据
- 七、唯一标识
- 八、批量抽取脚本
- 九、维护:元数据会过期
- 十、坑清单
一、元数据的三类
| 类型 | 内容 | 来源 | 成本 |
|---|---|---|---|
| 技术元数据 | 分辨率、帧率、编码、码率、时长、位深、色彩、音轨、字幕轨、文件大小、校验和 | ffprobe 全自动 | 几乎为零 |
| 内容元数据 | 标题、描述、标签、人物、地点、事件、章节、语音文本、画面文字 | ASR/OCR/检测 + 人工确认 | 中 |
| 管理元数据 | 来源、创建时间、负责人、授权范围、保留期、状态码 | 人工录入 | 高(但必要) |
这三类的工作量差异巨大,所以要分开对待:
- 技术元数据:全部自动,每次入库都跑;
- 内容元数据:先自动出候选,人工只做确认/修正(比纯人工快 10 倍);
- 管理元数据:靠流程约束(入库表单必填),不能指望事后补。
二、技术元数据:全自动抽取
import json
import subprocess
def probe_full(path: str) -> dict:
cmd = ['ffprobe', '-v', 'error', '-print_format', 'json',
'-show_format', '-show_streams', '-show_chapters', path]
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
return json.loads(out)
规范化(ffprobe 的原始 JSON 字段多且不稳定,要抽一个稳定的子集):
def normalize(info: dict) -> dict:
fmt = info.get('format', {})
streams = info.get('streams', [])
v = next((s for s in streams if s.get('codec_type') == 'video'), None)
a = next((s for s in streams if s.get('codec_type') == 'audio'), None)
subs = [s for s in streams if s.get('codec_type') == 'subtitle']
def fps_of(s):
r = (s or {}).get('avg_frame_rate') or (s or {}).get('r_frame_rate') or '0/0'
try:
n, d = r.split('/')
return round(float(n) / float(d), 3) if float(d) else None
except Exception:
return None
return {
'duration': round(float(fmt.get('duration') or 0), 2),
'size_bytes': int(fmt.get('size') or 0),
'format_name': fmt.get('format_name'),
'bitrate_kbps': int(int(fmt.get('bit_rate') or 0) / 1000),
'video': None if not v else {
'codec': v.get('codec_name'),
'profile': v.get('profile'),
'width': v.get('width'),
'height': v.get('height'),
'fps': fps_of(v),
'pix_fmt': v.get('pix_fmt'),
'bitrate_kbps': int(int(v.get('bit_rate') or 0) / 1000),
'color_transfer': v.get('color_transfer'),
'color_primaries': v.get('color_primaries'),
'is_hdr': v.get('color_transfer') in ('smpte2084', 'arib-std-b67'),
},
'audio': None if not a else {
'codec': a.get('codec_name'),
'sample_rate': int(a.get('sample_rate') or 0),
'channels': a.get('channels'),
'bitrate_kbps': int(int(a.get('bit_rate') or 0) / 1000),
'language': (a.get('tags') or {}).get('language'),
},
'subtitle_count': len(subs),
'chapter_count': len(info.get('chapters') or []),
'has_audio': a is not None,
}
这个规范化结构有几个实用设计:
is_hdr:从color_transfer推导(前面 HDR 那篇讲过),检索"所有 HDR 素材"很方便;has_audio:很多"没声音"的投诉其实是没有音轨,这个字段能直接筛出来;chapter_count:知道哪些素材已经做过章节。
三、存哪:内嵌 vs sidecar vs 数据库
| 方式 | 优点 | 缺点 |
|---|---|---|
| 内嵌(容器 metadata) | 跟着文件走,不会丢 | 能存的字段有限;改起来要重写文件 |
| sidecar(同目录的 JSON/XML) | 字段丰富;易读易改 | 文件和元数据可能分开(移动文件时容易漏) |
| 数据库 | 能索引、能查询、能关联 | 文件搬走后元数据还在(可能不一致) |
我的组合方案:
内嵌:基础信息(title / artist / comment / creation_time)
↓ 保证"文件单独发出去时还知道是什么"
sidecar:完整技术元数据 + 内容元数据的 JSON
↓ 放在同一个目录/归档包里,跟着文件走
数据库:索引 + 业务字段 + 关系
↓ 用于检索和管理
内嵌示例:
ffmpeg -i in.mp4 -c copy \
-metadata title="2026年数据安全培训(第一讲)" \
-metadata artist="技术部" \
-metadata comment="asset_id=AST-2026-0142" \
-metadata creation_time="2026-03-11T10:00:00+08:00" \
out.mp4
关键:内嵌一个 asset_id(在 comment 里),这样即使 sidecar 丢了,也能通过文件本身找回数据库记录。这是"元数据跟着文件走"的最后一道保险。
sidecar 的命名:video.mp4.meta.json 或 video.mp4.json(同一目录,同名加后缀)。
四、要不要用 EBUCore / PBCore
标准简介:
| 标准 | 领域 | 特点 |
|---|---|---|
| Dublin Core | 通用 | 15 个核心字段,简单 |
| EBUCore | 欧洲广播联盟 | 很全(几百个字段),XML/RDF |
| PBCore | 美国公共广播 | 全,XML,有描述/知识产权/实例化三块 |
| SMPTE ST 377 (MXF) | 专业制作 | 容器级元数据 |
| schema.org/VideoObject | 网页 SEO | 给搜索引擎看的 |
我的建议:不要照搬全套。
理由:
- 这些标准是给广播电台/公共媒体设计的(它们的元数据团队是全职的);
- 字段数量几百个,你维护不动——维护不了的元数据会变成垃圾数据;
- 大部分字段在你的场景里永远用不上。
务实的做法:
{
"schema": "viddown-asset/1.0",
"asset_id": "AST-2026-0142",
"title": "2026年数据安全培训(第一讲)",
"created_at": "2026-03-11",
"technical": { ... },
"content": {
"summary": "...",
"tags": ["数据安全", "培训"],
"people": ["张工"],
"chapters": [{"start": 0, "end": 450, "title": "开场"}]
},
"administrative": {
"source": "内部录制",
"owner": "技术部",
"rights": "内部使用",
"retention_until": "2029-03-11",
"checksum": "xxh3:abc123"
},
"_mapping": {
"dc.title": "content.title",
"dcterms.created": "created_at"
}
}
保留一个 _mapping 字段说明"我们的字段对应哪些标准字段"——将来要跟外部系统交换时,靠这个映射做转换就够了,不需要一开始就全盘照搬标准。
一个例外:如果你的内容要发布到网页,那就用 schema.org/VideoObject 的结构化数据(JSON-LD)——因为这是给搜索引擎看的,有实际流量收益。
五、内容元数据:半自动
自动能出什么:
| 内容 | 来源 |
|---|---|
| 语音文本 | Whisper 转写(高准确率) |
| 画面文字 | OCR(第 49 篇,PPT 类准确率 92%) |
| 章节/场景切分 | 场景检测(第 25 篇) |
| 人物(人脸) | 人脸检测 + 聚类(能知道"出现了几个人",但不知道"是谁") |
| 标签候选 | 从转写文本抽关键词 |
| 摘要 | 从转写文本做抽取式摘要 |
人工要做什么:
- 确认/修正自动出的标题和标签;
- 标注"是谁"(人脸聚类后人工给每个聚类命名);
- 补充业务相关的分类信息。
效率对比(3000 条素材):
| 方式 | 预估工时 |
|---|---|
| 纯人工建档 | 3000 × 5 分钟 = 250 小时 |
| 自动出候选 + 人工确认 | 3000 × 1 分钟 = 50 小时 |
| 自动 + 人工只审可疑的(置信度低的) | 约 20 小时 |
按置信度分流是关键:自动结果置信度高的(比如 ASR 置信度 > 0.9)自动采纳,低的人工看。这样人工只需处理 20~30%。
六、管理元数据
这部分必须靠流程约束,不能事后补:
class Asset(models.Model):
asset_id = models.CharField(max_length=64, unique=True)
title = models.CharField(max_length=200)
# 管理字段(入库必填)
source = models.CharField(max_length=100, help_text='来源')
owner = models.ForeignKey('auth.User', on_delete=models.PROTECT)
rights = models.CharField(max_length=100, help_text='使用范围')
retention_until = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
# 技术元数据(自动填)
technical = models.JSONField(default=dict)
checksum = models.CharField(max_length=128, blank=True)
path = models.CharField(max_length=512)
status = models.CharField(max_length=20, default='ACTIVE')
入库表单里把 source/owner/rights 设为必填——这比事后追着人补要有效得多。
保留期:有了 retention_until,就能做自动清理(到期提醒 + 归档/删除)。前面归档那篇讲过保留期的重要性。
七、唯一标识
不要用文件名做 ID(会改名、会重复、有特殊字符)。
推荐:
| 方案 | 说明 |
|---|---|
| UUID | 简单、全球唯一、不含语义 |
| 内容哈希(xxhash/sha256) | 能去重(同一内容同一 ID),但文件改了 ID 就变 |
业务编码(如 AST-2026-0142) |
可读性好,便于人工沟通 |
我的组合:业务编码作为对外 ID(人能读、能写在文档里)+ 内容哈希作为去重依据(技术层)。
asset_id = "AST-2026-0142" # 人用的
content_hash = "xxh3:abc123..." # 机器用的(去重、校验)
八、批量抽取脚本
import json
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
def process_one(path: Path):
try:
info = probe_full(str(path))
tech = normalize(info)
meta = {
'schema': 'viddown-asset/1.0',
'asset_id': None, # 入库时分配
'title': path.stem,
'technical': tech,
'administrative': {
'checksum': f'xxh3:{file_hash(str(path))}',
'file': path.name,
'indexed_at': time.time(),
},
}
# 写 sidecar
sidecar = path.with_suffix(path.suffix + '.meta.json')
sidecar.write_text(json.dumps(meta, ensure_ascii=False, indent=2),
encoding='utf-8')
return {'path': str(path), 'ok': True, 'duration': tech['duration']}
except Exception as e:
return {'path': str(path), 'ok': False, 'error': str(e)[:200]}
def index_library(root: str, workers=8):
files = [p for p in Path(root).rglob('*')
if p.suffix.lower() in ('.mp4', '.mov', '.mkv', '.ts', '.avi', '.mxf')]
print(f'发现 {len(files)} 个文件')
with ProcessPoolExecutor(max_workers=workers) as ex:
results = list(ex.map(process_one, files))
ok = [r for r in results if r['ok']]
bad = [r for r in results if not r['ok']]
print(f'成功 {len(ok)},失败 {len(bad)}')
for b in bad[:10]:
print(f" {b['path']}: {b['error']}")
# 汇总(总时长、总大小、格式分布)
total_dur = sum(r['duration'] for r in ok)
print(f'总时长: {total_dur / 3600:.1f} 小时')
return results
if __name__ == '__main__':
import sys
index_library(sys.argv[1])
跑完之后:把 sidecar 的数据导入数据库(建索引),就能检索了。
实测(3000 个文件,约 4 TB,8 进程):
- 耗时:约 40 分钟(主要花在 probe 上,大文件的 probe 要读文件头);
- 失败 12 个(损坏文件)→ 单独列出交人工处理。
九、维护:元数据会过期
问题:文件被重新转码、被替换、被移动之后,元数据就旧了。
应对:
- 定期重扫(每月一次),对比 sidecar 里的 checksum,变了就更新;
- 文件移动时同步移动 sidecar(在归档/迁移脚本里处理);
- 入库流程强制写元数据(避免"先放着以后再说");
- 校验和联动(前面归档那篇讲过)。
我的做法:把 sidecar 生成和校验放进归档流程,每次归档/迁移都顺带更新一次——这样元数据不会长期过期。
十、坑清单
- 靠文件名检索 → 改名就找不到,而且"最终版_final2"没有信息量。必须建档。
- 照搬 EBUCore/PBCore 全套 → 维护不动,变成垃圾。取子集。
- 元数据只存数据库 → 文件搬走后元数据成了孤儿。sidecar + 内嵌。
- 只存 sidecar 不入库 → 不能检索。两边都要。
- 不内嵌 asset_id → 文件单独发出去后无法回溯。在 comment 内嵌 ID。
- 用文件名当 ID → 改名/重复。用 UUID 或业务编码。
- 内容元数据纯人工 → 3000 条要 250 小时。自动出候选 + 人工确认。
- 不按置信度分流 → 人工还是要看全部。只审低置信度的。
- 管理字段事后补 → 补不上。入库表单必填。
- 不写保留期 → 文件永远不敢删,存储无限增长。
- sidecar 命名不规范 → 工具找不到。
原文件名.meta.json。 - 元数据不更新 → 转码后参数变了但记录还是旧的。定期重扫 + checksum 比对。
- 不统计"失败的文件" → 损坏的文件静静地躺在库里。
- 没做格式/时长分布的汇总 → 不知道库里到底有什么。建完档先看汇总数据。
- 忽略隐私 → 元数据里可能含个人信息(比如出镜人员)。注意访问控制。
最后说说这个项目的价值。
建完档之后,最大的收获不是"能搜索了",而是"知道自己有什么"。
客户建完档后看到的第一份汇总报告是:
共 3127 个文件,总时长 2840 小时(约 4.1 TB)
格式分布:MP4 78% / MOV 12% / MKV 6% / 其他 4%
分辨率分布:1080p 54% / 720p 28% / 480p 12% / 4K 6%
无音轨的文件:147 个(需要确认)
HDR 素材:89 个
疑似重复(内容哈希相同):213 个(可清理,约 280 GB)
"疑似重复 213 个、可清理 280 GB"这一条当场就值回了建档的成本。
没有元数据的时候,这些是"看不见的浪费"——你不知道库里有重复、不知道有 147 个没声音的文件、不知道有多少低分辨率的老素材该淘汰。
元数据的作用不只是"检索",更是"看见"。 这是我做完这个项目最大的体会:
- 检索是"我找一个已知的";
- 而汇总统计是"我发现不知道的"——后者往往更有价值。
所以我现在做完任何建档/盘点工作,第一步不是"配搜索框",而是跑一份汇总报告:总量、分布、异常项。这份报告通常能直接发现几个可以立即行动的省钱/提质机会。