mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-30 17:34:27 +08:00
Fix Shotcut melt rendering and inclusive timeline timing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user