diff --git a/shotcut/agent-harness/cli_anything/shotcut/core/timeline.py b/shotcut/agent-harness/cli_anything/shotcut/core/timeline.py index 3bb2081e5..74879dcae 100644 --- a/shotcut/agent-harness/cli_anything/shotcut/core/timeline.py +++ b/shotcut/agent-harness/cli_anything/shotcut/core/timeline.py @@ -152,7 +152,11 @@ def _entry_duration_frames(session: Session, entry: dict) -> int: out_point = entry.get("out") if not out_point: raise RuntimeError("Absolute timeline placement requires clips with finite out points") - return parse_time_input(out_point, fps_num, fps_den) - parse_time_input(in_point, fps_num, fps_den) + return ( + parse_time_input(out_point, fps_num, fps_den) + - parse_time_input(in_point, fps_num, fps_den) + + 1 + ) def _absolute_insertion_point( @@ -187,7 +191,11 @@ def _absolute_insertion_point( if out_tc is None: prod = mlt_xml.find_element_by_id(session.root, child.get("producer", "")) out_tc = prod.get("out", "00:00:00.000") if prod is not None else "00:00:00.000" - duration = parse_time_input(out_tc, fps_num, fps_den) - parse_time_input(in_tc, fps_num, fps_den) + duration = ( + parse_time_input(out_tc, fps_num, fps_den) + - parse_time_input(in_tc, fps_num, fps_den) + + 1 + ) start = timeline_cursor end = start + duration if target == start: @@ -249,12 +257,13 @@ def _update_tractor_out(session: Session) -> None: out_tc = "00:00:00.000" track_frames += parse_time_input(out_tc, fps_num, fps_den) track_frames -= parse_time_input(in_tc, fps_num, fps_den) + track_frames += 1 elif child.tag == "blank": track_frames += parse_time_input(child.get("length", "00:00:00.000"), fps_num, fps_den) max_frames = max(max_frames, track_frames) - out_tc = frames_to_timecode(max_frames, fps_num, fps_den) if max_frames > 0 else "00:00:00.000" + out_tc = frames_to_timecode(max_frames - 1, fps_num, fps_den) if max_frames > 0 else "00:00:00.000" mlt_xml.set_tractor_out(session.root, out_tc) # Sync background producer and playlist entry to match — melt ignores @@ -263,7 +272,7 @@ def _update_tractor_out(session: Session) -> None: if bg_producer is not None: bg_producer.set("out", out_tc) mlt_xml.set_property(bg_producer, "length", - frames_to_timecode(max_frames + 1, fps_num, fps_den) + frames_to_timecode(max_frames, fps_num, fps_den) if max_frames > 0 else "00:00:00.040") bg_playlist = mlt_xml.find_element_by_id(session.root, "background") if bg_playlist is not None: @@ -579,7 +588,7 @@ def remove_clip(session: Session, track_index: int, clip_index: int, playlist.remove(target_child) in_frames = parse_time_input(in_tc, fps_num, fps_den) out_frames = parse_time_input(out_tc, fps_num, fps_den) - duration_frames = out_frames - in_frames + duration_frames = out_frames - in_frames + 1 if duration_frames > 0: duration_tc = frames_to_timecode(duration_frames, fps_num, fps_den) blank = ET.Element("blank") @@ -748,8 +757,19 @@ def split_clip(session: Session, track_index: int, clip_index: int, if old_out is None: raise RuntimeError("Cannot split clip without out point") - # First part: original in → split point (AFTER transition restore) - child.set("out", at) + old_in_frames = parse_time_input(old_in, fps_num, fps_den) + old_out_frames = parse_time_input(old_out, fps_num, fps_den) + split_frames = parse_time_input(at, fps_num, fps_den) + if split_frames <= old_in_frames: + raise ValueError("Split point must be after the clip in point") + if split_frames > old_out_frames: + raise ValueError("Split point must not exceed the clip out point") + + first_out = frames_to_timecode(split_frames - 1, fps_num, fps_den) + + # MLT out-points are inclusive, so the first half must end on + # the frame immediately before the split point. + child.set("out", first_out) # Second part: split point → original out # Create a copy of the timeline chain @@ -782,7 +802,7 @@ def split_clip(session: Session, track_index: int, clip_index: int, "track_index": track_index, "clip_index": clip_index, "at": at, - "first_clip": {"chain_id": producer_id, "in": old_in, "out": at}, + "first_clip": {"chain_id": producer_id, "in": old_in, "out": first_out}, "second_clip": {"chain_id": new_chain_id, "in": at, "out": old_out}, } entry_count += 1 diff --git a/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py b/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py index ce2c2cb7b..fd34bd574 100644 --- a/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py +++ b/shotcut/agent-harness/cli_anything/shotcut/tests/test_core.py @@ -109,6 +109,30 @@ class TestMltXml: assert parsed.tag == "mlt" assert parsed.find("profile").get("width") == "1920" + def test_write_mlt_normalizes_late_media_nodes(self, tmp_path): + root = create_blank_project(PROFILE_HD1080) + late_chain = ET.Element("chain") + late_chain.set("id", "late_chain") + late_chain.set("in", "00:00:00.000") + late_chain.set("out", "00:00:01.000") + set_property(late_chain, "resource", "/tmp/fake.mp4") + set_property(late_chain, "mlt_service", "avformat-novalidate") + root.append(late_chain) + + tmpfile = str(tmp_path / "ordered.mlt") + write_mlt(root, tmpfile) + parsed = parse_mlt(tmpfile) + children = list(parsed) + first_playlist_or_tractor = min( + idx for idx, child in enumerate(children) if child.tag in ("playlist", "tractor") + ) + late_idx = next( + idx + for idx, child in enumerate(children) + if child.tag == "chain" and child.get("id") == "late_chain" + ) + assert late_idx < first_playlist_or_tractor + def test_properties(self): import xml.etree.ElementTree as ET elem = ET.Element("producer") @@ -449,6 +473,15 @@ class TestTimeline: if c.get("clip_index") is not None] assert len(clips) == 0 + def test_remove_clip_without_ripple_preserves_inclusive_duration(self, session_with_track, dummy_file): + clip_id = media_mod.import_media(session_with_track, dummy_file)["clip_id"] + tl_mod.add_clip(session_with_track, clip_id, 1, + in_point="00:00:00.000", out_point="00:00:01.000") + tl_mod.remove_clip(session_with_track, 1, 0, ripple=False) + blank = next(item for item in tl_mod.list_clips(session_with_track, 1) + if item.get("type") == "blank") + assert parse_time_input(blank["length"]) == timecode_to_frames("00:00:01.000") + 1 + def test_trim_clip(self, session_with_clip): result = tl_mod.trim_clip(session_with_clip, 1, 0, in_point="00:00:02.000", out_point="00:00:04.000") @@ -457,7 +490,8 @@ class TestTimeline: def test_split_clip(self, session_with_clip): result = tl_mod.split_clip(session_with_clip, 1, 0, "00:00:03.000") - assert result["first_clip"]["out"] == "00:00:03.000" + expected_first_out = frames_to_timecode(timecode_to_frames("00:00:03.000") - 1) + assert result["first_clip"]["out"] == expected_first_out assert result["second_clip"]["in"] == "00:00:03.000" clips = [c for c in tl_mod.list_clips(session_with_clip, 1) if c.get("clip_index") is not None] @@ -520,6 +554,18 @@ class TestTimeline: at_time="00:00:03.000", ) + def test_add_clip_at_time_uses_inclusive_duration(self, session_with_track, dummy_file): + clip_id = media_mod.import_media(session_with_track, dummy_file)["clip_id"] + tl_mod.add_clip(session_with_track, clip_id, 1, + in_point="00:00:00.000", out_point="00:00:01.000") + next_start = frames_to_timecode(timecode_to_frames("00:00:01.000") + 1) + tl_mod.add_clip(session_with_track, clip_id, 1, + in_point="00:00:00.000", out_point="00:00:01.000", + at_time=next_start) + items = tl_mod.list_clips(session_with_track, 1) + assert len([item for item in items if item.get("type") == "blank"]) == 0 + assert len([item for item in items if item.get("clip_index") is not None]) == 2 + def test_add_clip_at_time_inserts_gap(self, session_with_track, dummy_file): clip_id = media_mod.import_media(session_with_track, dummy_file)["clip_id"] result = tl_mod.add_clip( @@ -547,14 +593,16 @@ class TestTimeline: def test_add_clip_at_boundary_between_clips(self, session_with_track, dummy_file): clip_id = media_mod.import_media(session_with_track, dummy_file)["clip_id"] + next_start = frames_to_timecode(timecode_to_frames("00:00:01.000") + 1) + third_start = frames_to_timecode((timecode_to_frames("00:00:01.000") + 1) * 2) tl_mod.add_clip(session_with_track, clip_id, 1, in_point="00:00:00.000", out_point="00:00:01.000") tl_mod.add_clip(session_with_track, clip_id, 1, in_point="00:00:00.000", out_point="00:00:01.000", - at_time="00:00:01.000") + at_time=next_start) tl_mod.add_clip(session_with_track, clip_id, 1, in_point="00:00:00.000", out_point="00:00:01.000", - at_time="00:00:01.000") + at_time=third_start) clips = tl_mod.list_clips(session_with_track, 1) real = [c for c in clips if c.get("clip_index") is not None] assert len(real) == 3 @@ -565,14 +613,16 @@ class TestTimeline: def test_add_clip_at_after_two_clips(self, session_with_track, dummy_file): clip_id = media_mod.import_media(session_with_track, dummy_file)["clip_id"] + next_start = frames_to_timecode(timecode_to_frames("00:00:01.000") + 1) + third_start = frames_to_timecode((timecode_to_frames("00:00:01.000") + 1) * 2) tl_mod.add_clip(session_with_track, clip_id, 1, in_point="00:00:00.000", out_point="00:00:01.000") tl_mod.add_clip(session_with_track, clip_id, 1, in_point="00:00:00.000", out_point="00:00:01.000", - at_time="00:00:01.000") + at_time=next_start) tl_mod.add_clip(session_with_track, clip_id, 1, in_point="00:00:00.000", out_point="00:00:01.000", - at_time="00:00:02.000") + at_time=third_start) clips = tl_mod.list_clips(session_with_track, 1) real = [c for c in clips if c.get("clip_index") is not None] assert len(real) == 3 @@ -710,7 +760,8 @@ class TestTimeline: clip_a_index=0, duration_frames=14) orig_out = parse_time_input("00:00:10.000", 30000, 1001) result = tl_mod.split_clip(session_with_three_clips, 1, 0, "00:00:05.000") - assert result["first_clip"]["out"] == "00:00:05.000" + expected_first_out = frames_to_timecode(timecode_to_frames("00:00:05.000") - 1) + assert result["first_clip"]["out"] == expected_first_out assert abs(parse_time_input(result["second_clip"]["out"], 30000, 1001) - orig_out) <= 1 diff --git a/shotcut/agent-harness/cli_anything/shotcut/tests/test_full_e2e.py b/shotcut/agent-harness/cli_anything/shotcut/tests/test_full_e2e.py index dc0fac969..53e4a56b8 100644 --- a/shotcut/agent-harness/cli_anything/shotcut/tests/test_full_e2e.py +++ b/shotcut/agent-harness/cli_anything/shotcut/tests/test_full_e2e.py @@ -8,10 +8,12 @@ CLI subprocess, and melt rendering. import os import sys import json +import shutil import tempfile import subprocess import pytest +from PIL import Image, ImageStat sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) @@ -750,6 +752,42 @@ class TestMeltRenderE2E: assert os.path.exists(output_path) assert os.path.getsize(output_path) > 0 + def test_render_imported_media_is_not_black(self, session, video): + if shutil.which("ffmpeg") is None: + pytest.skip("ffmpeg is required for frame extraction") + + tl_mod.add_track(session, "video", "V1") + clip_id = media_mod.import_media(session, video)["clip_id"] + tl_mod.add_clip(session, clip_id, 1, "00:00:00.000", "00:00:01.000") + + with tempfile.TemporaryDirectory() as tmp_dir: + output_path = os.path.join(tmp_dir, "render.mp4") + frame_path = os.path.join(tmp_dir, "frame.png") + + result = export_mod.render(session, output_path, "default", overwrite=True) + assert result["method"] == "melt" + assert os.path.exists(output_path) + + subprocess.run( + [ + "ffmpeg", + "-y", + "-ss", + "00:00:00.500", + "-i", + output_path, + "-frames:v", + "1", + frame_path, + ], + check=True, + capture_output=True, + text=True, + timeout=120, + ) + mean = ImageStat.Stat(Image.open(frame_path).convert("RGB")).mean + assert max(mean) > 5, f"Rendered frame appears black: {mean}" + # ============================================================================ # 10. CHAIN LENGTH BUG REGRESSION diff --git a/shotcut/agent-harness/cli_anything/shotcut/utils/mlt_xml.py b/shotcut/agent-harness/cli_anything/shotcut/utils/mlt_xml.py index 463a5e53e..0b6882e0f 100644 --- a/shotcut/agent-harness/cli_anything/shotcut/utils/mlt_xml.py +++ b/shotcut/agent-harness/cli_anything/shotcut/utils/mlt_xml.py @@ -58,8 +58,13 @@ def parse_mlt(filepath: str) -> ET.Element: def write_mlt(root: ET.Element, filepath: str) -> None: - """Write an MLT XML tree to a file.""" + """Write an MLT XML tree to a file. + + Normalize top-level media ordering first so playlists never + forward-reference chain/producer nodes declared later in the XML. + """ pretty = copy.deepcopy(root) + normalize_top_level_order(pretty) ET.indent(pretty, space=" ") tree = ET.ElementTree(pretty) tree.write(filepath, xml_declaration=True, encoding="utf-8") @@ -257,6 +262,38 @@ def create_blank_project(profile: dict) -> ET.Element: return root +def _first_playlist_or_tractor_index(root: ET.Element) -> int: + """Return the first top-level playlist/tractor index, or len(root).""" + for idx, child in enumerate(list(root)): + if child.tag in ("playlist", "tractor"): + return idx + return len(root) + + +def insert_before_playlists_and_tractors(root: ET.Element, element: ET.Element) -> None: + """Insert a top-level declaration before any playlists or tractors.""" + root.insert(_first_playlist_or_tractor_index(root), element) + _set_parent(element, root) + + +def normalize_top_level_order(root: ET.Element) -> None: + """Move late top-level chains/producers ahead of playlists and tractors.""" + late_media: list[ET.Element] = [] + seen_playlist_or_tractor = False + for child in list(root): + if child.tag in ("playlist", "tractor"): + seen_playlist_or_tractor = True + elif child.tag in ("producer", "chain") and seen_playlist_or_tractor: + late_media.append(child) + + for element in late_media: + root.remove(element) + + insert_idx = _first_playlist_or_tractor_index(root) + for offset, element in enumerate(late_media): + root.insert(insert_idx + offset, element) + + def _add_system_transitions(tractor: ET.Element, track_index: int, root: ET.Element = None, track_type: str = "video") -> None: @@ -403,9 +440,9 @@ def create_chain(root: ET.Element, resource: str, if insert_idx is not None: root.insert(insert_idx, chain) + _set_parent(chain, root) else: - root.append(chain) - _set_parent(chain, root) + insert_before_playlists_and_tractors(root, chain) return chain @@ -421,9 +458,7 @@ def create_producer(root: ET.Element, resource: str, "producer", prod_id, resource, in_point, out_point, caption, service ) - insert_idx = find_insert_index_for_timeline_chain(root) - root.insert(insert_idx, producer) - _set_parent(producer, root) + insert_before_playlists_and_tractors(root, producer) return producer