"""The final cut. The filter graph is checked as text; the render and export are run on real ffmpeg.""" import os import pytest from modules.output.render.tests.clips import comfy, make_clip, needs_ffmpeg, probe # noqa: F401 from core import projects from modules.output.render import files, overlays, stitch def clip(name, dur=2.0, **extra): return {"filename": name, "subfolder": "", "type": "output", "in": 0, "dur": dur, "fx": {}, "fps": 25, "w": 160, "h": 120, "transition": "", "tdur": 0, "volume": 1.0, **extra} # --- the graph, as text --------------------------------------------------------- def test_a_crossfade_overlaps_and_a_hard_cut_concatenates(): a = clip("a.mp4", transition="crossfade", tdur=0.5) g, audio = stitch.build_filter([a, clip("b.mp4"), clip("c.mp4")]) assert audio and "xfade=transition=fade:duration=0.500:offset=1.500" in g assert "acrossfade=d=0.500" in g assert "[vx1][v2]concat=n=2:v=1:a=0[vc2]" in g.replace("[v2]", "[v2]") def test_a_seam_longer_than_a_clip_is_a_hard_cut_not_a_broken_graph(): g, _ = stitch.build_filter([clip("a.mp4", transition="crossfade", tdur=5.0), clip("b.mp4")]) assert "xfade" not in g and "concat=n=2:v=1:a=0[vc1]" in g def test_a_gap_adds_black_and_silence_of_that_length(): g, _ = stitch.build_filter([clip("a.mp4", gap_after=0.75), clip("b.mp4")]) assert "color=c=black:s=160x120:r=25:d=0.750" in g and "anullsrc=r=48000:cl=stereo:d=0.750" in g def test_a_clip_with_no_sound_contributes_silence_instead_of_sinking_the_render(): g, _ = stitch.build_filter([clip("a.mp4", has_audio=False), clip("b.mp4")]) assert "[0:a:0]" not in g and "anullsrc=r=48000:cl=stereo:d=2.000" in g and "[1:a:0]" in g def test_audio_is_resampled_so_clips_of_different_rates_can_be_joined(): g, _ = stitch.build_filter([clip("a.mp4"), clip("b.mp4")]) assert g.count("sample_rates=48000") >= 2 def test_reversed_clip_reverses_its_sound_too_and_volume_applies(): g, _ = stitch.build_filter([clip("a.mp4", fx={"reverse": True}, volume=0.5)]) assert "areverse,volume=0.500" in g def test_keep_original_off_drops_the_clips_sound_and_extra_tracks_still_mix(): g, has_audio = stitch.build_filter([clip("a.mp4")], keep_original=False) assert not has_audio and "[a0]" not in g g, has_audio = stitch.build_filter([clip("a.mp4")], [{"start_sec": 1.5, "volume": 0.5}], keep_original=False, base_input=1) assert has_audio and "[1:a:0]" in g and "adelay=1500|1500" in g and "volume=0.500" in g def test_a_reverse_that_cannot_run_names_the_clip(): with pytest.raises(stitch.RenderError, match="Clip 2: Reverse needs every frame"): stitch.build_filter([clip("a.mp4"), clip("b.mp4", dur=90.0, fx={"reverse": True})]) def test_no_clips_and_no_canvas_is_refused(): with pytest.raises(stitch.RenderError, match="nothing to render"): stitch.build_filter([]) def test_junk_clip_numbers_do_not_crash_the_graph(): g, _ = stitch.build_filter([clip("a.mp4", dur="x", tdur=float("nan"), volume=None, w="wide", fps=None)]) assert "[0:v:0]" in g # --- overlays --------------------------------------------------------------------- def test_each_overlay_reads_its_own_picture_even_when_an_earlier_one_is_not_drawn(): ovs = [{"kind": "image", "duration_sec": 0}, {"kind": "image", "duration_sec": 2, "start_sec": 1, "x": 0.25}] lines, final = overlays.composite("[vbase]", ovs, canvas_w=160, image_labels=["[5:v:0]", "[6:v:0]"]) assert final == "[vov1]" and "[6:v:0]scale=" in lines[0] and "[5:v:0]" not in "".join(lines) assert "enable='between(t,1.000,3.000)'" in lines[-1] and "overlay=x=0.250000*W-w/2" in lines[-1] def test_overlays_stack_by_lane_then_start(): lanes = [{"id": "low"}, {"id": "high"}] ovs = [{"id": 1, "lane_id": "high", "start_sec": 0}, {"id": 2, "lane_id": "low", "start_sec": 5}, {"id": 3, "lane_id": "low", "start_sec": 1}] assert [o["id"] for o in overlays.in_stacking_order(ovs, lanes)] == [3, 2, 1] # --- real ffmpeg ------------------------------------------------------------------ @needs_ffmpeg def test_two_clips_with_a_crossfade_and_effects_render_to_the_right_length_and_size(comfy): make_clip(comfy.output / "a.mp4", 2.0, size="160x120") make_clip(comfy.output / "b.mp4", 2.0, size="320x180") # a different native size proj = projects.Project(width=160, height=120, frame_rate=25) out = stitch.render(proj, [clip("a.mp4", transition="crossfade", tdur=0.5, fx={"flip_h": True, "blur": 0.1}), clip("b.mp4", fx={"fit": "fill", "fade_out": 0.3})]) assert out["clips"] == 2 and out["media"]["type"] == "temp" dur, w, h, audio = probe(comfy.temp / out["media"]["filename"]) assert (w, h) == (160, 120) and audio assert dur == pytest.approx(3.5, abs=0.15) # 2 + 2 - the 0.5 s overlap @needs_ffmpeg def test_a_silent_clip_among_clips_with_sound_still_renders(comfy): make_clip(comfy.output / "a.mp4", 1.0, audio=False) make_clip(comfy.output / "b.mp4", 1.0) out = stitch.render(projects.Project(width=160, height=120), [clip("a.mp4", dur=1.0), clip("b.mp4", dur=1.0)]) dur, _w, _h, audio = probe(comfy.temp / out["media"]["filename"]) assert audio and dur == pytest.approx(2.0, abs=0.15) @needs_ffmpeg def test_a_trim_window_and_a_gap_are_honoured(comfy): make_clip(comfy.output / "a.mp4", 4.0) out = stitch.render(projects.Project(width=160, height=120), [clip("a.mp4", dur=1.0, **{"in": 1.5}, gap_after=0.5), clip("a.mp4", dur=1.0)]) dur, *_ = probe(comfy.temp / out["media"]["filename"]) assert dur == pytest.approx(2.5, abs=0.15) @needs_ffmpeg def test_a_reversed_clip_renders(comfy): make_clip(comfy.output / "a.mp4", 1.0) out = stitch.render(projects.Project(width=160, height=120), [clip("a.mp4", dur=1.0, fx={"reverse": True})]) assert probe(comfy.temp / out["media"]["filename"])[0] == pytest.approx(1.0, abs=0.15) @needs_ffmpeg def test_overlays_and_audio_lanes_render_with_no_clips_at_all(comfy): from core import media wav = comfy.root / "tone.wav" import subprocess subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=330:duration=2", str(wav)], check=True, capture_output=True) entry = media.save_upload("tone.wav", wav.read_bytes()) proj = projects.Project(width=160, height=120, frame_rate=25, audio_tracks=[ {"id": "a1", "kind": "overlay", "media_ref": entry["id"], "start_sec": 0.5, "volume": 1.0, "source_in_sec": 0, "source_dur": 1.5}], overlay_lanes=[{"id": "l1"}], overlay_tracks=[{"id": "t1", "lane_id": "l1", "kind": "text", "text": "hello", "start_sec": 0, "duration_sec": 1.5, "x": 0.5, "y": 0.5, "font_size": 24, "color": "#ffcc00", "bold": True, "italic": True, "shadow": True, "bg_enabled": True}]) out = stitch.render(proj, []) dur, w, h, audio = probe(comfy.temp / out["media"]["filename"]) assert (w, h) == (160, 120) and audio and dur == pytest.approx(2.0, abs=0.2) @needs_ffmpeg def test_nothing_to_render_says_so(comfy): with pytest.raises(stitch.RenderError, match="Nothing to render"): stitch.render(projects.Project(), []) @needs_ffmpeg def test_a_missing_clip_file_is_a_sentence_not_a_traceback(comfy): with pytest.raises(stitch.RenderError, match="not on disk"): stitch.render(projects.Project(), [clip("gone.mp4")]) @needs_ffmpeg def test_a_clip_name_that_climbs_out_of_the_output_folder_is_not_found(comfy): (comfy.root / "secret.mp4").write_bytes(b"x") with pytest.raises(stitch.RenderError): stitch.render(projects.Project(), [{**clip("secret.mp4"), "subfolder": ".."}]) @needs_ffmpeg def test_export_one_clip_trims_and_several_hard_cut(comfy): make_clip(comfy.output / "a.mp4", 3.0) one = stitch.concat([{"filename": "a.mp4", "in": 1.0, "dur": 1.0}]) assert probe(comfy.temp / one["media"]["filename"])[0] == pytest.approx(1.0, abs=0.15) two = stitch.concat([{"filename": "a.mp4", "in": 0, "dur": 1.0}, {"filename": "a.mp4", "in": 1.0, "dur": 1.0}]) assert two["clips"] == 2 and probe(comfy.temp / two["media"]["filename"])[0] == pytest.approx(2.0, abs=0.2) assert not [p for p in os.listdir(comfy.temp) if p.startswith(("funui_seg_", "funui_concat_"))] with pytest.raises(stitch.RenderError): stitch.concat([]) @needs_ffmpeg def test_a_separated_audio_lane_plays_the_clips_own_sound_from_its_file(comfy): make_clip(comfy.output / "a.mp4", 2.0) proj = projects.Project(width=160, height=120, keep_original_audio=False, audio_tracks=[ {"id": "s", "kind": "separated", "scene_id": "sc1", "start_sec": 0}]) out = stitch.render(proj, [clip("a.mp4", dur=2.0, scene_id="sc1")]) assert probe(comfy.temp / out["media"]["filename"])[3] is True @needs_ffmpeg def test_export_keeps_the_sound_when_the_first_clip_is_silent(comfy): make_clip(comfy.output / "quiet.mp4", 1.0, audio=False) make_clip(comfy.output / "loud.mp4", 1.0, audio=True) out = stitch.concat([{"filename": "quiet.mp4", "dur": 1.0}, {"filename": "loud.mp4", "dur": 1.0}]) assert files.has_audio(str(comfy.temp / out["media"]["filename"])) @needs_ffmpeg def test_export_of_a_window_past_the_end_says_so_and_leaves_nothing(comfy): make_clip(comfy.output / "a.mp4", 1.0) with pytest.raises(stitch.RenderError, match="shorter than the window|past"): stitch.concat([{"filename": "a.mp4", "in": 5, "dur": 1.0}]) with pytest.raises(stitch.RenderError, match="Clip 1"): stitch.concat([{"filename": "a.mp4", "in": 0, "dur": 3.0}]) assert not os.listdir(comfy.temp) @needs_ffmpeg def test_two_exports_in_one_second_do_not_share_a_file(comfy): make_clip(comfy.output / "a.mp4", 1.0) first = stitch.concat([{"filename": "a.mp4", "dur": 1.0}])["media"]["filename"] second = stitch.concat([{"filename": "a.mp4", "dur": 1.0}])["media"]["filename"] assert first != second and (comfy.temp / first).is_file() and (comfy.temp / second).is_file() def _audio_seconds(path): import subprocess out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=duration", "-of", "default=nw=1:nk=1", str(path)], capture_output=True, text=True).stdout.split() return float(out[0]) @needs_ffmpeg def test_a_clip_whose_sound_is_shorter_than_its_picture_does_not_pull_later_sound_early(comfy): import subprocess short = comfy.output / "short.mp4" subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "testsrc=duration=2:size=160x120:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:duration=1.5", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(short)], check=True, capture_output=True) out = stitch.render(projects.Project(width=160, height=120), [clip("short.mp4", dur=2.0), clip("short.mp4", dur=2.0), clip("short.mp4", dur=2.0)]) assert _audio_seconds(comfy.temp / out["media"]["filename"]) == pytest.approx(6.0, abs=0.15) @needs_ffmpeg def test_an_odd_canvas_renders(comfy): make_clip(comfy.output / "a.mp4", 1.0, size="160x120") out = stitch.render(projects.Project(width=161, height=121), [clip("a.mp4", dur=1.0, w=161, h=121)]) assert probe(comfy.temp / out["media"]["filename"])[1:3] == (160, 120) @needs_ffmpeg def test_export_of_clips_with_different_frame_rates_keeps_picture_and_sound_together(comfy): make_clip(comfy.output / "a.mp4", 2.0, rate=25) make_clip(comfy.output / "b.mp4", 2.0, rate=30) out = stitch.concat([{"filename": "a.mp4", "dur": 2.0}, {"filename": "b.mp4", "dur": 2.0}]) path = comfy.temp / out["media"]["filename"] assert probe(path)[0] == pytest.approx(4.0, abs=0.25) assert _audio_seconds(path) == pytest.approx(4.0, abs=0.25) @needs_ffmpeg def test_a_render_says_what_it_left_out(comfy): make_clip(comfy.output / "a.mp4", 1.0) proj = projects.Project(width=160, height=120, overlay_tracks=[{"id": "o1", "kind": "image", "media_ref": "aaaaaaaaaaaa", "start_sec": 0, "duration_sec": 1}], audio_tracks=[{"id": "t1", "media_ref": "bbbbbbbbbbbb", "start_sec": 0}]) out = stitch.render(proj, [clip("a.mp4", dur=1.0)]) assert len(out["warnings"]) == 2 and "overlay" in out["warnings"][0] and "audio" in out["warnings"][1] @needs_ffmpeg def test_a_crossfade_after_a_hard_cut_or_a_gap_renders(comfy): make_clip(comfy.output / "a.mp4", 2.0, size="160x120") c = lambda **k: clip("a.mp4", dur=2.0, **k) # noqa: E731 for first in ({}, {"gap_after": 0.5}): out = stitch.render(projects.Project(width=160, height=120), [c(**first), c(transition="crossfade", tdur=0.5), c()]) assert probe(comfy.temp / out["media"]["filename"])[0] > 5.0 @needs_ffmpeg def test_a_clip_whose_render_is_shorter_than_its_window_is_refused(comfy): make_clip(comfy.output / "a.mp4", 1.0) with pytest.raises(stitch.RenderError, match="Clip 1's render has"): stitch.render(projects.Project(width=160, height=120), [clip("a.mp4", dur=2.0)]) @needs_ffmpeg def test_an_audio_lane_with_no_recorded_length_renders_as_long_as_its_file(comfy, monkeypatch): import subprocess from core import media wav = comfy.root / "song.wav" subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=duration=3", str(wav)], check=True, capture_output=True) monkeypatch.setattr(media, "path_for", lambda ref: wav) monkeypatch.setattr(media, "is_id", lambda ref: True) proj = projects.Project(width=160, height=120, audio_tracks=[{"id": "t", "media_ref": "aaaaaaaaaaaa", "start_sec": 1.0}]) assert stitch.graphics_duration(proj) == pytest.approx(4.0, abs=0.2) def _video_seconds(path): import subprocess return float(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration", "-of", "default=nw=1:nk=1", str(path)], capture_output=True, text=True).stdout.split()[0]) @needs_ffmpeg def test_picture_and_sound_stay_together_when_a_render_runs_a_little_short(comfy): make_clip(comfy.output / "a.mp4", 1.6) c = lambda **k: clip("a.mp4", dur=2.0, **k) # noqa: E731 out = stitch.render(projects.Project(width=160, height=120), [c(), c(), c()]) path = comfy.temp / out["media"]["filename"] assert _video_seconds(path) == pytest.approx(6.0, abs=0.1) assert _audio_seconds(path) == pytest.approx(6.0, abs=0.1) @needs_ffmpeg def test_an_audio_lane_whose_file_has_no_sound_is_left_out_and_said(comfy): make_clip(comfy.output / "quiet.mp4", 1.0, audio=False) proj = projects.Project(width=160, height=120, audio_tracks=[{"id": "t", "kind": "separated", "scene_id": "s1"}]) out = stitch.render(proj, [clip("quiet.mp4", dur=1.0, scene_id="s1")]) assert any("audio lane" in w for w in out["warnings"]) @needs_ffmpeg def test_the_render_is_made_at_the_clips_own_size_not_the_projects(comfy): make_clip(comfy.output / "a.mp4", 1.0, size="160x120") make_clip(comfy.output / "b.mp4", 1.0, size="96x96") spec = lambda name, **k: clip(name, dur=1.0, w=100, h=100, **k) # noqa: E731 (scene settings say 100x100) size = lambda proj, clips: probe(comfy.temp / stitch.render(proj, clips)["media"]["filename"])[1:3] # noqa: E731 clips = [spec("a.mp4", scene_id="s1"), spec("b.mp4", scene_id="s2")] assert size(projects.Project(width=100, height=100), clips) == (160, 120) # the first clip assert size(projects.Project(width=100, height=100, export_size_from="s2"), clips) == (96, 96) assert size(projects.Project(width=100, height=100, export_size_from="project"), clips) == (100, 100) assert size(projects.Project(width=100, height=100, export_size_from="gone"), clips) == (160, 120) def test_a_separated_lane_goes_quiet_with_a_clip_that_is_not_in_the_cut(): proj = projects.Project(width=160, height=120, audio_tracks=[ {"id": "a", "kind": "separated", "scene_id": "gone"}, {"id": "b", "kind": "separated", "scene_id": "here"}, {"id": "c", "media_ref": "aaaaaaaaaaaa", "start_sec": 0}]) assert [t["id"] for t in stitch._live_tracks(proj, {"here": {}})] == ["b", "c"] def test_a_separated_lane_starts_where_its_clip_is_in_this_render_plus_its_slide(tmp_path, monkeypatch): f = tmp_path / "x.mp4" f.write_bytes(b"") monkeypatch.setattr(stitch.files, "has_audio", lambda p: True) proj = projects.Project(width=160, height=120, audio_tracks=[ {"id": "a", "kind": "separated", "scene_id": "s2", "start_sec": 99, "offset_sec": 0.5}, # start_sec is the editor's guess, the render's own timeline wins {"id": "b", "kind": "separated", "scene_id": "s2", "offset_sec": -9}]) clips = [{"scene_id": "s1", "dur": 2.0}, {"scene_id": "s2", "dur": 3.0}] by = {c["scene_id"]: c for c in clips} out = stitch._audio_tracks(proj, by, lambda c: str(f), stitch._clip_starts(clips)) assert [round(t["start_sec"], 2) for t in out] == [2.5, 0.0] def test_clip_starts_follow_the_renders_own_clock_gaps_and_overlapping_seams(): clips = [{"scene_id": "a", "dur": 2.0, "gap_after": 1.0, "transition": "crossfade", "tdur": 0.5}, {"scene_id": "b", "dur": 3.0, "transition": "crossfade", "tdur": 0.5}, {"scene_id": "c", "dur": 1.0}] assert stitch._clip_starts(clips) == {"a": 0.0, "b": 2.5, "c": 5.0} # --- a picture render ---------------------------------------------------------- def _picture(path, size=(161, 121)): from PIL import Image Image.new("RGB", size, (200, 40, 40)).save(path) return path @needs_ffmpeg def test_a_picture_render_plays_as_a_clip_of_the_length_asked_with_even_sides(comfy): _picture(comfy.output / "p.png") path = files.clip_path({"filename": "p.png", "subfolder": "", "type": "output", "in": 0, "dur": 1.5}) seconds, w, h, audio = probe(path) assert abs(seconds - 1.5) < 0.2 and (w, h) == (160, 120) and not audio assert files.clip_path({"filename": "p.png", "subfolder": "", "type": "output", "in": 0, "dur": 1.5}) == path # made once longer = files.clip_path({"filename": "p.png", "subfolder": "", "type": "output", "in": 0.5, "dur": 2.0}) assert longer != path and probe(longer)[0] > 2.3 # a window that starts later has the picture for it @needs_ffmpeg def test_a_picture_render_trims_previews_and_exports_like_any_clip(comfy): _picture(comfy.output / "p.png", (160, 120)) make_clip(comfy.output / "v.mp4", seconds=1.0) spec = {"filename": "p.png", "subfolder": "", "type": "output", "in": 0, "dur": 1.0} out = files.temp_file("seg.mp4") files.trim(files.clip_path(spec), out, 0, 1.0) assert abs(probe(out)[0] - 1.0) < 0.2 rendered = stitch.render(projects.Project(width=160, height=120), [clip("p.png", dur=1.0), clip("v.mp4", dur=1.0)]) got = comfy.temp / rendered["media"]["filename"] if rendered["media"].get("type") == "temp" else comfy.output / rendered["media"]["filename"] assert abs(probe(got)[0] - 2.0) < 0.3 thumb = files.temp_file("thumb.jpg") files.poster(str(comfy.output / "p.png"), thumb) # the timeline's small still of a picture render assert os.path.getsize(thumb) > 0 still = files.temp_file("last.png") files.last_frame(files.clip_path(spec), still, 0, 1.0) assert os.path.getsize(still) > 0