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
+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