fix(editor): a crop of a video with wide pixels keeps what its frame showed
The browser reports such a video by its shown size and ffmpeg's crop counts stored pixels, so a frame over the middle half of a 320 stored, 640 shown video kept the whole width. A crop now carries the size it was measured on and is scaled to the file's own pixels. A spec from before it did is cut as it always was. Also drops a stale note in downloads/quota.py: edit jobs are held to the cap.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""The editor's crop lands on the part of the picture the frame was drawn on, also when a video's
|
||||
pixels are not square. The browser reports such a video by its shown size (320 stored, 640 shown),
|
||||
and ffmpeg's `crop` counts stored pixels: measured on a real clip, a frame over the middle half kept
|
||||
half the frame's red surroundings. So a crop carries the size it was measured on.
|
||||
|
||||
Real ffmpeg: the pixels of the output are the oracle.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit, pipeline, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
|
||||
|
||||
def _source(path: Path, size: str, box: str, sar: int) -> None:
|
||||
"""Red, with the picture's middle half green: a crop of that middle holds no red."""
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", f"color=c=red:s={size}:d=2:r=10",
|
||||
"-f", "lavfi", "-i", "sine=duration=2",
|
||||
"-vf", f"drawbox={box}:c=lime:t=fill,setsar={sar}",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(path),
|
||||
],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def _red_and_green(path: Path) -> tuple[int, int]:
|
||||
raw = subprocess.run(
|
||||
["ffmpeg", "-loglevel", "error", "-i", str(path), "-frames:v", "1",
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
).stdout # fmt: skip
|
||||
red = green = 0
|
||||
for i in range(0, len(raw) - 2, 3):
|
||||
if raw[i] > 180 and raw[i + 1] < 80:
|
||||
red += 1
|
||||
elif raw[i + 1] > 180 and raw[i] < 80:
|
||||
green += 1
|
||||
return red, green
|
||||
|
||||
|
||||
def _cut(db, make_user, tmp_path, src: Path, crop: dict) -> Path:
|
||||
user = make_user()
|
||||
source = uploadmod.ingest_upload(
|
||||
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
assert source.status == "done"
|
||||
job = service.enqueue_edit(db, user.id, source, {"crop": crop})
|
||||
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")
|
||||
return storage.abs_path(str(tmp_path), asset.rel_path)
|
||||
|
||||
|
||||
def test_a_crop_of_a_video_with_wide_pixels_keeps_what_the_frame_showed(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
|
||||
|
||||
# What the editor sends for the middle half of this video: the browser shows it at 640 x 240.
|
||||
final = _cut(
|
||||
db, make_user, tmp_path, src,
|
||||
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
|
||||
) # fmt: skip
|
||||
|
||||
red, green = _red_and_green(final)
|
||||
assert (red, green) == (0, 160 * 120)
|
||||
|
||||
|
||||
def test_a_crop_of_square_pixels_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "480x640", "x=120:y=160:w=240:h=320", sar=1)
|
||||
|
||||
final = _cut(
|
||||
db, make_user, tmp_path, src,
|
||||
{"x": 120, "y": 160, "w": 240, "h": 320, "frame_w": 480, "frame_h": 640},
|
||||
) # fmt: skip
|
||||
|
||||
assert _red_and_green(final) == (0, 240 * 320)
|
||||
|
||||
|
||||
def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was():
|
||||
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50}})
|
||||
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
|
||||
cmd = edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4")
|
||||
assert cmd[cmd.index("-vf") + 1].startswith("crop=100:50:4:6,")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", [{"frame_w": 0, "frame_h": 240}, {"frame_w": "x", "frame_h": 2}])
|
||||
def test_a_frame_that_cannot_be_read_is_not_divided_by(frame):
|
||||
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}})
|
||||
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
|
||||
Reference in New Issue
Block a user