feat: initialize video metadata analyzer

This commit is contained in:
kim
2026-04-02 18:14:20 +08:00
commit bc242f8a31
20 changed files with 1331 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
.pytest_cache/
__pycache__/
reports/
tmp_verify_input/
tmp_verify_output*/
+87
View File
@@ -0,0 +1,87 @@
# 视频元数据分析器
基于 Python + `uv` 的命令行工具,用于递归扫描一个或多个目录中的视频文件,调用 `ffprobe` 提取结构化元数据,并调用 `ffmpeg` 记录运行环境能力,最终输出:
- `report.json`:完整元数据与汇总统计
- `files.csv`:逐文件明细
- `report.html`:可直接打开的离线可视化报告
## 当前能力
- 输入一个或多个目录,递归发现常见视频文件
- 提取视频时长、大小、编码、分辨率、帧率、音频编码等字段
- 汇总时长分布、大小分布、视频编码分布、分辨率分布
- 生成适合人工查看的 HTML 报告,以及适合后续二次处理的 JSON / CSV
## 依赖要求
- Python 3.11+
- `uv`
- `ffprobe`
- `ffmpeg`
要求 `ffprobe` 与 `ffmpeg` 已经在系统 `PATH` 中,或者在运行时通过参数显式指定。
## 初始化
```powershell
uv sync --dev
```
## 运行方式
最直接的方式:
```powershell
uv run video-metadata-analyzer D:\Videos E:\Archive --output-dir .\reports\latest
```
如果你更习惯模块方式:
```powershell
uv run python -m video_metadata_analyzer D:\Videos --output-dir .\reports\latest
```
如果 `ffprobe` / `ffmpeg` 不在 `PATH` 中,可以显式指定:
```powershell
uv run video-metadata-analyzer D:\Videos `
--ffprobe-bin C:\ffmpeg\bin\ffprobe.exe `
--ffmpeg-bin C:\ffmpeg\bin\ffmpeg.exe `
--output-dir .\reports\latest
```
## 输出内容
生成目录内默认包含以下文件:
- `report.json`
- `files.csv`
- `report.html`
其中 `report.html` 是离线静态页面,可以直接双击打开查看。
## 开发与测试
运行测试:
```powershell
uv run pytest -q
```
查看命令帮助:
```powershell
uv run video-metadata-analyzer --help
```
## 第一版边界
当前版本优先保证“扫描、提取、汇总、可视化”这条主链路可用,暂时没有做这些增强项:
- 损坏视频的深度解码验证
- 单文件失败后的错误报告面板
- 更复杂的前端交互图表
- 增量扫描与数据库缓存
如果后续要继续做,最值得优先加的是“探测失败文件单独记录,不让整次扫描因为一个坏文件直接中断”。
@@ -0,0 +1,234 @@
# Video Metadata Analyzer Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 构建一个基于 Python + uv 的命令行视频元数据分析器,递归扫描一个或多个目录,调用 ffprobe/ffmpeg 汇总视频元数据,并输出 JSON、CSV 和静态 HTML 可视化报告。
**Architecture:** 项目采用 `src/` 布局的 CLI 应用。扫描层负责发现视频文件;探测层调用 ffprobe 获取结构化元数据,并调用 ffmpeg 记录运行环境能力;分析层负责做总量统计、分布分箱和类别汇总;报告层输出机器可读 JSON/CSV 以及带内嵌 SVG 图表的 HTML 报告,避免引入浏览器端框架依赖。
**Tech Stack:** Python 3.11+、uv、pytest、ffprobe、ffmpeg、标准库 `argparse/json/csv/pathlib/subprocess/statistics/html`
---
### Task 1: 建立 uv 项目基础
**Files:**
- Create: `pyproject.toml`
- Create: `src/video_metadata_analyzer/__init__.py`
- Create: `tests/__init__.py`
**Step 1: Write the failing test**
```python
def test_package_version_is_exposed():
from video_metadata_analyzer import __version__
assert __version__
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_package.py::test_package_version_is_exposed -v`
Expected: FAIL with `ModuleNotFoundError` or missing `__version__`
**Step 3: Write minimal implementation**
```python
__version__ = "0.1.0"
```
**Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_package.py::test_package_version_is_exposed -v`
Expected: PASS
**Step 5: Commit**
```bash
git add pyproject.toml src/video_metadata_analyzer/__init__.py tests/test_package.py
git commit -m "chore: initialize uv project skeleton"
```
### Task 2: 实现视频文件发现
**Files:**
- Create: `tests/test_discovery.py`
- Create: `src/video_metadata_analyzer/discovery.py`
**Step 1: Write the failing test**
```python
def test_discover_video_files_recursively_filters_supported_extensions(tmp_path):
...
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_discovery.py::test_discover_video_files_recursively_filters_supported_extensions -v`
Expected: FAIL with missing function
**Step 3: Write minimal implementation**
```python
def discover_video_files(inputs: list[Path]) -> list[Path]:
...
```
**Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_discovery.py::test_discover_video_files_recursively_filters_supported_extensions -v`
Expected: PASS
**Step 5: Commit**
```bash
git add tests/test_discovery.py src/video_metadata_analyzer/discovery.py
git commit -m "feat: add recursive video discovery"
```
### Task 3: 实现 ffprobe 元数据解析
**Files:**
- Create: `tests/test_probe.py`
- Create: `src/video_metadata_analyzer/models.py`
- Create: `src/video_metadata_analyzer/probe.py`
**Step 1: Write the failing test**
```python
def test_probe_video_metadata_parses_primary_streams():
...
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_probe.py::test_probe_video_metadata_parses_primary_streams -v`
Expected: FAIL with missing class or parser
**Step 3: Write minimal implementation**
```python
@dataclass
class VideoMetadata:
...
```
**Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_probe.py::test_probe_video_metadata_parses_primary_streams -v`
Expected: PASS
**Step 5: Commit**
```bash
git add tests/test_probe.py src/video_metadata_analyzer/models.py src/video_metadata_analyzer/probe.py
git commit -m "feat: parse metadata from ffprobe output"
```
### Task 4: 实现聚合分析与图表数据
**Files:**
- Create: `tests/test_analysis.py`
- Create: `src/video_metadata_analyzer/analysis.py`
**Step 1: Write the failing test**
```python
def test_build_summary_computes_totals_and_histograms():
...
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_analysis.py::test_build_summary_computes_totals_and_histograms -v`
Expected: FAIL with missing function
**Step 3: Write minimal implementation**
```python
def build_summary(records: list[VideoMetadata]) -> AnalysisSummary:
...
```
**Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_analysis.py::test_build_summary_computes_totals_and_histograms -v`
Expected: PASS
**Step 5: Commit**
```bash
git add tests/test_analysis.py src/video_metadata_analyzer/analysis.py
git commit -m "feat: add aggregate analysis summaries"
```
### Task 5: 实现报告输出与 CLI
**Files:**
- Create: `tests/test_report.py`
- Create: `tests/test_cli.py`
- Create: `src/video_metadata_analyzer/reporting.py`
- Create: `src/video_metadata_analyzer/cli.py`
**Step 1: Write the failing test**
```python
def test_write_reports_creates_json_csv_and_html(tmp_path):
...
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_report.py::test_write_reports_creates_json_csv_and_html -v`
Expected: FAIL with missing writer
**Step 3: Write minimal implementation**
```python
def write_report_bundle(output_dir: Path, ...):
...
```
**Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_report.py::test_write_reports_creates_json_csv_and_html -v`
Expected: PASS
**Step 5: Commit**
```bash
git add tests/test_report.py tests/test_cli.py src/video_metadata_analyzer/reporting.py src/video_metadata_analyzer/cli.py
git commit -m "feat: add report writers and CLI entrypoint"
```
### Task 6: 文档与验收
**Files:**
- Create: `README.md`
**Step 1: Write the failing test**
```python
def test_readme_mentions_uv_run_command():
...
```
**Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_cli.py::test_readme_mentions_uv_run_command -v`
Expected: FAIL because README or command example is absent
**Step 3: Write minimal implementation**
补充中文 README,写明依赖、安装、运行方式、输出文件说明和常见问题。
**Step 4: Run test to verify it passes**
Run: `uv run pytest -q`
Expected: PASS
**Step 5: Commit**
```bash
git add README.md
git commit -m "docs: add usage guide for video metadata analyzer"
```
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["hatchling>=1.27.0"]
build-backend = "hatchling.build"
[project]
name = "video-metadata-analyzer"
version = "0.1.0"
description = "递归扫描视频目录并生成元数据分析报告的 CLI 工具"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
video-metadata-analyzer = "video_metadata_analyzer.cli:main"
[dependency-groups]
dev = [
"pytest>=8.4.2",
]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from video_metadata_analyzer.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from video_metadata_analyzer.models import VideoMetadata
MEGABYTE = 1024 * 1024
GIGABYTE = 1024 * 1024 * 1024
DURATION_BUCKETS: list[tuple[str, float, float | None]] = [
("0-1 min", 0, 60),
("1-5 min", 60, 300),
("5-15 min", 300, 900),
("15-60 min", 900, 3600),
("60+ min", 3600, None),
]
SIZE_BUCKETS: list[tuple[str, int, int | None]] = [
("0-100 MB", 0, 100 * MEGABYTE),
("100-500 MB", 100 * MEGABYTE, 500 * MEGABYTE),
("500 MB-1 GB", 500 * MEGABYTE, GIGABYTE),
("1-4 GB", GIGABYTE, 4 * GIGABYTE),
("4+ GB", 4 * GIGABYTE, None),
]
@dataclass(slots=True)
class HistogramBin:
label: str
count: int
@dataclass(slots=True)
class CategoryCount:
label: str
count: int
@dataclass(slots=True)
class AnalysisSummary:
total_files: int
total_size_bytes: int
total_duration_seconds: float
duration_histogram: list[HistogramBin]
size_histogram: list[HistogramBin]
video_codecs: list[CategoryCount]
resolutions: list[CategoryCount]
def _bucketize_numeric(
values: list[float],
buckets: list[tuple[str, float, float | None]],
) -> list[HistogramBin]:
results: list[HistogramBin] = []
for label, lower, upper in buckets:
count = 0
for value in values:
if value < lower:
continue
if upper is None or value < upper:
count += 1
results.append(HistogramBin(label=label, count=count))
return results
def _build_ranked_counts(labels: list[str]) -> list[CategoryCount]:
counts = Counter(labels)
first_seen: dict[str, int] = {}
for index, label in enumerate(labels):
first_seen.setdefault(label, index)
ordered = sorted(counts.items(), key=lambda item: (-item[1], first_seen[item[0]]))
return [CategoryCount(label=label, count=count) for label, count in ordered]
def build_summary(records: list[VideoMetadata]) -> AnalysisSummary:
durations = [record.duration_seconds for record in records if record.duration_seconds is not None]
sizes = [record.size_bytes for record in records]
codecs = [record.video_codec for record in records if record.video_codec]
resolutions = [
f"{record.width}x{record.height}"
for record in records
if record.width is not None and record.height is not None
]
return AnalysisSummary(
total_files=len(records),
total_size_bytes=sum(sizes),
total_duration_seconds=sum(durations),
duration_histogram=_bucketize_numeric(durations, DURATION_BUCKETS),
size_histogram=_bucketize_numeric(sizes, SIZE_BUCKETS),
video_codecs=_build_ranked_counts(codecs),
resolutions=_build_ranked_counts(resolutions),
)
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import argparse
from datetime import datetime
from pathlib import Path
from typing import Sequence
from video_metadata_analyzer.analysis import build_summary
from video_metadata_analyzer.discovery import discover_video_files
from video_metadata_analyzer.probe import CommandRunner, probe_tool_versions, probe_video_metadata
from video_metadata_analyzer.reporting import write_report_bundle
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="video-metadata-analyzer",
description="递归扫描目录中的视频文件并生成元数据分析报告。",
)
parser.add_argument("inputs", nargs="+", help="一个或多个待扫描的目录或视频文件路径")
parser.add_argument(
"--output-dir",
help="报告输出目录,默认写入 reports/<timestamp>",
)
parser.add_argument("--ffprobe-bin", default="ffprobe", help="ffprobe 可执行文件路径")
parser.add_argument("--ffmpeg-bin", default="ffmpeg", help="ffmpeg 可执行文件路径")
return parser
def main(
argv: Sequence[str] | None = None,
*,
command_runner: CommandRunner | None = None,
) -> int:
parser = build_parser()
args = parser.parse_args(list(argv) if argv is not None else None)
input_paths = [Path(item) for item in args.inputs]
output_dir = (
Path(args.output_dir)
if args.output_dir
else Path("reports") / datetime.now().strftime("%Y%m%d-%H%M%S")
)
tool_versions = probe_tool_versions(
ffprobe_bin=args.ffprobe_bin,
ffmpeg_bin=args.ffmpeg_bin,
runner=command_runner,
)
video_files = discover_video_files(input_paths)
records = [
probe_video_metadata(
path,
ffprobe_bin=args.ffprobe_bin,
runner=command_runner,
)
for path in video_files
]
summary = build_summary(records)
paths = write_report_bundle(
output_dir=output_dir,
scanned_inputs=input_paths,
records=records,
summary=summary,
tool_versions=tool_versions,
)
print(f"扫描完成:共发现 {summary.total_files} 个视频文件。")
print(f"JSON 报告:{paths['json']}")
print(f"CSV 明细:{paths['csv']}")
print(f"HTML 报告:{paths['html']}")
return 0
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
VIDEO_EXTENSIONS = {
".3gp",
".avi",
".flv",
".m4v",
".mkv",
".mov",
".mp4",
".mpeg",
".mpg",
".mts",
".m2ts",
".ts",
".webm",
".wmv",
}
def discover_video_files(inputs: list[Path]) -> list[Path]:
discovered: set[Path] = set()
for input_path in inputs:
if input_path.is_file() and input_path.suffix.lower() in VIDEO_EXTENSIONS:
discovered.add(input_path)
continue
if not input_path.is_dir():
continue
for candidate in input_path.rglob("*"):
if candidate.is_file() and candidate.suffix.lower() in VIDEO_EXTENSIONS:
discovered.add(candidate)
return sorted(discovered)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(slots=True)
class VideoMetadata:
path: Path
size_bytes: int
duration_seconds: float | None
format_name: str | None
bit_rate: int | None
video_codec: str | None
audio_codec: str | None
width: int | None
height: int | None
frame_rate: float | None
video_stream_count: int
audio_stream_count: int
subtitle_stream_count: int
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Callable
from video_metadata_analyzer.models import VideoMetadata
CommandRunner = Callable[[list[str]], str]
def _run_command(command: list[str]) -> str:
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return completed.stdout
def _parse_optional_int(value: object) -> int | None:
if value in (None, "", "N/A"):
return None
return int(value)
def _parse_optional_float(value: object) -> float | None:
if value in (None, "", "N/A"):
return None
return float(value)
def _parse_frame_rate(value: object) -> float | None:
if value in (None, "", "0/0", "N/A"):
return None
if isinstance(value, str) and "/" in value:
numerator, denominator = value.split("/", maxsplit=1)
if denominator == "0":
return None
return float(numerator) / float(denominator)
return float(value)
def probe_video_metadata(
path: Path,
*,
ffprobe_bin: str = "ffprobe",
runner: CommandRunner | None = None,
) -> VideoMetadata:
command = [
ffprobe_bin,
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
str(path),
]
raw_output = (runner or _run_command)(command)
payload = json.loads(raw_output)
format_info = payload.get("format", {})
streams = payload.get("streams", [])
video_streams = [stream for stream in streams if stream.get("codec_type") == "video"]
audio_streams = [stream for stream in streams if stream.get("codec_type") == "audio"]
subtitle_streams = [stream for stream in streams if stream.get("codec_type") == "subtitle"]
primary_video = video_streams[0] if video_streams else {}
primary_audio = audio_streams[0] if audio_streams else {}
return VideoMetadata(
path=path,
size_bytes=_parse_optional_int(format_info.get("size")) or path.stat().st_size,
duration_seconds=_parse_optional_float(format_info.get("duration")),
format_name=format_info.get("format_name"),
bit_rate=_parse_optional_int(format_info.get("bit_rate")),
video_codec=primary_video.get("codec_name"),
audio_codec=primary_audio.get("codec_name"),
width=_parse_optional_int(primary_video.get("width")),
height=_parse_optional_int(primary_video.get("height")),
frame_rate=_parse_frame_rate(primary_video.get("avg_frame_rate")),
video_stream_count=len(video_streams),
audio_stream_count=len(audio_streams),
subtitle_stream_count=len(subtitle_streams),
)
def probe_tool_versions(
*,
ffprobe_bin: str = "ffprobe",
ffmpeg_bin: str = "ffmpeg",
runner: CommandRunner | None = None,
) -> dict[str, str]:
active_runner = runner or _run_command
ffprobe_output = active_runner([ffprobe_bin, "-version"])
ffmpeg_output = active_runner([ffmpeg_bin, "-version"])
return {
"ffprobe": ffprobe_output.splitlines()[0].strip() if ffprobe_output else "unknown",
"ffmpeg": ffmpeg_output.splitlines()[0].strip() if ffmpeg_output else "unknown",
}
+286
View File
@@ -0,0 +1,286 @@
from __future__ import annotations
import csv
import json
from dataclasses import asdict
from datetime import datetime
from html import escape
from pathlib import Path
from typing import Iterable
from video_metadata_analyzer.analysis import AnalysisSummary, CategoryCount, HistogramBin
from video_metadata_analyzer.models import VideoMetadata
def _record_to_dict(record: VideoMetadata) -> dict[str, object]:
return {
"path": record.path.as_posix(),
"size_bytes": record.size_bytes,
"duration_seconds": record.duration_seconds,
"format_name": record.format_name,
"bit_rate": record.bit_rate,
"video_codec": record.video_codec,
"audio_codec": record.audio_codec,
"width": record.width,
"height": record.height,
"frame_rate": record.frame_rate,
"video_stream_count": record.video_stream_count,
"audio_stream_count": record.audio_stream_count,
"subtitle_stream_count": record.subtitle_stream_count,
}
def _summary_to_dict(summary: AnalysisSummary) -> dict[str, object]:
return {
"total_files": summary.total_files,
"total_size_bytes": summary.total_size_bytes,
"total_duration_seconds": summary.total_duration_seconds,
"duration_histogram": [asdict(item) for item in summary.duration_histogram],
"size_histogram": [asdict(item) for item in summary.size_histogram],
"video_codecs": [asdict(item) for item in summary.video_codecs],
"resolutions": [asdict(item) for item in summary.resolutions],
}
def _write_json(
output_path: Path,
*,
scanned_inputs: list[Path],
records: list[VideoMetadata],
summary: AnalysisSummary,
tool_versions: dict[str, str],
) -> None:
payload = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"scanned_inputs": [path.as_posix() for path in scanned_inputs],
"tool_versions": tool_versions,
"summary": _summary_to_dict(summary),
"files": [_record_to_dict(record) for record in records],
}
output_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _write_csv(output_path: Path, records: list[VideoMetadata]) -> None:
fieldnames = [
"path",
"size_bytes",
"duration_seconds",
"format_name",
"bit_rate",
"video_codec",
"audio_codec",
"width",
"height",
"frame_rate",
"video_stream_count",
"audio_stream_count",
"subtitle_stream_count",
]
with output_path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for record in records:
writer.writerow(_record_to_dict(record))
def _format_size(value: int) -> str:
units = ["B", "KB", "MB", "GB", "TB"]
size = float(value)
for unit in units:
if size < 1024 or unit == units[-1]:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TB"
def _format_duration(value: float) -> str:
total_seconds = int(round(value))
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes}m {seconds}s"
if minutes:
return f"{minutes}m {seconds}s"
return f"{seconds}s"
def _render_bar_chart(title: str, items: Iterable[HistogramBin | CategoryCount]) -> str:
rows = list(items)
width = 680
height = 320
margin_left = 110
margin_right = 24
margin_top = 24
margin_bottom = 48
plot_width = width - margin_left - margin_right
plot_height = height - margin_top - margin_bottom
bar_gap = 12
bar_width = plot_width / max(len(rows), 1) - bar_gap
max_count = max((item.count for item in rows), default=1)
pieces = [
f'<section class="chart"><h2>{escape(title)}</h2>',
f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="{escape(title)}">',
f'<line x1="{margin_left}" y1="{margin_top + plot_height}" x2="{width - margin_right}" y2="{margin_top + plot_height}" class="axis" />',
]
for index, item in enumerate(rows):
bar_height = 0 if max_count == 0 else plot_height * (item.count / max_count)
x = margin_left + index * (bar_width + bar_gap) + bar_gap / 2
y = margin_top + plot_height - bar_height
text_x = x + bar_width / 2
pieces.append(
f'<rect x="{x:.2f}" y="{y:.2f}" width="{bar_width:.2f}" height="{bar_height:.2f}" class="bar" />'
)
pieces.append(f'<text x="{text_x:.2f}" y="{y - 8:.2f}" class="count">{item.count}</text>')
pieces.append(
f'<text x="{text_x:.2f}" y="{margin_top + plot_height + 20:.2f}" class="label">{escape(item.label)}</text>'
)
pieces.append("</svg></section>")
return "".join(pieces)
def _write_html(
output_path: Path,
*,
scanned_inputs: list[Path],
summary: AnalysisSummary,
tool_versions: dict[str, str],
) -> None:
summary_cards = f"""
<section class="cards">
<article class="card"><h2>视频文件数</h2><p>{summary.total_files}</p></article>
<article class="card"><h2>总大小</h2><p>{escape(_format_size(summary.total_size_bytes))}</p></article>
<article class="card"><h2>总时长</h2><p>{escape(_format_duration(summary.total_duration_seconds))}</p></article>
<article class="card"><h2>扫描目录数</h2><p>{len(scanned_inputs)}</p></article>
</section>
"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>视频元数据分析报告</title>
<style>
:root {{
color-scheme: light;
--bg: #f4efe6;
--panel: #fffdf8;
--line: #d9cdb7;
--text: #1b1b18;
--muted: #625d53;
--accent: #1d6b5b;
--accent-soft: #d6efe8;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(29,107,91,0.14), transparent 28%),
linear-gradient(180deg, #fbf7ef 0%, var(--bg) 100%);
color: var(--text);
}}
main {{ max-width: 1180px; margin: 0 auto; padding: 40px 24px 64px; }}
header {{ margin-bottom: 28px; }}
h1 {{ margin: 0 0 8px; font-size: 2.4rem; }}
p, li {{ color: var(--muted); line-height: 1.6; }}
.cards {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 16px;
margin: 24px 0 36px;
}}
.card, .chart, .meta {{
background: rgba(255, 253, 248, 0.88);
border: 1px solid rgba(217, 205, 183, 0.85);
border-radius: 18px;
padding: 20px;
box-shadow: 0 16px 40px rgba(36, 31, 24, 0.05);
backdrop-filter: blur(10px);
}}
.card h2, .chart h2, .meta h2 {{ margin: 0 0 10px; font-size: 1rem; }}
.card p {{ margin: 0; font-size: 1.6rem; color: var(--text); }}
.charts {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 18px;
}}
svg {{ width: 100%; height: auto; overflow: visible; }}
.bar {{ fill: var(--accent); opacity: 0.9; }}
.axis {{ stroke: var(--line); stroke-width: 1.5; }}
.label {{
fill: var(--muted);
font-size: 10px;
text-anchor: middle;
}}
.count {{
fill: var(--text);
font-size: 11px;
text-anchor: middle;
font-weight: 700;
}}
ul {{ padding-left: 18px; }}
</style>
</head>
<body>
<main>
<header>
<h1>视频元数据分析报告</h1>
<p>扫描输入:{escape(", ".join(path.as_posix() for path in scanned_inputs))}</p>
</header>
{summary_cards}
<section class="charts">
{_render_bar_chart("时长分布", summary.duration_histogram)}
{_render_bar_chart("大小分布", summary.size_histogram)}
{_render_bar_chart("视频编码分布", summary.video_codecs)}
{_render_bar_chart("分辨率分布", summary.resolutions)}
</section>
<section class="meta">
<h2>工具版本</h2>
<ul>
<li>ffprobe: {escape(tool_versions.get("ffprobe", "unknown"))}</li>
<li>ffmpeg: {escape(tool_versions.get("ffmpeg", "unknown"))}</li>
</ul>
</section>
</main>
</body>
</html>
"""
output_path.write_text(html, encoding="utf-8")
def write_report_bundle(
*,
output_dir: Path,
scanned_inputs: list[Path],
records: list[VideoMetadata],
summary: AnalysisSummary,
tool_versions: dict[str, str],
) -> dict[str, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / "report.json"
csv_path = output_dir / "files.csv"
html_path = output_dir / "report.html"
_write_json(
json_path,
scanned_inputs=scanned_inputs,
records=records,
summary=summary,
tool_versions=tool_versions,
)
_write_csv(csv_path, records)
_write_html(
html_path,
scanned_inputs=scanned_inputs,
summary=summary,
tool_versions=tool_versions,
)
return {"json": json_path, "csv": csv_path, "html": html_path}
+1
View File
@@ -0,0 +1 @@
+83
View File
@@ -0,0 +1,83 @@
from pathlib import Path
from video_metadata_analyzer.analysis import build_summary
from video_metadata_analyzer.models import VideoMetadata
def test_build_summary_computes_totals_and_histograms() -> None:
records = [
VideoMetadata(
path=Path("a.mp4"),
size_bytes=50 * 1024 * 1024,
duration_seconds=30,
format_name="mov,mp4,m4a,3gp,3g2,mj2",
bit_rate=1_000_000,
video_codec="h264",
audio_codec="aac",
width=1920,
height=1080,
frame_rate=30.0,
video_stream_count=1,
audio_stream_count=1,
subtitle_stream_count=0,
),
VideoMetadata(
path=Path("b.mkv"),
size_bytes=700 * 1024 * 1024,
duration_seconds=600,
format_name="matroska,webm",
bit_rate=2_000_000,
video_codec="h264",
audio_codec="aac",
width=1280,
height=720,
frame_rate=24.0,
video_stream_count=1,
audio_stream_count=1,
subtitle_stream_count=0,
),
VideoMetadata(
path=Path("c.mov"),
size_bytes=2 * 1024 * 1024 * 1024,
duration_seconds=4200,
format_name="mov,mp4,m4a,3gp,3g2,mj2",
bit_rate=8_000_000,
video_codec="hevc",
audio_codec="aac",
width=3840,
height=2160,
frame_rate=60.0,
video_stream_count=1,
audio_stream_count=1,
subtitle_stream_count=0,
),
]
summary = build_summary(records)
assert summary.total_files == 3
assert summary.total_size_bytes == 2_933_915_648
assert summary.total_duration_seconds == 4830
assert [(item.label, item.count) for item in summary.duration_histogram] == [
("0-1 min", 1),
("1-5 min", 0),
("5-15 min", 1),
("15-60 min", 0),
("60+ min", 1),
]
assert [(item.label, item.count) for item in summary.size_histogram] == [
("0-100 MB", 1),
("100-500 MB", 0),
("500 MB-1 GB", 1),
("1-4 GB", 1),
("4+ GB", 0),
]
assert [(item.label, item.count) for item in summary.video_codecs] == [
("h264", 2),
("hevc", 1),
]
assert [(item.label, item.count) for item in summary.resolutions] == [
("1920x1080", 1),
("1280x720", 1),
("3840x2160", 1),
]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import json
from pathlib import Path
from video_metadata_analyzer.cli import main
def test_main_scans_input_directory_and_writes_reports(tmp_path: Path) -> None:
input_dir = tmp_path / "videos"
input_dir.mkdir()
sample = input_dir / "sample.mp4"
sample.write_bytes(b"x" * 2048)
output_dir = tmp_path / "output"
def fake_runner(command: list[str]) -> str:
if command[:2] == ["ffprobe", "-version"]:
return "ffprobe version 8.0.1\nbuilt with ...\n"
if command[:2] == ["ffmpeg", "-version"]:
return "ffmpeg version 8.0.1\nbuilt with ...\n"
if command[0] == "ffprobe":
return json.dumps(
{
"format": {
"filename": str(sample),
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "8.000000",
"bit_rate": "1024000",
"size": "2048",
},
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 1280,
"height": 720,
"avg_frame_rate": "25/1",
}
],
}
)
raise AssertionError(f"unexpected command: {command}")
exit_code = main(
[str(input_dir), "--output-dir", str(output_dir)],
command_runner=fake_runner,
)
assert exit_code == 0
assert (output_dir / "report.json").exists()
assert (output_dir / "files.csv").exists()
assert (output_dir / "report.html").exists()
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
from video_metadata_analyzer.discovery import discover_video_files
def test_discover_video_files_recursively_filters_supported_extensions(tmp_path: Path) -> None:
source_a = tmp_path / "source-a"
source_b = tmp_path / "source-b"
nested = source_a / "nested"
nested.mkdir(parents=True)
source_b.mkdir(parents=True)
keep_1 = source_a / "movie.mp4"
keep_2 = nested / "clip.MKV"
keep_3 = source_b / "recording.mov"
skip_1 = source_a / "notes.txt"
skip_2 = nested / "thumb.jpg"
for path in [keep_1, keep_2, keep_3, skip_1, skip_2]:
path.write_bytes(b"sample")
discovered = discover_video_files([source_a, source_b])
assert discovered == sorted([keep_1, keep_2, keep_3])
+5
View File
@@ -0,0 +1,5 @@
from video_metadata_analyzer import __version__
def test_package_version_is_exposed() -> None:
assert __version__
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import json
from pathlib import Path
from video_metadata_analyzer.probe import probe_video_metadata
def test_probe_video_metadata_parses_primary_streams(tmp_path: Path) -> None:
sample = tmp_path / "sample.mp4"
sample.write_bytes(b"x" * 4096)
captured_command: list[str] = []
def fake_runner(command: list[str]) -> str:
captured_command[:] = command
return json.dumps(
{
"format": {
"filename": str(sample),
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"duration": "12.500000",
"bit_rate": "2621440",
"size": "4096",
},
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"avg_frame_rate": "30000/1001",
},
{
"codec_type": "audio",
"codec_name": "aac",
},
],
}
)
result = probe_video_metadata(sample, runner=fake_runner)
assert captured_command[:4] == ["ffprobe", "-v", "quiet", "-print_format"]
assert result.path == sample
assert result.size_bytes == 4096
assert result.duration_seconds == 12.5
assert result.format_name == "mov,mp4,m4a,3gp,3g2,mj2"
assert result.bit_rate == 2621440
assert result.video_codec == "h264"
assert result.audio_codec == "aac"
assert result.width == 1920
assert result.height == 1080
assert result.frame_rate == 30000 / 1001
assert result.video_stream_count == 1
assert result.audio_stream_count == 1
assert result.subtitle_stream_count == 0
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import json
from pathlib import Path
from video_metadata_analyzer.analysis import build_summary
from video_metadata_analyzer.models import VideoMetadata
from video_metadata_analyzer.reporting import write_report_bundle
def test_write_report_bundle_creates_json_csv_and_html(tmp_path: Path) -> None:
output_dir = tmp_path / "report"
record = VideoMetadata(
path=Path("videos/sample.mp4"),
size_bytes=50 * 1024 * 1024,
duration_seconds=120,
format_name="mov,mp4,m4a,3gp,3g2,mj2",
bit_rate=1_500_000,
video_codec="h264",
audio_codec="aac",
width=1920,
height=1080,
frame_rate=30.0,
video_stream_count=1,
audio_stream_count=1,
subtitle_stream_count=0,
)
summary = build_summary([record])
paths = write_report_bundle(
output_dir=output_dir,
scanned_inputs=[Path("videos")],
records=[record],
summary=summary,
tool_versions={"ffprobe": "ffprobe version x", "ffmpeg": "ffmpeg version y"},
)
assert set(paths) == {"json", "csv", "html"}
assert paths["json"].exists()
assert paths["csv"].exists()
assert paths["html"].exists()
payload = json.loads(paths["json"].read_text(encoding="utf-8"))
assert payload["summary"]["total_files"] == 1
assert payload["tool_versions"]["ffmpeg"] == "ffmpeg version y"
assert payload["files"][0]["path"] == "videos/sample.mp4"
csv_text = paths["csv"].read_text(encoding="utf-8")
assert "path,size_bytes,duration_seconds" in csv_text
assert "videos/sample.mp4" in csv_text
html_text = paths["html"].read_text(encoding="utf-8")
assert "视频元数据分析报告" in html_text
assert "时长分布" in html_text
assert "<svg" in html_text
Generated
+79
View File
@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "video-metadata-analyzer"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.4.2" }]