From 542b5a56418f348473e3c08909219f8dd629f73e Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 06:23:57 -0700 Subject: [PATCH] feat: mechanical mode/duration/resolution tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per sub-project-2 plan Task 4 / spec §5.1. derive_tags(Probe) -> Tags with the attached_pic cover-art guard so an art-bearing audio file stays mode=audio. Duration falls back to the longest stream when format.duration is absent, and rounds half-up so 12.5s -> 13s (Python's round() would give 12 via banker's rounding) to match the spec's stated behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_tagging.py | 94 +++++++++++++++++++++++++++++++++++++++++++ tools/tagging.py | 65 ++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/test_tagging.py create mode 100644 tools/tagging.py diff --git a/tests/test_tagging.py b/tests/test_tagging.py new file mode 100644 index 0000000..674c480 --- /dev/null +++ b/tests/test_tagging.py @@ -0,0 +1,94 @@ +from tools.probe import Probe +from tools.tagging import Tags, derive_tags + + +def test_video_and_audio_is_av(): + p = Probe( + streams=[ + { + "codec_type": "video", + "width": 1920, + "height": 1080, + "disposition": {"attached_pic": 0}, + }, + {"codec_type": "audio"}, + ], + format={"duration": "12.5"}, + ) + t = derive_tags(p) + assert isinstance(t, Tags) + assert t.mode == "av" + assert t.resolution == "1920x1080" + assert t.duration_s == 13 # round(12.5) + + +def test_audio_only(): + p = Probe(streams=[{"codec_type": "audio"}], format={"duration": "30.0"}) + t = derive_tags(p) + assert t.mode == "audio" and t.resolution == "" and t.duration_s == 30 + + +def test_cover_art_stays_audio(): + p = Probe( + streams=[ + {"codec_type": "audio", "duration": "30.0"}, + { + "codec_type": "video", + "width": 600, + "height": 600, + "disposition": {"attached_pic": 1}, + }, + ], + format={"duration": "30.0"}, + ) + t = derive_tags(p) + assert t.mode == "audio" and t.resolution == "" and t.duration_s == 30 + + +def test_video_only(): + p = Probe( + streams=[ + { + "codec_type": "video", + "width": 1280, + "height": 720, + "disposition": {"attached_pic": 0}, + } + ], + format={"duration": "5.0"}, + ) + t = derive_tags(p) + assert t.mode == "video" and t.resolution == "1280x720" + + +def test_duration_falls_back_to_longest_stream(): + p = Probe( + streams=[ + {"codec_type": "audio", "duration": "10.0"}, + { + "codec_type": "video", + "width": 640, + "height": 480, + "duration": "42.4", + "disposition": {"attached_pic": 0}, + }, + ], + format={}, + ) + t = derive_tags(p) + assert t.duration_s == 42 # round(42.4), longest stream + + +def test_duration_never_negative_and_zero_default(): + p = Probe(streams=[{"codec_type": "audio"}], format={}) + t = derive_tags(p) + assert t.duration_s == 0 + + +def test_video_without_dimensions_yields_empty_resolution(): + p = Probe( + streams=[{"codec_type": "video", "disposition": {"attached_pic": 0}}], + format={"duration": "3.0"}, + ) + t = derive_tags(p) + assert t.mode == "video" and t.resolution == "" diff --git a/tools/tagging.py b/tools/tagging.py new file mode 100644 index 0000000..3728d63 --- /dev/null +++ b/tools/tagging.py @@ -0,0 +1,65 @@ +"""Mechanical tagging: derive mode/duration_s/resolution from a Probe. + +Honors the cover-art guard (spec §5.1): an embedded album-art image in an audio +file shows up as a video stream with disposition.attached_pic == 1; it must NOT +make the file 'av'. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from tools.probe import Probe + + +@dataclass +class Tags: + mode: str + duration_s: int + resolution: str + + +def _is_real_video(stream) -> bool: + if stream.get("codec_type") != "video": + return False + return stream.get("disposition", {}).get("attached_pic", 0) != 1 + + +def _parse_duration(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def derive_tags(probe: Probe) -> Tags: + real_video = [s for s in probe.streams if _is_real_video(s)] + has_audio = any(s.get("codec_type") == "audio" for s in probe.streams) + + if real_video and has_audio: + mode = "av" + elif real_video: + mode = "video" + else: + mode = "audio" + + resolution = "" + if real_video: + v = real_video[0] + w, h = v.get("width"), v.get("height") + if w and h: + resolution = f"{w}x{h}" + + if "duration" in probe.format: + seconds = _parse_duration(probe.format.get("duration")) + else: + seconds = max( + (_parse_duration(s.get("duration")) for s in probe.streams), + default=0.0, + ) + + # Round half up (math.floor(x + 0.5)) rather than Python's banker's + # rounding, so a 12.5s clip becomes 13s as the spec intends. + duration_s = max(0, math.floor(seconds + 0.5)) + return Tags(mode=mode, duration_s=duration_s, resolution=resolution)