Files
siftlode/backend/tests/test_playable.py
T
peter 5561d9a379 feat(downloads): every ready file is phone-safe — uploads and clips too
A share link is opened on whatever the friend holds. Measured on an iPhone and an iPad: AV1,
Matroska, HEVC tagged hev1 and MP3 inside an .mp4 fail there; Chrome and Firefox add HEVC and
10-bit / 4:4:4 H.264. The old check judged the codec NAME only and ran on downloads only, so a
10-bit H.264 passed, an upload went out exactly as it came (an iPhone's HEVC .mov, a webm, an mkv)
and a fast trim kept its source's codecs.

One rule now, `downloads/playable.py`: video is H.264 8-bit 4:2:0 + AAC in .mp4, audio-only is
.mp3 or AAC .m4a; `plan` says what must change, `build_cmd` re-encodes only the failing stream (or
remuxes). The worker applies it to a download, an editor clip and — new — an upload: media that
fails the rule is queued (asset pending, job queued) and the worker converts it, keeping only the
converted file (owner's choice). Until then the original sits at rel_path, so Retry starts from it
and a delete removes it; an upload that lost its last holder mid-conversion is reaped by the GC.
Every libx264 run (edit, concat, conversion) now pins `-pix_fmt yuv420p` — without it a 10-bit
source came out High 10. The download cache signature moves to v5 so a cached 10-bit asset
re-derives. The upload toast says when the file is still converting (HU/EN).

Tests: the plan for every measured class; real ffmpeg turning a 10-bit Matroska + Opus and an
mp3-in-mp4 into files the rule accepts (red without the pixel-format pin); the queued upload's
delete, cancel-then-GC, failed conversion and real worker conversion (the cleanup tests red
without their rules); the toast (red without the branch). Live on localdev: 9 fixture uploads
converted, every served file probed H.264 High yuv420p + AAC, or mp3 / m4a.
2026-10-05 03:16:27 +02:00

158 lines
6.6 KiB
Python

"""The phone-safe rule (`downloads.playable`): what a library file must be so a share link plays
on any phone or browser, and the one ffmpeg command that makes it so.
The plan table is the measured device verdict, class by class: what failed on a real iPhone + iPad
(AV1, Matroska, HEVC, MP3 inside an .mp4) must change, and so must what browsers other than Safari
cannot be trusted with (10-bit / 4:4:4 H.264, VP9, Opus). The ffmpeg cases run the real encoder and
probe its output, because the rule is about the bytes, not the flags.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.downloads import playable, probe
# conftest turns a missing ffmpeg into a hard error in the gate (SIFTLODE_TEST_REQUIRE_DB), so this
# only ever skips on an explicit pure-logic run.
needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def _data(video=None, audio=None, fmt="mov,mp4,m4a,3gp,3g2,mj2", cover=False):
streams = []
if cover:
streams.append(
{"codec_type": "video", "codec_name": "mjpeg", "disposition": {"attached_pic": 1}}
)
if video:
codec, pix = video
streams.append({"codec_type": "video", "codec_name": codec, "pix_fmt": pix})
if audio:
streams.append({"codec_type": "audio", "codec_name": audio})
return {"streams": streams, "format": {"format_name": fmt}}
# (probe, extension) -> (ext, reencode_video, reencode_audio, needs_change)
CASES = {
"h264 8-bit + aac in mp4": (_data(("h264", "yuv420p"), "aac"), "mp4", ("mp4", 0, 0, 0)),
"full-range h264 is still 8-bit 4:2:0": (
_data(("h264", "yuvj420p"), "aac"),
"mp4",
("mp4", 0, 0, 0),
),
"10-bit h264": (_data(("h264", "yuv420p10le"), "aac"), "mp4", ("mp4", 1, 0, 1)),
"4:4:4 h264": (_data(("h264", "yuv444p"), "aac"), "mp4", ("mp4", 1, 0, 1)),
"hevc (an iPhone's .mov)": (_data(("hevc", "yuv420p10le"), "aac"), "mov", ("mp4", 1, 0, 1)),
"vp9 + opus webm": (_data(("vp9", "yuv420p"), "opus"), "webm", ("mp4", 1, 1, 1)),
"av1": (_data(("av1", "yuv420p"), "aac"), "mp4", ("mp4", 1, 0, 1)),
"h264 + aac in mkv: remux only": (_data(("h264", "yuv420p"), "aac"), "mkv", ("mp4", 0, 0, 1)),
"h264 + opus in mp4": (_data(("h264", "yuv420p"), "opus"), "mp4", ("mp4", 0, 1, 1)),
"mp3 beside video in mp4": (_data(("h264", "yuv420p"), "mp3"), "mp4", ("mp4", 0, 1, 1)),
"a silent h264 video": (_data(("h264", "yuv420p")), "mp4", ("mp4", 0, 0, 0)),
"mp3": (_data(audio="mp3", fmt="mp3"), "mp3", ("mp3", 0, 0, 0)),
"mp3 with cover art": (_data(audio="mp3", fmt="mp3", cover=True), "mp3", ("mp3", 0, 0, 0)),
"mp3 inside an mp4: remux to .mp3": (_data(audio="mp3"), "mp4", ("mp3", 0, 0, 1)),
"aac in m4a": (_data(audio="aac"), "m4a", ("m4a", 0, 0, 0)),
"aac audio-only in mp4: remux to .m4a": (_data(audio="aac"), "mp4", ("m4a", 0, 0, 1)),
"opus in ogg": (_data(audio="opus", fmt="ogg"), "ogg", ("m4a", 0, 1, 1)),
}
@pytest.mark.parametrize("name", CASES)
def test_the_plan_for_each_class(name):
data, ext, (want_ext, rv, ra, needs) = CASES[name]
p = playable.plan(data, ext)
assert p is not None
assert (p.ext, p.reencode_video, p.reencode_audio, p.needs_change) == (
want_ext,
bool(rv),
bool(ra),
bool(needs),
)
@pytest.mark.parametrize(
"data",
[
None, # ffprobe could not read it
{"streams": [], "format": {"format_name": "pdf"}},
_data(("png", "rgb24"), fmt="png_pipe"), # a still image is a download-only file
],
)
def test_a_file_that_is_not_media_has_no_plan(data):
assert playable.plan(data, "bin") is None
def test_the_extension_is_read_with_or_without_its_dot():
data = _data(("h264", "yuv420p"), "aac")
assert playable.plan(data, ".MP4") == playable.plan(data, "mp4")
assert not playable.plan(data, ".MP4").needs_change
def test_a_video_re_encode_pins_8_bit_420_and_maps_only_the_judged_streams():
p = playable.Plan("mp4", True, True, False, True)
cmd = playable.build_cmd(Path("/in.mov"), Path("/out.mp4"), p)
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
assert cmd[cmd.index("-c:v") + 1] == "libx264"
assert cmd[cmd.index("-c:a") + 1] == "copy"
maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"]
assert maps == ["0:V:0", "0:a:0?"] # never cover art, never a subtitle the muxer refuses
assert cmd[-3:] == ["-movflags", "+faststart", "/out.mp4"]
def test_an_audio_remux_copies_the_audio_alone():
p = playable.Plan("mp3", False, False, False, True)
cmd = playable.build_cmd(Path("/in.mp4"), Path("/out.mp3"), p)
assert [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"] == ["0:a:0"]
assert "-c:v" not in cmd and "-pix_fmt" not in cmd
assert cmd[cmd.index("-c:a") + 1] == "copy"
assert "-movflags" not in cmd # an .mp3 has no moov atom to move
# --- the real encoder ------------------------------------------------------------------------
def _ffmpeg(*args: str) -> None:
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
def _convert(src: Path, tmp_path: Path) -> Path:
p = playable.plan(probe.probe_json(src), src.suffix)
assert p is not None and p.needs_change
dest = tmp_path / f"out.{p.ext}"
subprocess.run(playable.build_cmd(src, dest, p), check=True, capture_output=True)
return dest
@needs_ffmpeg
def test_a_10_bit_matroska_with_opus_comes_out_phone_safe(tmp_path):
src = tmp_path / "in.mkv"
_ffmpeg(
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=1",
"-f", "lavfi", "-i", "sine=duration=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p10le", "-c:a", "libopus",
str(src),
) # fmt: skip
out = _convert(src, tmp_path)
data = probe.probe_json(out)
video, audio = probe.primary_streams(data)
assert (video["codec_name"], video["pix_fmt"], video["profile"]) == ("h264", "yuv420p", "High")
assert audio["codec_name"] == "aac"
assert out.suffix == ".mp4"
assert not playable.plan(data, out.suffix).needs_change # the rule accepts its own output
@needs_ffmpeg
def test_mp3_inside_an_mp4_comes_out_as_an_mp3(tmp_path):
# The one unsafe file prod held: an audio download remuxed into .mp4, which iPhone and iPad
# both refused to play.
src = tmp_path / "in.mp4"
_ffmpeg("-f", "lavfi", "-i", "sine=duration=1", "-c:a", "libmp3lame", "-f", "mp4", str(src))
out = _convert(src, tmp_path)
data = probe.probe_json(out)
assert out.suffix == ".mp3"
assert probe.primary_streams(data)[1]["codec_name"] == "mp3"
assert not playable.plan(data, out.suffix).needs_change