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.
100 lines
4.2 KiB
Python
100 lines
4.2 KiB
Python
"""The phone-safe rule: what a library file must be so it plays wherever a share link lands.
|
|
|
|
A shared `/watch` link is opened by whoever got it, on whatever they hold: an iPhone's WebKit, an
|
|
Android Chrome, a desktop Firefox. The one shape every one of them plays:
|
|
|
|
* video → H.264, 8-bit 4:2:0 (`yuv420p`, or its full-range twin `yuvj420p`), AAC audio, in .mp4
|
|
* audio only → MP3 in .mp3, or AAC in .m4a
|
|
|
|
Measured on the owner's iPhone + iPad (2026-10-05): AV1, Matroska, HEVC tagged `hev1` and MP3
|
|
inside an .mp4 all fail there. VP9/WebM, HEVC `hvc1` and 10-bit H.264 played on those devices, but
|
|
Chrome and Firefox do not decode HEVC and browsers decode 10-bit / 4:4:4 H.264 only patchily, so the
|
|
rule is the shape every browser plays, not the shape one device plays.
|
|
|
|
Every file that becomes a ready library asset passes through here — a download, an upload (the
|
|
worker converts it, see `upload`), and an editor clip — so a link can only ever serve this shape.
|
|
`plan` says what (if anything) must change; `build_cmd` is the one ffmpeg command that changes it:
|
|
re-encode only the stream that fails, copy the rest, or just remux when only the container is wrong.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from app.downloads import edit as editmod
|
|
from app.downloads import probe
|
|
|
|
_SAFE_VCODEC = "h264"
|
|
_SAFE_PIX_FMTS = frozenset({"yuv420p", "yuvj420p"})
|
|
_VIDEO_EXT = "mp4"
|
|
# An audio-only file: the codec, and the extension it plays in. Anything else becomes AAC in .m4a.
|
|
_AUDIO_HOME = {"mp3": "mp3", "aac": "m4a"}
|
|
_AUDIO_FALLBACK_EXT = "m4a"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Plan:
|
|
"""What makes one file phone-safe. `ext` is the extension the safe file has (no dot)."""
|
|
|
|
ext: str
|
|
has_video: bool
|
|
reencode_video: bool
|
|
reencode_audio: bool
|
|
needs_change: bool
|
|
|
|
|
|
def plan(data: dict | None, ext: str) -> Plan | None:
|
|
"""The phone-safe plan for a file whose `probe.probe_json` is `data` and whose extension is
|
|
`ext`; None when it is not media at all (an unreadable probe, a still image, no audio or video
|
|
stream) — such a file is a download-only 'file' and never plays inline, so there is nothing to
|
|
make safe."""
|
|
if data is None:
|
|
return None
|
|
video, audio = probe.primary_streams(data)
|
|
ext = ext.lower().lstrip(".")
|
|
if video is not None:
|
|
rv = (video.get("codec_name") or "").lower() != _SAFE_VCODEC or (
|
|
video.get("pix_fmt") or ""
|
|
).lower() not in _SAFE_PIX_FMTS
|
|
ra = audio is not None and (audio.get("codec_name") or "").lower() != "aac"
|
|
return Plan(_VIDEO_EXT, True, rv, ra, rv or ra or ext != _VIDEO_EXT)
|
|
if audio is not None:
|
|
home = _AUDIO_HOME.get((audio.get("codec_name") or "").lower())
|
|
target = home or _AUDIO_FALLBACK_EXT
|
|
return Plan(target, False, False, home is None, home is None or ext != target)
|
|
return None
|
|
|
|
|
|
def build_cmd(src: Path, dest: Path, p: Plan, threads: int | None = None) -> list[str]:
|
|
"""The ffmpeg command that writes `src` to `dest` (whose suffix is `p.ext`) in the phone-safe
|
|
shape: the failing stream(s) re-encoded with the package's shared encoder settings, the rest
|
|
stream-copied. An audio-only result drops any cover-art stream. `-progress pipe:1`
|
|
streams machine-readable progress; `threads` caps the encoder so a re-encode cannot claim every
|
|
core on the shared box (see the worker-child de-prioritization)."""
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-nostdin",
|
|
"-progress",
|
|
"pipe:1",
|
|
"-nostats",
|
|
"-i",
|
|
str(src),
|
|
]
|
|
# Exactly the two streams `plan` judged: `V` is a video stream that is not cover art. Nothing
|
|
# else rides along — a subtitle or data track the .mp4 muxer cannot take would fail the run.
|
|
if p.has_video:
|
|
cmd += ["-map", "0:V:0", "-map", "0:a:0?"]
|
|
cmd += editmod.X264_ARGS if p.reencode_video else ["-c:v", "copy"]
|
|
else:
|
|
cmd += ["-map", "0:a:0"]
|
|
cmd += editmod.AAC_ARGS if p.reencode_audio else ["-c:a", "copy"]
|
|
if threads and threads > 0:
|
|
cmd += ["-threads", str(threads)]
|
|
if p.ext in ("mp4", "m4a"):
|
|
cmd += ["-movflags", "+faststart"]
|
|
cmd += [str(dest)]
|
|
return cmd
|