Fix Shotcut melt rendering and inclusive timeline timing

This commit is contained in:
yuhao
2026-04-20 11:58:37 +00:00
parent 7c62dcbdac
commit ef86f4dcc7
4 changed files with 164 additions and 20 deletions
@@ -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
@@ -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
@@ -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
@@ -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