提示

返回博客列表

几千条视频怎么建档:技术元数据、sidecar 与批量抽取

客户有个三千多条视频的媒资库,检索方式基本靠"文件名 + 记忆"。

文件名长这样:课程录制_最终版_final2.mp4、新建文件夹/未命名项目.mp4。想找"去年那场关于数据安全的培训",只能一个个打开看。

我们做了一层元数据建档:

  • 技术元数据(分辨率、编码、时长、码率、是否有音轨)→ 全自动抽取;
  • 内容元数据(标题、描述、标签、人物、章节)→ 半自动(ASR/OCR/场景检测出候选,人工确认);
  • 管理元数据(来源、授权、保留期、负责人)→ 人工录入 + 流程约束。

做完之后检索从"翻文件夹"变成了"查数据库"。这篇写完整方案。

TL;DR:元数据分三类:技术(ffprobe 全自动抽)、内容(ASR/OCR/场景检测出候选 + 人工确认)、管理(人工录入)。存储建议:内嵌基础信息(title/comment)+ sidecar JSON 存完整信息 + 数据库建索引。关于标准:不要照搬 EBUCore/PBCore 全套——它们是给广播/公共媒体设计的,字段多到你维护不动。取一个够用的子集 + 自己的扩展。标识用 UUID 或内容哈希,不要用文件名(会改名、会重复)。最后:元数据要跟着文件走(sidecar 放在同一个目录/归档包里),否则文件一搬家元数据就丢了。

目录

一、元数据的三类

类型 内容 来源 成本
技术元数据 分辨率、帧率、编码、码率、时长、位深、色彩、音轨、字幕轨、文件大小、校验和 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,
    }

这个规范化结构有几个实用设计:

  1. is_hdr:从 color_transfer 推导(前面 HDR 那篇讲过),检索"所有 HDR 素材"很方便;
  2. has_audio:很多"没声音"的投诉其实是没有音轨,这个字段能直接筛出来;
  3. 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 给搜索引擎看的

我的建议:不要照搬全套。

理由:

  1. 这些标准是给广播电台/公共媒体设计的(它们的元数据团队是全职的);
  2. 字段数量几百个,你维护不动——维护不了的元数据会变成垃圾数据;
  3. 大部分字段在你的场景里永远用不上。

务实的做法:

{
  "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 个(损坏文件)→ 单独列出交人工处理。

九、维护:元数据会过期

问题:文件被重新转码、被替换、被移动之后,元数据就旧了。

应对:

  1. 定期重扫(每月一次),对比 sidecar 里的 checksum,变了就更新;
  2. 文件移动时同步移动 sidecar(在归档/迁移脚本里处理);
  3. 入库流程强制写元数据(避免"先放着以后再说");
  4. 校验和联动(前面归档那篇讲过)。

我的做法:把 sidecar 生成和校验放进归档流程,每次归档/迁移都顺带更新一次——这样元数据不会长期过期。

十、坑清单

  1. 靠文件名检索 → 改名就找不到,而且"最终版_final2"没有信息量。必须建档。
  2. 照搬 EBUCore/PBCore 全套 → 维护不动,变成垃圾。取子集。
  3. 元数据只存数据库 → 文件搬走后元数据成了孤儿。sidecar + 内嵌。
  4. 只存 sidecar 不入库 → 不能检索。两边都要。
  5. 不内嵌 asset_id → 文件单独发出去后无法回溯。在 comment 内嵌 ID。
  6. 用文件名当 ID → 改名/重复。用 UUID 或业务编码。
  7. 内容元数据纯人工 → 3000 条要 250 小时。自动出候选 + 人工确认。
  8. 不按置信度分流 → 人工还是要看全部。只审低置信度的。
  9. 管理字段事后补 → 补不上。入库表单必填。
  10. 不写保留期 → 文件永远不敢删,存储无限增长。
  11. sidecar 命名不规范 → 工具找不到。原文件名.meta.json。
  12. 元数据不更新 → 转码后参数变了但记录还是旧的。定期重扫 + checksum 比对。
  13. 不统计"失败的文件" → 损坏的文件静静地躺在库里。
  14. 没做格式/时长分布的汇总 → 不知道库里到底有什么。建完档先看汇总数据。
  15. 忽略隐私 → 元数据里可能含个人信息(比如出镜人员)。注意访问控制。

最后说说这个项目的价值。

建完档之后,最大的收获不是"能搜索了",而是"知道自己有什么"。

客户建完档后看到的第一份汇总报告是:

共 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 个没声音的文件、不知道有多少低分辨率的老素材该淘汰。

元数据的作用不只是"检索",更是"看见"。 这是我做完这个项目最大的体会:

  • 检索是"我找一个已知的";
  • 而汇总统计是"我发现不知道的"——后者往往更有价值。

所以我现在做完任何建档/盘点工作,第一步不是"配搜索框",而是跑一份汇总报告:总量、分布、异常项。这份报告通常能直接发现几个可以立即行动的省钱/提质机会。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部