Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions scripts/dotty_av_clips.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,11 @@ def crop_region(value):
return value


ROTATE_FILTERS = {90: "transpose=1", 180: "hflip,vflip", 270: "transpose=2"}


def export(session, case_id, *, start=0, end=None, name=None, title=None, reviewed=(),
include_captions=True, crop=None):
include_captions=True, crop=None, rotate=0):
session = session.resolve(strict=True)
case = inside(session / "cases" / identifier(case_id), session / "cases")
source = inside(case / "raw.mp4", session)
Expand All @@ -148,6 +151,8 @@ def export(session, case_id, *, start=0, end=None, name=None, title=None, review
raise ValueError("clip interval must be within source duration")
if crop is not None:
crop = crop_region(crop)
if rotate not in (0, *ROTATE_FILTERS):
raise ValueError("rotate must be 0, 90, 180 or 270 degrees clockwise")
acknowledged = set(reviewed)
if acknowledged - GATES:
raise ValueError("unknown review gate")
Expand All @@ -168,13 +173,16 @@ def export(session, case_id, *, start=0, end=None, name=None, title=None, review
"result_file": str(result_path.relative_to(session)),
"sha256": file_digest(source), "start_seconds": start, "end_seconds": end,
"case_started": result.get("started"), "duration_seconds": duration},
"layout": (f"1080x1920 cropped from source region {crop}" if crop
else "1080x1920 fitted; entire source frame retained"),
"layout": ((f"1080x1920 cropped from source region {crop}" if crop
else "1080x1920 fitted; entire source frame retained")
+ (f" (after rotating source {rotate} degrees clockwise)" if rotate else "")),
"rotate_degrees": rotate,
"audio": "original audio with loudness normalization; no replacement soundtrack",
"captions": {"state": "absent", "source": "local response.json transcription"}}
try:
execute(["ffmpeg", "-nostdin", "-n", "-v", "error", "-ss", str(start), "-i", str(source),
"-t", str(end - start), "-map", "0:v:0", "-map", "0:a:0", "-vf",
(ROTATE_FILTERS[rotate] + "," if rotate else "") +
(f"crop={crop},scale=1080:1920:flags=lanczos,setsar=1" if crop else
"scale=1080:1920:force_original_aspect_ratio=decrease:force_divisible_by=2,"
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1"),
Expand Down Expand Up @@ -225,6 +233,9 @@ def main():
p.add_argument("--title")
p.add_argument("--reviewed", choices=sorted(GATES), action="append", default=[])
p.add_argument("--no-captions", action="store_true")
p.add_argument("--rotate", type=int, default=0, choices=[0, 90, 180, 270],
help="rotate source clockwise before crop/fit, for a physically turned camera "
"(crop coordinates then refer to the rotated frame)")
p.add_argument("--crop", help="W:H:X:Y portrait source region to fill the 9:16 frame "
"(default: fit the whole frame with padding)")
p = sub.add_parser("index")
Expand All @@ -235,7 +246,7 @@ def main():
else:
print(export(args.session, args.case_id, start=args.start, end=args.end, name=args.name,
title=args.title, reviewed=args.reviewed, include_captions=not args.no_captions,
crop=args.crop))
crop=args.crop, rotate=args.rotate))


if __name__ == "__main__":
Expand Down
47 changes: 47 additions & 0 deletions tests/test_dotty_av_clips.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,50 @@ def test_crop_must_be_a_portrait_source_region(case, crop):
clips.export(session, "funny-1", name="bad-crop", start=9, end=14, crop=crop)
assert commands == []
assert not list((session / "clips").glob("*/bad-crop"))


# --- rotated camera. AI-assisted: Claude. ---

@pytest.mark.parametrize("degrees,expected", [(90, "transpose=1"), (270, "transpose=2"), (180, "hflip,vflip")])
def test_rotate_turns_sideways_frames_upright_before_fitting(case, degrees, expected):
session, _folder, commands = case
destination = clips.export(session, "funny-1", name=f"rot{degrees}", start=9, end=14, rotate=degrees)
video_filter = commands[0][commands[0].index("-vf") + 1]
assert video_filter.startswith(expected + ",scale=1080:1920")
manifest = clips.load(destination / "manifest.json")
assert manifest["rotate_degrees"] == degrees
assert f"rotating source {degrees} degrees clockwise" in manifest["layout"]


def test_rotate_then_crop_uses_rotated_coordinates(case):
session, _folder, commands = case
clips.export(session, "funny-1", name="rotcrop", start=9, end=14, rotate=90, crop="405:720:0:0")
assert commands[0][commands[0].index("-vf") + 1].startswith("transpose=1,crop=405:720:0:0,scale=")


def test_invalid_rotation_creates_no_export(case):
session, _folder, commands = case
with pytest.raises(ValueError):
clips.export(session, "funny-1", name="badrot", start=9, end=14, rotate=45)
assert commands == []
assert not list((session / "clips").glob("*/badrot"))


def test_rotated_synthetic_landscape_fills_portrait_frame(tmp_path):
import subprocess
if not shutil.which("ffmpeg"):
pytest.skip("ffmpeg missing")
folder = tmp_path / "cases" / "rot-1"
folder.mkdir(parents=True)
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "color=c=white:size=1920x1080:rate=10:duration=2",
"-f", "lavfi", "-i", "sine=duration=2", "-shortest", "-pix_fmt", "yuv420p", str(folder / "raw.mp4")], check=True)
(folder / "result.json").write_text(json.dumps({"verdict": "PASS"}))
destination = clips.export(tmp_path, "rot-1", name="rot", rotate=90)
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height",
"-of", "json", str(destination / "clean.mp4")], capture_output=True, text=True, check=True)
stream = json.loads(out.stdout)["streams"][0]
assert (stream["width"], stream["height"]) == (1080, 1920)
# 16:9 rotated is 9:16, so nothing is letterboxed: the corner pixel is picture, not black padding.
frame = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(destination / "clean.mp4"), "-frames:v", "1",
"-vf", "crop=16:16:0:0,format=gray", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout
assert frame[0] > 16
Loading