From 6fe1b59a8c97a0460859e81fd3e96e8caf82147c Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 10 Sep 2026 23:34:23 -0500 Subject: [PATCH 1/5] media_info: probe duration, format and level of a generated file Co-Authored-By: Claude Opus 5 (1M context) --- dw/media_info.py | 79 +++++++++++++++++++++++++++ tests/test_media_info.py | 113 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 dw/media_info.py create mode 100644 tests/test_media_info.py diff --git a/dw/media_info.py b/dw/media_info.py new file mode 100644 index 0000000..e1331cc --- /dev/null +++ b/dw/media_info.py @@ -0,0 +1,79 @@ +"""What the server knows about a generated media file and would otherwise +not say. An agent cannot listen: duration against a ceiling, peak against +a normalization target and the level at a seam are the only checks it can +make on an audio deliverable, and every one of them was being made by +fetching the file and running ffprobe by hand. +""" + +import logging +import math + +import av +import numpy + +logger = logging.getLogger("dw") + +# The floor a level is reported at rather than -inf, which JSON cannot carry +SILENCE_DBFS = -120.0 + + +def _dbfs(value): + if value <= 0: + return SILENCE_DBFS + return max(SILENCE_DBFS, 20.0 * math.log10(float(value))) + + +def _audio_levels(container, stream): + """Peak and rms of the whole decoded track, in dBFS.""" + peak = 0.0 + total = 0.0 + count = 0 + for frame in container.decode(stream): + samples = frame.to_ndarray() + if samples.dtype.kind in "iu": + samples = samples.astype(numpy.float32) / numpy.iinfo(samples.dtype).max + samples = samples.astype(numpy.float32) + peak = max(peak, float(numpy.abs(samples).max(initial=0.0))) + total += float(numpy.square(samples).sum()) + count += samples.size + rms = math.sqrt(total / count) if count else 0.0 + return _dbfs(peak), _dbfs(rms) + + +def probe_media(path): + """Duration, format and level of an audio or video file, or None. + + Video answers fps, frame_count, width and height, plus the soundtrack's + sample_rate, channels, peak_dbfs and mean_dbfs when it carries one; + audio answers the soundtrack fields. Levels come from decoding the + whole track, which is cheap next to generating it. + """ + try: + container = av.open(path) + except Exception as e: + logger.debug(f"Not probeable as media: {path}: {e}") + return None + with container: + video = container.streams.video[0] if container.streams.video else None + audio = container.streams.audio[0] if container.streams.audio else None + if video is None and audio is None: + return None + info = {} + if video is not None: + info["kind"] = "video" + info["fps"] = float(video.average_rate) if video.average_rate else None + if video.frames: + info["frame_count"] = int(video.frames) + else: + info["frame_count"] = sum(1 for _ in container.decode(video)) + info["width"] = int(video.width) + info["height"] = int(video.height) + else: + info["kind"] = "audio" + if container.duration is not None: + info["duration_seconds"] = container.duration / av.time_base + if audio is not None: + info["sample_rate"] = int(audio.rate) + info["channels"] = int(audio.channels) + info["peak_dbfs"], info["mean_dbfs"] = _audio_levels(container, audio) + return info diff --git a/tests/test_media_info.py b/tests/test_media_info.py new file mode 100644 index 0000000..2e4c2fb --- /dev/null +++ b/tests/test_media_info.py @@ -0,0 +1,113 @@ +"""probe_media reports what the server knows about a generated file and +would otherwise not say - an agent cannot listen, so duration and level +are the only way it checks an audio deliverable.""" + +import math + +import numpy +import pytest + +from dw.media_info import probe_media + + +def write_wav(path, seconds=2.0, sample_rate=8000, amplitude=0.5): + import wave + + t = numpy.arange(int(seconds * sample_rate)) / sample_rate + samples = (numpy.sin(2 * numpy.pi * 220 * t) * amplitude * 32767).astype(" Date: Thu, 10 Sep 2026 23:38:52 -0500 Subject: [PATCH 2/5] media_info: count frames and read levels in one pass Co-Authored-By: Claude Opus 5 (1M context) --- dw/media_info.py | 62 ++++++++++++++++++++++++++-------------- tests/test_media_info.py | 26 +++++++++++++++++ 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/dw/media_info.py b/dw/media_info.py index e1331cc..22b75ee 100644 --- a/dw/media_info.py +++ b/dw/media_info.py @@ -23,23 +23,6 @@ def _dbfs(value): return max(SILENCE_DBFS, 20.0 * math.log10(float(value))) -def _audio_levels(container, stream): - """Peak and rms of the whole decoded track, in dBFS.""" - peak = 0.0 - total = 0.0 - count = 0 - for frame in container.decode(stream): - samples = frame.to_ndarray() - if samples.dtype.kind in "iu": - samples = samples.astype(numpy.float32) / numpy.iinfo(samples.dtype).max - samples = samples.astype(numpy.float32) - peak = max(peak, float(numpy.abs(samples).max(initial=0.0))) - total += float(numpy.square(samples).sum()) - count += samples.size - rms = math.sqrt(total / count) if count else 0.0 - return _dbfs(peak), _dbfs(rms) - - def probe_media(path): """Duration, format and level of an audio or video file, or None. @@ -47,6 +30,12 @@ def probe_media(path): sample_rate, channels, peak_dbfs and mean_dbfs when it carries one; audio answers the soundtrack fields. Levels come from decoding the whole track, which is cheap next to generating it. + + When a frame count still needs counting and/or a soundtrack still needs + its levels measured, both are gathered from a single decode pass over + whichever streams are involved - `container.decode()` demuxes to EOF, so + two separate passes (count video, then decode audio) would leave the + second one nothing to read. """ try: container = av.open(path) @@ -62,10 +51,6 @@ def probe_media(path): if video is not None: info["kind"] = "video" info["fps"] = float(video.average_rate) if video.average_rate else None - if video.frames: - info["frame_count"] = int(video.frames) - else: - info["frame_count"] = sum(1 for _ in container.decode(video)) info["width"] = int(video.width) info["height"] = int(video.height) else: @@ -75,5 +60,38 @@ def probe_media(path): if audio is not None: info["sample_rate"] = int(audio.rate) info["channels"] = int(audio.channels) - info["peak_dbfs"], info["mean_dbfs"] = _audio_levels(container, audio) + + # Some muxers don't write a frame count up front (0 means "count + # them"); a soundtrack always needs decoding to measure its level. + # Do both together, since decoding is a one-way trip through the file. + need_frame_count = video is not None and not video.frames + if video is not None and not need_frame_count: + info["frame_count"] = int(video.frames) + + if need_frame_count or audio is not None: + frame_count = 0 + peak = 0.0 + total = 0.0 + count = 0 + streams = [s for s in (video, audio) if s is not None] + for frame in container.decode(*streams): + if isinstance(frame, av.VideoFrame): + frame_count += 1 + elif isinstance(frame, av.AudioFrame): + samples = frame.to_ndarray() + if samples.dtype.kind in "iu": + samples = ( + samples.astype(numpy.float32) + / numpy.iinfo(samples.dtype).max + ) + samples = samples.astype(numpy.float32) + peak = max(peak, float(numpy.abs(samples).max(initial=0.0))) + total += float(numpy.square(samples).sum()) + count += samples.size + if need_frame_count: + info["frame_count"] = frame_count + if audio is not None: + rms = math.sqrt(total / count) if count else 0.0 + info["peak_dbfs"] = _dbfs(peak) + info["mean_dbfs"] = _dbfs(rms) return info diff --git a/tests/test_media_info.py b/tests/test_media_info.py index 2e4c2fb..3cf699c 100644 --- a/tests/test_media_info.py +++ b/tests/test_media_info.py @@ -88,6 +88,32 @@ def test_a_video_reports_its_picture_and_its_soundtrack(tmp_path): assert info["peak_dbfs"] == pytest.approx(-12.0, abs=1.5) +def test_a_container_with_no_upfront_frame_count_still_reads_both_passes( + tmp_path, +): + """Matroska doesn't write a frame count into the stream header the way + mp4 does, so `video.frames` comes back 0 and probe_media must count + frames by decoding - in the same pass that measures the soundtrack, since + a second decode pass over an already-exhausted demuxer reads nothing.""" + import av + + path = tmp_path / "shot.mkv" + write_mp4(path, frames=12, fps=6, width=32, height=16) + + with av.open(str(path)) as container: + assert container.streams.video[0].frames == 0, ( + "fixture assumption broken: this container format now writes " + "a frame count up front, so it no longer exercises the " + "fallback-counting path probe_media relies on" + ) + + info = probe_media(str(path)) + + assert info["kind"] == "video" + assert info["frame_count"] == 12 + assert info["peak_dbfs"] == pytest.approx(-12.0, abs=1.5) + + def test_a_silent_video_has_no_audio_fields(tmp_path): write_mp4(tmp_path / "mute.mp4", with_audio=False) From 16dd5eaafb80e0a86d46837507b5c825937acd02 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 10 Sep 2026 23:42:29 -0500 Subject: [PATCH 3/5] Gallery metadata: describe audio and video files Co-Authored-By: Claude Opus 5 (1M context) --- dw/server/app.py | 13 +++++++++++-- tests/test_server.py | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/dw/server/app.py b/dw/server/app.py index f6456ec..195936d 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -55,6 +55,7 @@ from .enhancers import build_enhance_workflow, preset_descriptions from .exports import export_directory, export_job from ..result import read_embedded_metadata +from ..media_info import probe_media from ..hub_cache import scan_models, delete_model, DownloadManager from ..runs import strip_run_id from ..workspace import ( @@ -1821,7 +1822,9 @@ def gallery( def gallery_metadata(name: str, ws: Workspace = Depends(selected_workspace)): """Generation metadata embedded in a saved image ('workflow' inside it is the full definition the editor can reopen), plus the job that - produced the file when history remembers one.""" + produced the file when history remembers one, plus - for audio and + video - what the file itself holds: duration, format and level, + which is how an agent that cannot listen checks a track.""" path = _output_file(name, ws.outputs) metadata = read_embedded_metadata(path) try: @@ -1831,7 +1834,13 @@ def gallery_metadata(name: str, ws: Workspace = Depends(selected_workspace)): job = manager.history.job_for_file(name, workspace=ws.name) except Exception: job = None - return {"name": name, "metadata": metadata, "job": job} + extension = os.path.splitext(path)[1].lower() + media = ( + probe_media(path) + if MEDIA_KINDS.get(extension) in ("audio", "video") + else None + ) + return {"name": name, "metadata": metadata, "job": job, "media": media} @app.get("/api/gallery/{name:path}/thumbnail") @query_token_ok diff --git a/tests/test_server.py b/tests/test_server.py index 5014aba..6eaaee0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1109,6 +1109,33 @@ def test_gallery_lists_media_and_reads_metadata(server, tmp_path): assert read_embedded_metadata(str(outputs / "meta.jpg"))["step_name"] == "gen" +def test_gallery_metadata_describes_audio_and_video(server, tmp_path): + """A generated mp3 answered metadata: null and nothing else, so every + duration and level check was ffprobe by hand. The route now says what + the server knows.""" + from PIL import Image + from tests.test_media_info import write_mp4, write_wav + + with server(success_script) as client: + outputs = tmp_path / "outputs" + write_wav(outputs / "score-gen.0-0.0.wav", seconds=2.0) + write_mp4(outputs / "shot-gen.0-0.0.mp4", frames=12, fps=6) + Image.new("RGB", (4, 4)).save(outputs / "still-gen.0-0.0.png") + + score = client.get("/api/gallery/score-gen.0-0.0.wav/metadata").json() + assert score["metadata"] is None + assert score["media"]["kind"] == "audio" + assert score["media"]["duration_seconds"] == pytest.approx(2.0, abs=0.01) + assert score["media"]["channels"] == 2 + + shot = client.get("/api/gallery/shot-gen.0-0.0.mp4/metadata").json() + assert shot["media"]["kind"] == "video" + assert shot["media"]["frame_count"] == 12 + + still = client.get("/api/gallery/still-gen.0-0.0.png/metadata").json() + assert still["media"] is None + + def test_gallery_paginates_and_groups_by_workflow_folder(server, tmp_path): """Outputs nested under a workflow subfolder (dw/workflow.py's effective_output_dir) still show up in the gallery, tagged with their From 2e1016bd1d573d5af5df9b07b0981a4144f574bc Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 10 Sep 2026 23:43:58 -0500 Subject: [PATCH 4/5] get_gallery_metadata: surface the media block and how to read it Co-Authored-By: Claude Opus 5 (1M context) --- docs/MCP.md | 2 +- dw_mcp/catalog.py | 16 +++++++++++-- dw_mcp/server.py | 4 +++- plugins/dw/skills/minimax-music3/SKILL.md | 6 +++-- tests/test_mcp_catalog.py | 29 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index d8f93e3..fd3937a 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -225,7 +225,7 @@ when no single workflow covers it. | `get_server_info()` | — | What this installation can do and where it keeps things: `device` (the accelerator a run will use), `version`, the `workspace` this session is working in and the workflow/asset/output/prompt `directories` of *that* workspace, the bind address and port, whether a token is required, and whether MCP is mounted. Check the device before authoring - a CUDA-only choice (bitsandbytes, `torch.compile`, flash attention) is not available on an `mps` or `cpu` server | | `list_jobs()` | — | List queued, running and recent jobs. In a named workspace, that workspace's jobs; in the default one, every job the server holds | | `list_gallery(limit=50)` | `limit` | List generated output files, newest first. A name is `//`; each entry also carries a ready-made `url`, already scoped to the workspace that made it - a hand-built `/outputs/` URL 404s for anything but the default workspace | -| `get_gallery_metadata(name)` | `name` | Get the metadata embedded in a generated file: the exact workflow and arguments that produced it | +| `get_gallery_metadata(name)` | `name` | Get the metadata embedded in a generated file: the exact workflow and arguments that produced it, and, for audio/video, a `media` block (duration, rate, channels, fps, size, peak/mean dBFS) | ### Media diff --git a/dw_mcp/catalog.py b/dw_mcp/catalog.py index 9a5a3ac..2a48d1f 100644 --- a/dw_mcp/catalog.py +++ b/dw_mcp/catalog.py @@ -103,5 +103,17 @@ def list_gallery(client, limit=50): def get_gallery_metadata(client, name): """Metadata embedded in a saved file: the full workflow that made it, - plus the job that produced it when history remembers one.""" - return client.get_json(api_path("api", "gallery", name, "metadata")) + plus the job that produced it when history remembers one, plus for + audio and video what the file holds - duration, sample rate, channels, + fps, size, peak and mean level in dBFS.""" + body = client.get_json(api_path("api", "gallery", name, "metadata")) + media = body.get("media") + if media and media.get("kind") in ("audio", "video"): + body["next"] = ( + "Check duration_seconds against what was asked for: a Music 3 " + "track that lands within 0.2 s of its audio_duration ceiling was " + "cut off, one well short of it finished naturally. peak_dbfs is " + "the level normalize_audio would be given; mean_dbfs below -40 " + "on a track that should be full is a near-silent render." + ) + return body diff --git a/dw_mcp/server.py b/dw_mcp/server.py index 533b6c1..61401e2 100644 --- a/dw_mcp/server.py +++ b/dw_mcp/server.py @@ -289,7 +289,9 @@ def get_gallery_metadata(name: str) -> dict: workflow, arguments and seed that produced it. Use this to reproduce a result, or to see what a run that went wrong actually ran - it is the definition, not a summary, so it can be edited and - re-run.""" + re-run. For audio and video the `media` block carries duration, + sample rate, channels, fps, size and level - the checks an agent + that cannot listen makes on a deliverable.""" return catalog.get_gallery_metadata(client, name) def list_guides() -> dict: diff --git a/plugins/dw/skills/minimax-music3/SKILL.md b/plugins/dw/skills/minimax-music3/SKILL.md index d164017..b0d5f85 100644 --- a/plugins/dw/skills/minimax-music3/SKILL.md +++ b/plugins/dw/skills/minimax-music3/SKILL.md @@ -116,8 +116,10 @@ Control" section. 4. You cannot listen: no tool returns audio inline. Hand the user the gallery `url` (`list_gallery`, or the manifest's file name) and check what you can yourself - `get_gallery_metadata` for the file's duration against the - ceiling (a track well short of it stopped on its own; one exactly at it was - cut) and the sample rate. Ask the user to listen for the family's failure + ceiling: `media.duration_seconds` within 0.2 s of `audio_duration` means + the ceiling cut the track (raise it and rerun); well short of it means the + song finished on its own. Also check the sample rate. Ask the user to + listen for the family's failure modes: a song that went instrumental (name the vocals in the caption), an ending cut mid-note (raise the ceiling, then trim), a structure that ignored the tags (fewer sections, plainer directions). diff --git a/tests/test_mcp_catalog.py b/tests/test_mcp_catalog.py index fc3f776..38a6ca0 100644 --- a/tests/test_mcp_catalog.py +++ b/tests/test_mcp_catalog.py @@ -21,6 +21,20 @@ def handler(request): return DwClient(transport=httpx.MockTransport(handler)), seen +def scripted(routes): + """A client whose transport answers a fixed map of (method, path) -> + (status, body), for a route the recording_client above can't.""" + + def handler(request): + key = (request.method, request.url.path) + if key not in routes: + return httpx.Response(404, json={"detail": f"unrouted {key}"}) + status, body = routes[key] + return httpx.Response(status, json=body) + + return DwClient(transport=httpx.MockTransport(handler)), None + + @pytest.mark.parametrize( "call, path", [ @@ -146,3 +160,18 @@ def handler(request): catalog.get_workflow(client, "../escape") assert seen["raw_path"] == b"/api/workflows/..%2Fescape" + + +def test_gallery_metadata_passes_the_media_block_through_and_says_how_to_read_it(): + body = { + "name": "score.mp3", + "metadata": None, + "job": {"id": "job-1", "status": "succeeded"}, + "media": {"kind": "audio", "duration_seconds": 45.05, "peak_dbfs": -1.0}, + } + client, _ = scripted({("GET", "/api/gallery/score.mp3/metadata"): (200, body)}) + + result = catalog.get_gallery_metadata(client, "score.mp3") + + assert result["media"]["duration_seconds"] == 45.05 + assert "audio_duration" in result["next"] From 4059d08aabb0af4e12238e1148ec4122abecc5b1 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Thu, 10 Sep 2026 23:54:33 -0500 Subject: [PATCH 5/5] media_info: survive a damaged track, decode only what is needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - probe_media now wraps the decode loop in try/except: a track that opens fine but fails mid-decode (damage past the header) falls back to the header-level fields already gathered (kind, fps, width, height, duration_seconds, sample_rate, channels) instead of 500ing the gallery metadata route, matching read_embedded_metadata's precedent of degrading rather than raising. - Only decode the video stream when a frame count still needs counting; a container with an upfront frame count no longer pays for a video decode it doesn't need. - duration_seconds is now wrapped in float() - older PyAV exposed time_base as a Fraction, which JSON can't serialize. - Unsigned 8-bit PCM is offset-binary (silence = 128): recenter before scaling instead of dividing raw samples by iinfo.max, which reported silence as -6 dBFS. - write_mp4's test fixture now writes a real 220 Hz tone instead of a DC signal, letting the mp4 peak_dbfs tolerance tighten from ±1.5 to ±1.0. Co-Authored-By: Claude Opus 5 (1M context) --- dw/media_info.py | 49 +++++++++++++++++++++++++++------------- tests/test_media_info.py | 48 ++++++++++++++++++++++++++++++++++++--- tests/test_server.py | 25 ++++++++++++++++++++ 3 files changed, 103 insertions(+), 19 deletions(-) diff --git a/dw/media_info.py b/dw/media_info.py index 22b75ee..ebeeb67 100644 --- a/dw/media_info.py +++ b/dw/media_info.py @@ -56,7 +56,7 @@ def probe_media(path): else: info["kind"] = "audio" if container.duration is not None: - info["duration_seconds"] = container.duration / av.time_base + info["duration_seconds"] = float(container.duration / av.time_base) if audio is not None: info["sample_rate"] = int(audio.rate) info["channels"] = int(audio.channels) @@ -73,21 +73,38 @@ def probe_media(path): peak = 0.0 total = 0.0 count = 0 - streams = [s for s in (video, audio) if s is not None] - for frame in container.decode(*streams): - if isinstance(frame, av.VideoFrame): - frame_count += 1 - elif isinstance(frame, av.AudioFrame): - samples = frame.to_ndarray() - if samples.dtype.kind in "iu": - samples = ( - samples.astype(numpy.float32) - / numpy.iinfo(samples.dtype).max - ) - samples = samples.astype(numpy.float32) - peak = max(peak, float(numpy.abs(samples).max(initial=0.0))) - total += float(numpy.square(samples).sum()) - count += samples.size + streams = [ + s + for s in ((video if need_frame_count else None), audio) + if s is not None + ] + try: + for frame in container.decode(*streams): + if isinstance(frame, av.VideoFrame): + frame_count += 1 + elif isinstance(frame, av.AudioFrame): + samples = frame.to_ndarray() + if samples.dtype.kind == "u": + iinfo = numpy.iinfo(samples.dtype) + half = (iinfo.max + 1) / 2 + samples = (samples.astype(numpy.float32) - half) / half + elif samples.dtype.kind == "i": + samples = ( + samples.astype(numpy.float32) + / numpy.iinfo(samples.dtype).max + ) + samples = samples.astype(numpy.float32) + peak = max(peak, float(numpy.abs(samples).max(initial=0.0))) + total += float(numpy.square(samples).sum()) + count += samples.size + except Exception as e: + # A track that opens fine can still fail mid-decode (damage + # past the header); the fields already gathered - duration, + # format - are still true, so report those rather than + # failing the whole probe. Matches read_embedded_metadata's + # precedent of degrading rather than raising. + logger.debug(f"Decode failed partway through {path}: {e}") + return info if need_frame_count: info["frame_count"] = frame_count if audio is not None: diff --git a/tests/test_media_info.py b/tests/test_media_info.py index 3cf699c..aef76bd 100644 --- a/tests/test_media_info.py +++ b/tests/test_media_info.py @@ -39,7 +39,9 @@ def write_mp4(path, frames=12, fps=6, width=32, height=16, with_audio=True): container.mux(packet) if audio is not None: total = 8000 * frames // fps - tone = numpy.stack([numpy.full(total, 0.25, numpy.float32)] * 2) + t = numpy.arange(total) / 8000 + sine = (numpy.sin(2 * numpy.pi * 220 * t) * 0.25).astype(numpy.float32) + tone = numpy.stack([sine, sine]) for start in range(0, total, 1024): chunk = av.AudioFrame.from_ndarray( numpy.ascontiguousarray(tone[:, start : start + 1024]), @@ -85,7 +87,7 @@ def test_a_video_reports_its_picture_and_its_soundtrack(tmp_path): assert info["channels"] == 2 # AAC's lossy encode shifts the peak beyond the raw -12 dBFS the tone was # written at; widen the tolerance rather than the wav assertions above. - assert info["peak_dbfs"] == pytest.approx(-12.0, abs=1.5) + assert info["peak_dbfs"] == pytest.approx(-12.0, abs=1.0) def test_a_container_with_no_upfront_frame_count_still_reads_both_passes( @@ -111,7 +113,7 @@ def test_a_container_with_no_upfront_frame_count_still_reads_both_passes( assert info["kind"] == "video" assert info["frame_count"] == 12 - assert info["peak_dbfs"] == pytest.approx(-12.0, abs=1.5) + assert info["peak_dbfs"] == pytest.approx(-12.0, abs=1.0) def test_a_silent_video_has_no_audio_fields(tmp_path): @@ -137,3 +139,43 @@ def test_a_file_that_is_not_media_answers_none(tmp_path): (tmp_path / "notes.txt").write_text("not media") assert probe_media(str(tmp_path / "notes.txt")) is None + + +def test_unsigned_8bit_silence_is_not_reported_as_loud(tmp_path): + """u8 PCM is offset-binary - silence is the byte 128, not 0 - so dividing + raw samples by iinfo.max without recentering reports silence around + -6 dBFS instead of the floor.""" + import wave + + path = tmp_path / "quiet-u8.wav" + with wave.open(str(path), "w") as handle: + handle.setnchannels(1) + handle.setsampwidth(1) + handle.setframerate(8000) + handle.writeframes(bytes([128]) * 8000) + + info = probe_media(str(path)) + + assert info["peak_dbfs"] == -120.0 + + +def test_a_damaged_track_still_reports_header_fields(tmp_path): + """A file that opens fine but fails mid-decode (a track damaged after + the header was written) must not 500 the metadata route - it should + fall back to the header-level fields and drop the fields that require + a full decode.""" + path = tmp_path / "shot.mkv" + write_mp4(path, frames=12, fps=6, width=32, height=16) + + raw = bytearray(path.read_bytes()) + mid = len(raw) // 2 + for i in range(mid, len(raw), 64): + raw[i] = (raw[i] + 137) % 256 + path.write_bytes(bytes(raw)) + + info = probe_media(str(path)) + + assert info is not None + assert info["kind"] == "video" + assert "peak_dbfs" not in info + assert "frame_count" not in info diff --git a/tests/test_server.py b/tests/test_server.py index 6eaaee0..32332b5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1136,6 +1136,31 @@ def test_gallery_metadata_describes_audio_and_video(server, tmp_path): assert still["media"] is None +def test_gallery_metadata_survives_a_damaged_track(server, tmp_path): + """A track that opens fine but fails partway through decode (damage + past the header) must not 500 the metadata route - it should fall back + to the header-level fields probe_media could still gather.""" + from tests.test_media_info import write_mp4 + + with server(success_script) as client: + outputs = tmp_path / "outputs" + path = outputs / "broken-gen.0-0.0.mkv" + write_mp4(path, frames=12, fps=6) + + raw = bytearray(path.read_bytes()) + mid = len(raw) // 2 + for i in range(mid, len(raw), 64): + raw[i] = (raw[i] + 137) % 256 + path.write_bytes(bytes(raw)) + + response = client.get("/api/gallery/broken-gen.0-0.0.mkv/metadata") + assert response.status_code == 200 + body = response.json() + assert "job" in body + assert body["media"]["kind"] == "video" + assert "peak_dbfs" not in body["media"] + + def test_gallery_paginates_and_groups_by_workflow_folder(server, tmp_path): """Outputs nested under a workflow subfolder (dw/workflow.py's effective_output_dir) still show up in the gallery, tagged with their