- Every ffmpeg/ffprobe over a library file names the demuxers it may use
(edit.INPUT_GUARD; a downloaded thumbnail edit.THUMB_GUARD): a playlist-type
demuxer would read local paths named inside a user's upload.
- A re-encode ends on an even frame (edit.EVEN_FRAME): an odd 4:4:4/RGB source
failed its conversion; a clip stores the frame its file has.
- An upload counts against its owner's cap from the moment it lands (queued, or
kept after a failed conversion) — the footprint, the GC's cap and the admin
view read one ON_DISK predicate — and a conversion that outgrows the cap is
refused, keeping the original.
- The converted name keeps _a{id} (a dotted bare name once lost it and two
uploads shared a file); a converted file placed but not committed is removed,
and the orphan sweep enters .uploads; the LRU cap never evicts an upload.
- Ingest judges the extension the file is stored under; a failed conversion is
convert_failed / upload_missing, reports the "converting" phase, and notifies
as an upload. Job statuses in routes/downloads use JobStatus.
104 lines
4.3 KiB
Python
104 lines
4.3 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",
|
|
*editmod.INPUT_GUARD,
|
|
"-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?"]
|
|
if p.reencode_video:
|
|
cmd += ["-vf", editmod.EVEN_FRAME, *editmod.X264_ARGS]
|
|
else:
|
|
cmd += ["-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
|