diff --git a/scripts/dotty_av_clips.py b/scripts/dotty_av_clips.py index dcb8c2f..19a355c 100644 --- a/scripts/dotty_av_clips.py +++ b/scripts/dotty_av_clips.py @@ -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) @@ -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") @@ -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"), @@ -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") @@ -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__": diff --git a/tests/test_dotty_av_clips.py b/tests/test_dotty_av_clips.py index 6dba059..e57056b 100644 --- a/tests/test_dotty_av_clips.py +++ b/tests/test_dotty_av_clips.py @@ -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