fix(downloads): an upload's conversion is guarded, counted, named and cleaned up
- 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.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user