- 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.
74 lines
3.1 KiB
Python
74 lines
3.1 KiB
Python
"""A libx264 re-encode of an odd-sized frame: 4:2:0 halves the chroma, so libx264 refuses an odd
|
|
width or height ("width not divisible by 2"). Measured: a source that is odd AND not 4:2:0 already
|
|
(a 4:4:4 or RGB screen capture, 1365x767) failed its conversion outright; a 4:2:0 source never
|
|
reaches the encoder odd (ffmpeg's crop rounds to its chroma grid), but the clip then stored the
|
|
size the user dragged, not the one the file has. Every re-encode now ends its filter chain on
|
|
`edit.EVEN_FRAME` (at most one pixel row or column), and a clip stores its probed frame.
|
|
|
|
Real ffmpeg on both paths — the refusal is the encoder's, so only the encoder can show it is gone.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.downloads import pipeline, playable, probe, service, storage
|
|
from app.downloads import upload as uploadmod
|
|
from app.models import DownloadJob, MediaAsset
|
|
|
|
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
|
|
|
|
|
def _ffmpeg(*args: str) -> None:
|
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
|
|
|
|
|
|
def _frame(path: Path) -> tuple[int | None, int | None]:
|
|
_, _, w, h, _ = probe.probe_media(path)
|
|
return w, h
|
|
|
|
|
|
def test_an_odd_sized_4_4_4_video_converts_to_the_even_frame_below_it(tmp_path):
|
|
src = tmp_path / "in.mkv"
|
|
_ffmpeg(
|
|
"-f", "lavfi", "-i", "testsrc=size=161x121:rate=10:duration=1",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv444p", str(src),
|
|
) # fmt: skip
|
|
assert _frame(src) == (161, 121) # odd for real (`testsrc2` rounds its own size)
|
|
p = playable.plan(probe.probe_json(src), "mkv")
|
|
assert p is not None and p.reencode_video
|
|
dest = tmp_path / "out.mp4"
|
|
subprocess.run(playable.build_cmd(src, dest, p), check=True, capture_output=True)
|
|
assert _frame(dest) == (160, 120)
|
|
|
|
|
|
def test_an_odd_crop_makes_a_clip_and_stores_the_frame_it_has(db, make_user, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
|
src = tmp_path / "in.mp4"
|
|
_ffmpeg(
|
|
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
|
"-f", "lavfi", "-i", "sine=duration=2",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(src),
|
|
) # fmt: skip
|
|
u = make_user()
|
|
source = uploadmod.ingest_upload(
|
|
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
|
)
|
|
assert source.status == "done" # already phone-safe: the clip's source as it came
|
|
job = service.enqueue_edit(db, u.id, source, {"crop": {"x": 3, "y": 5, "w": 101, "h": 51}})
|
|
asset = db.get(MediaAsset, job.asset_id)
|
|
job.status, asset.status = "running", "downloading" # the worker's claim
|
|
db.commit()
|
|
|
|
pipeline._process_edit(job.id, asset.id)
|
|
|
|
db.expire_all()
|
|
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
|
assert (job.status, job.error, asset.status) == ("done", None, "ready")
|
|
final = storage.abs_path(str(tmp_path), asset.rel_path)
|
|
assert _frame(final) == (100, 50)
|
|
assert (asset.width, asset.height) == (100, 50) # what the file is, not what was asked
|