feat: 支持自定义时长和大小分桶粒度

This commit is contained in:
kim
2026-04-02 22:07:17 +08:00
parent f243664acf
commit ff0b9228f8
6 changed files with 356 additions and 46 deletions
+29 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path
from video_metadata_analyzer.analysis import build_summary
from video_metadata_analyzer.analysis import MEGABYTE, SummaryOptions, build_summary
from video_metadata_analyzer.models import ProbeFailure, VideoMetadata
@@ -145,6 +145,34 @@ def test_build_summary_uses_finer_buckets_for_large_sample_sets_with_outliers()
assert summary.size_histogram[0].label == "0-500 MB"
def test_build_summary_respects_explicit_histogram_steps() -> None:
records = [
_build_record("a.mp4", size_mb=10, duration_seconds=25),
_build_record("b.mp4", size_mb=40, duration_seconds=65),
_build_record("c.mp4", size_mb=60, duration_seconds=115),
]
summary = build_summary(
records,
summary_options=SummaryOptions(
duration_bucket_seconds=30,
size_bucket_bytes=25 * MEGABYTE,
),
)
assert [(item.label, item.count) for item in summary.duration_histogram] == [
("0-30 s", 1),
("30-60 s", 0),
("60-90 s", 1),
("90-120 s", 1),
]
assert [(item.label, item.count) for item in summary.size_histogram] == [
("0-25 MB", 1),
("25-50 MB", 1),
("50-75 MB", 1),
]
def test_build_summary_tracks_successful_and_failed_files() -> None:
records = [
VideoMetadata(
+146
View File
@@ -53,6 +53,72 @@ def test_main_scans_input_directory_and_writes_reports(tmp_path: Path) -> None:
assert (output_dir / "report.html").exists()
def test_main_writes_reports_with_explicit_histogram_granularity(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": "65.000000",
"bit_rate": "1024000",
"size": str(60 * 1024 * 1024),
},
"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),
"--duration-bucket-seconds",
"30",
"--size-bucket-megabytes",
"25",
],
command_runner=fake_runner,
)
assert exit_code == 0
payload = json.loads((output_dir / "report.json").read_text(encoding="utf-8"))
assert payload["summary_options"] == {
"duration_bucket_seconds": 30.0,
"size_bucket_bytes": 25 * 1024 * 1024,
}
assert payload["summary"]["duration_histogram"] == [
{"label": "0-30 s", "count": 0},
{"label": "30-60 s", "count": 0},
{"label": "60-90 s", "count": 1},
]
assert payload["summary"]["size_histogram"] == [
{"label": "0-25 MB", "count": 0},
{"label": "25-50 MB", "count": 0},
{"label": "50-75 MB", "count": 1},
]
def test_main_continues_when_single_file_probe_fails(tmp_path: Path) -> None:
input_dir = tmp_path / "videos"
input_dir.mkdir()
@@ -228,3 +294,83 @@ def test_main_rebuilds_html_from_existing_report_json_without_running_probe(tmp_
assert payload["summary"]["duration_histogram"] == [{"label": "0-2 min", "count": 1}]
assert payload["summary"]["size_histogram"] == [{"label": "0-100 MB", "count": 1}]
assert "0-2 min" in html_text
def test_main_rebuilds_report_json_with_saved_histogram_granularity_and_cli_override(tmp_path: Path) -> None:
output_dir = tmp_path / "report"
output_dir.mkdir()
report_json = output_dir / "report.json"
report_json.write_text(
json.dumps(
{
"generated_at": "2026-04-02T12:00:00",
"scanned_inputs": ["videos"],
"tool_versions": {"ffprobe": "cached ffprobe", "ffmpeg": "cached ffmpeg"},
"summary_options": {
"duration_bucket_seconds": 30.0,
"size_bucket_bytes": 25 * 1024 * 1024,
},
"summary": {
"total_files": 1,
"successful_files": 1,
"failed_files": 0,
"total_size_bytes": 0,
"total_duration_seconds": 0,
"duration_histogram": [{"label": "0-30 s", "count": 1}],
"size_histogram": [{"label": "0-25 MB", "count": 1}],
"video_codecs": [],
"resolutions": [],
},
"files": [
{
"path": "videos/sample.mp4",
"size_bytes": 60 * 1024 * 1024,
"duration_seconds": 65.0,
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"bit_rate": 1024000,
"video_codec": "h264",
"audio_codec": "aac",
"width": 1280,
"height": 720,
"frame_rate": 25.0,
"video_stream_count": 1,
"audio_stream_count": 1,
"subtitle_stream_count": 0,
}
],
"failures": [],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def forbidden_runner(command: list[str]) -> str:
raise AssertionError(f"should not invoke external command: {command}")
exit_code = main(
[
"--report-json",
str(report_json),
"--duration-bucket-seconds",
"60",
],
command_runner=forbidden_runner,
)
assert exit_code == 0
payload = json.loads(report_json.read_text(encoding="utf-8"))
assert payload["summary_options"] == {
"duration_bucket_seconds": 60.0,
"size_bucket_bytes": 25 * 1024 * 1024,
}
assert payload["summary"]["duration_histogram"] == [
{"label": "0-1 min", "count": 0},
{"label": "1-2 min", "count": 1},
]
assert payload["summary"]["size_histogram"] == [
{"label": "0-25 MB", "count": 0},
{"label": "25-50 MB", "count": 0},
{"label": "50-75 MB", "count": 1},
]