mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-29 07:30:51 +08:00
fix(kdenlive): rewrite XML output as Gen 5 format for native kdenlive compatibility
- Replace string-concatenation with ElementTree for proper XML generation - Generate Gen 5 (doc v1.1) nested structure: per-track tractors, sequence tractor with UUID, main_bin playlist, project tractor - Fix filters not taking effect: add id attribute and kdenlive_id property on filter elements (closes #213) - Fix "removed effect" dialog: use child <property> for mlt_service instead of XML attribute - Add kdenlive_name mappings for fade/brightness/speed effects - Rewrite tests from string-matching to ET-based assertions Fix #213
This commit is contained in:
@@ -35,6 +35,7 @@ FILTER_REGISTRY = {
|
||||
},
|
||||
"fade_in_video": {
|
||||
"mlt_service": "brightness",
|
||||
"kdenlive_name": "fade_from_black",
|
||||
"category": "transition",
|
||||
"params": {
|
||||
"duration": {"type": "float", "default": 1.0, "min": 0.01, "max": 60.0},
|
||||
@@ -42,6 +43,7 @@ FILTER_REGISTRY = {
|
||||
},
|
||||
"fade_out_video": {
|
||||
"mlt_service": "brightness",
|
||||
"kdenlive_name": "fade_to_black",
|
||||
"category": "transition",
|
||||
"params": {
|
||||
"duration": {"type": "float", "default": 1.0, "min": 0.01, "max": 60.0},
|
||||
@@ -49,6 +51,7 @@ FILTER_REGISTRY = {
|
||||
},
|
||||
"fade_in_audio": {
|
||||
"mlt_service": "volume",
|
||||
"kdenlive_name": "fadein",
|
||||
"category": "transition",
|
||||
"params": {
|
||||
"duration": {"type": "float", "default": 1.0, "min": 0.01, "max": 60.0},
|
||||
@@ -56,6 +59,7 @@ FILTER_REGISTRY = {
|
||||
},
|
||||
"fade_out_audio": {
|
||||
"mlt_service": "volume",
|
||||
"kdenlive_name": "fadeout",
|
||||
"category": "transition",
|
||||
"params": {
|
||||
"duration": {"type": "float", "default": 1.0, "min": 0.01, "max": 60.0},
|
||||
@@ -87,6 +91,7 @@ FILTER_REGISTRY = {
|
||||
},
|
||||
"speed": {
|
||||
"mlt_service": "timewarp",
|
||||
"kdenlive_name": "speed",
|
||||
"category": "effect",
|
||||
"params": {
|
||||
"speed": {"type": "float", "default": 1.0, "min": 0.01, "max": 100.0},
|
||||
|
||||
@@ -6,8 +6,10 @@ No Kdenlive or melt installation required.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
@@ -25,10 +27,17 @@ from cli_anything.kdenlive.core.export import generate_kdenlive_xml, list_render
|
||||
from cli_anything.kdenlive.core.session import Session
|
||||
from cli_anything.kdenlive.utils.mlt_xml import (
|
||||
seconds_to_timecode, timecode_to_seconds, seconds_to_frames,
|
||||
xml_escape, build_mlt_xml,
|
||||
build_mlt_xml,
|
||||
)
|
||||
|
||||
|
||||
def _find_sequence(root):
|
||||
for tr in root.findall("tractor"):
|
||||
if tr.find("property[@name='kdenlive:uuid']") is not None:
|
||||
return tr
|
||||
return None
|
||||
|
||||
|
||||
# ── XML Generation Tests ───────────────────────────────────────
|
||||
|
||||
class TestXMLGeneration:
|
||||
@@ -54,97 +63,103 @@ class TestXMLGeneration:
|
||||
|
||||
return proj
|
||||
|
||||
def _parse(self, proj=None):
|
||||
proj = proj or self._make_full_project()
|
||||
return ET.fromstring(generate_kdenlive_xml(proj))
|
||||
|
||||
def test_xml_is_string(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert isinstance(xml, str)
|
||||
|
||||
def test_xml_has_mlt_root(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert xml.startswith('<?xml version="1.0"')
|
||||
assert "<mlt " in xml
|
||||
assert "</mlt>" in xml
|
||||
root = self._parse()
|
||||
assert root.tag == "mlt"
|
||||
|
||||
def test_xml_has_profile(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "<profile " in xml
|
||||
assert 'width="1920"' in xml
|
||||
assert 'height="1080"' in xml
|
||||
assert 'frame_rate_num="30"' in xml
|
||||
root = self._parse()
|
||||
profile = root.find("profile")
|
||||
assert profile is not None
|
||||
assert profile.get("width") == "1920"
|
||||
assert profile.get("height") == "1080"
|
||||
assert profile.get("frame_rate_num") == "30"
|
||||
|
||||
def test_xml_has_producers(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert '<producer id="clip0"' in xml
|
||||
assert '<producer id="clip1"' in xml
|
||||
assert '<producer id="clip2"' in xml
|
||||
assert "interview.mp4" in xml
|
||||
def test_xml_has_chains_for_clips(self):
|
||||
root = self._parse()
|
||||
chains = root.findall("chain")
|
||||
sources = [c.find("property[@name='resource']").text for c in chains if c.find("property[@name='resource']") is not None]
|
||||
assert any("interview.mp4" in s for s in sources)
|
||||
|
||||
def test_xml_has_playlists(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# Each track has A/B playlists, plus main_bin
|
||||
assert '<playlist id="main_bin">' in xml
|
||||
assert '<playlist id="playlist0">' in xml
|
||||
assert '<playlist id="playlist2">' in xml
|
||||
assert '<playlist id="playlist4">' in xml
|
||||
root = self._parse()
|
||||
playlists = root.findall("playlist")
|
||||
ids = [p.get("id") for p in playlists]
|
||||
assert "playlist0" in ids
|
||||
assert "playlist1" in ids
|
||||
|
||||
def test_xml_has_tractor(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# Track tractors + main tractor
|
||||
assert '<tractor id="tractor0"' in xml
|
||||
assert '<track producer="black_track"/>' in xml
|
||||
def test_xml_has_sequence_tractor(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
assert seq is not None
|
||||
assert seq.find("property[@name='kdenlive:uuid']") is not None
|
||||
|
||||
def test_xml_has_filters(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert 'mlt_service="brightness"' in xml
|
||||
root = self._parse()
|
||||
filters = [
|
||||
f for f in root.findall(".//entry/filter")
|
||||
if any(p.text == "brightness" for p in f.findall("property[@name='mlt_service']"))
|
||||
]
|
||||
assert len(filters) > 0
|
||||
|
||||
def test_xml_has_transitions(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert '<transition mlt_service="luma"' in xml
|
||||
def test_xml_has_user_transition(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
luma = seq.findall("transition[@mlt_service='luma']")
|
||||
assert len(luma) == 1
|
||||
|
||||
def test_xml_has_guides(self):
|
||||
proj = self._make_full_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "<kdenlivedoc>" in xml
|
||||
assert '<guide ' in xml
|
||||
assert 'comment="Start"' in xml
|
||||
def test_xml_has_guides_in_sequence(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
guides_prop = seq.find("property[@name='kdenlive:sequenceproperties.guides']")
|
||||
assert guides_prop is not None
|
||||
data = json.loads(guides_prop.text)
|
||||
assert len(data) == 2
|
||||
assert data[0]["comment"] == "Start"
|
||||
assert data[1]["comment"] == "End"
|
||||
|
||||
def test_xml_empty_project(self):
|
||||
proj = create_project()
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "<mlt " in xml
|
||||
assert "</mlt>" in xml
|
||||
assert "<profile " in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
assert root.tag == "mlt"
|
||||
assert root.find("profile") is not None
|
||||
|
||||
def test_xml_special_characters_escaped(self):
|
||||
proj = create_project(name='Test "Project" <1>')
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert '<' in xml
|
||||
assert '>' in xml
|
||||
assert '"' in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
assert root.get("title") == 'Test "Project" <1>'
|
||||
|
||||
def test_xml_clip_type_numbers(self):
|
||||
proj = create_project()
|
||||
import_clip(proj, "/a.mp4", name="V", clip_type="video", duration=10.0)
|
||||
import_clip(proj, "/b.mp3", name="A", clip_type="audio", duration=10.0)
|
||||
import_clip(proj, "/c.jpg", name="I", clip_type="image", duration=5.0)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert 'kdenlive:clip_type">0<' in xml
|
||||
assert 'kdenlive:clip_type">1<' in xml
|
||||
assert 'kdenlive:clip_type">2<' in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
type_nums = set()
|
||||
for chain in root.findall("chain"):
|
||||
ct = chain.find("property[@name='kdenlive:clip_type']")
|
||||
if ct is not None:
|
||||
type_nums.add(ct.text)
|
||||
assert "0" in type_nums # video
|
||||
assert "1" in type_nums # audio
|
||||
assert "2" in type_nums # image
|
||||
|
||||
def test_xml_sd_pal_profile(self):
|
||||
proj = create_project(profile="sd_pal")
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert 'width="720"' in xml
|
||||
assert 'height="576"' in xml
|
||||
assert 'progressive="0"' in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
profile = root.find("profile")
|
||||
assert profile.get("width") == "720"
|
||||
assert profile.get("height") == "576"
|
||||
assert profile.get("progressive") == "0"
|
||||
|
||||
|
||||
# ── Format Validation Tests ─────────────────────────────────────
|
||||
@@ -208,45 +223,40 @@ class TestFormatValidation:
|
||||
assert key in entry, f"Missing timeline clip key: {key}"
|
||||
|
||||
def test_xml_well_formed_basic(self):
|
||||
"""Check basic XML well-formedness (no unclosed tags)."""
|
||||
proj = create_project()
|
||||
import_clip(proj, "/a.mp4", name="A", duration=10.0)
|
||||
add_track(proj)
|
||||
add_clip_to_track(proj, 0, "clip0", out_point=5.0)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# Count open/close tags (self-closing playlists like <playlist .../> count too)
|
||||
assert xml.count("<mlt ") == xml.count("</mlt>")
|
||||
assert xml.count("<tractor") == xml.count("</tractor>")
|
||||
open_playlists = xml.count("<playlist")
|
||||
close_playlists = xml.count("</playlist>")
|
||||
self_close_playlists = xml.count('<playlist id="playlist1"/>')
|
||||
assert open_playlists == close_playlists + self_close_playlists
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
assert root.tag == "mlt"
|
||||
|
||||
def test_xml_producer_count_matches_bin(self):
|
||||
def test_xml_chain_count_for_bin_clips(self):
|
||||
proj = create_project()
|
||||
import_clip(proj, "/a.mp4", name="A", duration=10.0)
|
||||
import_clip(proj, "/b.mp4", name="B", duration=20.0)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# 2 bin producers + 1 black_track (no timeline producers since no tracks)
|
||||
assert xml.count("<producer ") == 3
|
||||
assert xml.count("</producer>") == 3
|
||||
add_track(proj)
|
||||
add_clip_to_track(proj, 0, "clip0", out_point=10.0)
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
# 1 track chain for clip0 + 2 bin chains (clip0 and clip1)
|
||||
chains = root.findall("chain")
|
||||
assert len(chains) >= 3
|
||||
|
||||
|
||||
# ── Workflow E2E Tests ──────────────────────────────────────────
|
||||
|
||||
class TestWorkflowE2E:
|
||||
def test_basic_edit_workflow(self):
|
||||
"""Create project, import clip, put on timeline, export XML."""
|
||||
proj = create_project(name="BasicEdit", profile="hd1080p30")
|
||||
import_clip(proj, "/footage/scene1.mp4", name="Scene1", duration=60.0)
|
||||
add_track(proj, track_type="video")
|
||||
add_clip_to_track(proj, 0, "clip0", position=0.0, out_point=30.0)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "scene1.mp4" in xml
|
||||
assert '<entry producer="clip0"' in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
sources = [c.find("property[@name='resource']").text for c in root.findall("chain") if c.find("property[@name='resource']") is not None]
|
||||
assert any("scene1.mp4" in s for s in sources)
|
||||
entries = root.findall(".//playlist/entry")
|
||||
assert len(entries) > 0
|
||||
|
||||
def test_multicam_workflow(self):
|
||||
"""Multiple video tracks with clips."""
|
||||
proj = create_project(name="Multicam")
|
||||
import_clip(proj, "/cam1.mp4", name="Cam1", duration=60.0)
|
||||
import_clip(proj, "/cam2.mp4", name="Cam2", duration=60.0)
|
||||
@@ -256,12 +266,12 @@ class TestWorkflowE2E:
|
||||
add_clip_to_track(proj, 1, "clip1", position=0.0, out_point=30.0)
|
||||
tracks = list_tracks(proj)
|
||||
assert len(tracks) == 2
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "playlist0" in xml
|
||||
assert "playlist1" in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
playlist_ids = [p.get("id") for p in root.findall("playlist")]
|
||||
assert "playlist0" in playlist_ids
|
||||
assert "playlist1" in playlist_ids
|
||||
|
||||
def test_audio_video_workflow(self):
|
||||
"""Video and audio tracks together."""
|
||||
proj = create_project(name="AV")
|
||||
import_clip(proj, "/video.mp4", name="Video", duration=60.0)
|
||||
import_clip(proj, "/music.mp3", name="Music", duration=180.0, clip_type="audio")
|
||||
@@ -269,12 +279,12 @@ class TestWorkflowE2E:
|
||||
add_track(proj, track_type="audio")
|
||||
add_clip_to_track(proj, 0, "clip0", out_point=60.0)
|
||||
add_clip_to_track(proj, 1, "clip1", out_point=60.0)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "video.mp4" in xml
|
||||
assert "music.mp3" in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
sources = [c.find("property[@name='resource']").text for c in root.findall("chain") if c.find("property[@name='resource']") is not None]
|
||||
assert any("video.mp4" in s for s in sources)
|
||||
assert any("music.mp3" in s for s in sources)
|
||||
|
||||
def test_trim_and_split_workflow(self):
|
||||
"""Import, trim, split."""
|
||||
proj = create_project()
|
||||
import_clip(proj, "/long.mp4", name="Long", duration=120.0)
|
||||
add_track(proj)
|
||||
@@ -285,7 +295,6 @@ class TestWorkflowE2E:
|
||||
assert len(proj["tracks"][0]["clips"]) == 2
|
||||
|
||||
def test_filter_chain_workflow(self):
|
||||
"""Apply multiple filters to a clip."""
|
||||
proj = create_project()
|
||||
import_clip(proj, "/video.mp4", name="V", duration=30.0)
|
||||
add_track(proj)
|
||||
@@ -298,12 +307,11 @@ class TestWorkflowE2E:
|
||||
filters = list_filters(proj, 0, 0)
|
||||
assert len(filters) == 3
|
||||
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# 3 user filters + internal track filters (volume/panner/audiolevel per track + master)
|
||||
assert xml.count('kdenlive:filter_name') == 3
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
user_filters = root.findall(".//entry/filter")
|
||||
assert len(user_filters) == 3
|
||||
|
||||
def test_transition_workflow(self):
|
||||
"""Two tracks with a dissolve transition."""
|
||||
proj = create_project()
|
||||
import_clip(proj, "/a.mp4", name="A", duration=30.0)
|
||||
import_clip(proj, "/b.mp4", name="B", duration=30.0)
|
||||
@@ -315,11 +323,13 @@ class TestWorkflowE2E:
|
||||
|
||||
transitions = list_transitions(proj)
|
||||
assert len(transitions) == 1
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "<transition " in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
seq = _find_sequence(root)
|
||||
assert seq is not None
|
||||
user_trans = [t for t in seq.findall("transition") if t.find("property[@name='internal_added']") is None]
|
||||
assert len(user_trans) >= 1
|
||||
|
||||
def test_guide_workflow(self):
|
||||
"""Add guides and verify in XML."""
|
||||
proj = create_project()
|
||||
add_guide(proj, 0.0, label="Intro")
|
||||
add_guide(proj, 30.0, label="Main Content")
|
||||
@@ -328,13 +338,18 @@ class TestWorkflowE2E:
|
||||
guides = list_guides(proj)
|
||||
assert len(guides) == 3
|
||||
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert 'comment="Intro"' in xml
|
||||
assert 'comment="Main Content"' in xml
|
||||
assert 'comment="Outro"' in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
seq = _find_sequence(root)
|
||||
assert seq is not None
|
||||
guides_prop = seq.find("property[@name='kdenlive:sequenceproperties.guides']")
|
||||
assert guides_prop is not None
|
||||
data = json.loads(guides_prop.text)
|
||||
assert len(data) == 3
|
||||
assert data[0]["comment"] == "Intro"
|
||||
assert data[1]["comment"] == "Main Content"
|
||||
assert data[2]["comment"] == "Outro"
|
||||
|
||||
def test_undo_redo_workflow(self):
|
||||
"""Full undo/redo cycle."""
|
||||
sess = Session()
|
||||
proj = create_project(name="UndoTest")
|
||||
sess.set_project(proj)
|
||||
@@ -347,24 +362,19 @@ class TestWorkflowE2E:
|
||||
add_track(proj)
|
||||
assert len(proj["tracks"]) == 1
|
||||
|
||||
# Undo add track
|
||||
sess.undo()
|
||||
assert len(sess.get_project()["tracks"]) == 0
|
||||
|
||||
# Undo import clip
|
||||
sess.undo()
|
||||
assert len(sess.get_project()["bin"]) == 0
|
||||
|
||||
# Redo import clip
|
||||
sess.redo()
|
||||
assert len(sess.get_project()["bin"]) == 1
|
||||
|
||||
# Redo add track
|
||||
sess.redo()
|
||||
assert len(sess.get_project()["tracks"]) == 1
|
||||
|
||||
def test_save_load_roundtrip(self):
|
||||
"""Full project save/load roundtrip."""
|
||||
proj = create_project(name="Roundtrip", profile="hd1080p25")
|
||||
import_clip(proj, "/vid.mp4", name="Video", duration=60.0)
|
||||
import_clip(proj, "/aud.wav", name="Audio", duration=60.0, clip_type="audio")
|
||||
@@ -391,10 +401,9 @@ class TestWorkflowE2E:
|
||||
assert len(loaded["transitions"]) == 1
|
||||
assert len(loaded["guides"]) == 1
|
||||
|
||||
# Verify XML can be generated from loaded project
|
||||
xml = generate_kdenlive_xml(loaded)
|
||||
assert "<mlt " in xml
|
||||
assert "vid.mp4" in xml
|
||||
root = ET.fromstring(xml)
|
||||
assert root.tag == "mlt"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
@@ -410,13 +419,11 @@ class TestWorkflowE2E:
|
||||
from cli_anything.kdenlive.core.project import PROFILES
|
||||
for name in PROFILES:
|
||||
proj = create_project(profile=name)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert "<mlt " in xml
|
||||
assert "</mlt>" in xml
|
||||
assert "<profile " in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
assert root.tag == "mlt"
|
||||
assert root.find("profile") is not None
|
||||
|
||||
def test_complex_timeline_xml(self):
|
||||
"""Complex project with multiple clips, filters, transitions."""
|
||||
proj = create_project(name="Complex", profile="hd1080p30")
|
||||
for i in range(5):
|
||||
import_clip(proj, f"/clip{i}.mp4", name=f"Clip{i}", duration=30.0)
|
||||
@@ -424,35 +431,46 @@ class TestWorkflowE2E:
|
||||
add_track(proj, track_type="video")
|
||||
add_track(proj, track_type="audio")
|
||||
|
||||
# Place clips
|
||||
add_clip_to_track(proj, 0, "clip0", position=0.0, out_point=15.0)
|
||||
add_clip_to_track(proj, 0, "clip1", position=15.0, out_point=15.0)
|
||||
add_clip_to_track(proj, 1, "clip2", position=5.0, out_point=20.0)
|
||||
add_clip_to_track(proj, 2, "clip3", position=0.0, out_point=30.0)
|
||||
|
||||
# Filters
|
||||
add_filter(proj, 0, 0, "brightness", {"level": 1.1})
|
||||
add_filter(proj, 0, 0, "blur", {"hblur": 3, "vblur": 3})
|
||||
add_filter(proj, 0, 1, "fade_in_video", {"duration": 0.5})
|
||||
|
||||
# Transition
|
||||
add_transition(proj, "dissolve", 0, 1, position=5.0, duration=3.0)
|
||||
|
||||
# Guides
|
||||
add_guide(proj, 0.0, label="Start")
|
||||
add_guide(proj, 15.0, label="Mid")
|
||||
add_guide(proj, 30.0, label="End")
|
||||
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# 5 bin producers + 1 black_track + service producers for timeline clips
|
||||
assert xml.count("<producer ") >= 6
|
||||
# main_bin + A/B playlists per track (3 tracks * 2 = 6)
|
||||
assert '<playlist id="main_bin">' in xml
|
||||
# 3 user filters
|
||||
assert xml.count('kdenlive:filter_name') == 3
|
||||
# internal + user transitions
|
||||
assert xml.count("<transition ") >= 4
|
||||
assert xml.count("<guide ") == 3
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
|
||||
# 5 bin clips → bin chains for each + per-track chains for used clips
|
||||
main_bin = root.find(".//playlist[@id='main_bin']")
|
||||
bin_entries = [e for e in main_bin.findall("entry") if not e.get("producer", "").startswith("{")]
|
||||
assert len(bin_entries) == 5
|
||||
|
||||
# 3 tracks × 2 playlists + main_bin = 7
|
||||
playlists = root.findall("playlist")
|
||||
assert len(playlists) == 7
|
||||
|
||||
# 3 user filters on clips
|
||||
user_filters = root.findall(".//entry/filter")
|
||||
assert len(user_filters) == 3
|
||||
|
||||
# 1 user transition (luma/dissolve)
|
||||
seq = _find_sequence(root)
|
||||
luma_trans = [t for t in seq.findall("transition") if t.get("mlt_service") == "luma"]
|
||||
assert len(luma_trans) == 1
|
||||
|
||||
# Guides in sequence tractor
|
||||
guides_prop = seq.find("property[@name='kdenlive:sequenceproperties.guides']")
|
||||
assert guides_prop is not None
|
||||
data = json.loads(guides_prop.text)
|
||||
assert len(data) == 3
|
||||
|
||||
def test_move_clip_then_export(self):
|
||||
proj = create_project()
|
||||
@@ -460,9 +478,9 @@ class TestWorkflowE2E:
|
||||
add_track(proj)
|
||||
add_clip_to_track(proj, 0, "clip0", position=0.0, out_point=10.0)
|
||||
move_clip(proj, 0, 0, new_position=5.0)
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
# Should have a blank for the 5-second gap
|
||||
assert "<blank " in xml
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
blanks = root.findall(".//blank")
|
||||
assert len(blanks) > 0
|
||||
|
||||
def test_project_info_after_edits(self):
|
||||
proj = create_project(name="InfoTest")
|
||||
@@ -488,8 +506,9 @@ class TestWorkflowE2E:
|
||||
for fname in FILTER_REGISTRY:
|
||||
add_filter(proj, 0, 0, fname)
|
||||
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
assert xml.count('kdenlive:filter_name') == len(FILTER_REGISTRY)
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
user_filters = root.findall(".//entry/filter")
|
||||
assert len(user_filters) == len(FILTER_REGISTRY)
|
||||
|
||||
def test_xml_write_to_file(self):
|
||||
proj = create_project(name="FileTest")
|
||||
@@ -510,7 +529,6 @@ class TestWorkflowE2E:
|
||||
os.unlink(path)
|
||||
|
||||
def test_timecode_in_workflow(self):
|
||||
"""Use timecode conversion in a practical scenario."""
|
||||
tc = "00:01:30.000"
|
||||
secs = timecode_to_seconds(tc)
|
||||
assert secs == 90.0
|
||||
@@ -522,20 +540,17 @@ class TestWorkflowE2E:
|
||||
assert back_tc == "00:01:30.000"
|
||||
|
||||
def test_split_then_filter_workflow(self):
|
||||
"""Split a clip then apply filter to one half."""
|
||||
proj = create_project()
|
||||
import_clip(proj, "/vid.mp4", name="V", duration=20.0)
|
||||
add_track(proj)
|
||||
add_clip_to_track(proj, 0, "clip0", out_point=20.0)
|
||||
split_clip(proj, 0, 0, split_at=10.0)
|
||||
# Apply filter to second half only
|
||||
add_filter(proj, 0, 1, "fade_out_video", {"duration": 2.0})
|
||||
filters = list_filters(proj, 0, 1)
|
||||
assert len(filters) == 1
|
||||
assert filters[0]["name"] == "fade_out_video"
|
||||
|
||||
def test_session_with_full_workflow(self):
|
||||
"""Session tracks changes through a full editing workflow."""
|
||||
sess = Session()
|
||||
proj = create_project(name="SessionWorkflow")
|
||||
sess.set_project(proj)
|
||||
@@ -554,15 +569,12 @@ class TestWorkflowE2E:
|
||||
history = sess.list_history()
|
||||
assert len(history) == 3
|
||||
|
||||
# Undo place clips
|
||||
sess.undo()
|
||||
assert len(sess.get_project()["tracks"][0]["clips"]) == 0
|
||||
|
||||
# Undo add tracks
|
||||
sess.undo()
|
||||
assert len(sess.get_project()["tracks"]) == 0
|
||||
|
||||
# Redo both
|
||||
sess.redo()
|
||||
assert len(sess.get_project()["tracks"]) == 2
|
||||
sess.redo()
|
||||
@@ -608,7 +620,6 @@ class TestMeltRenderE2E:
|
||||
|
||||
melt = find_melt()
|
||||
|
||||
# Use built-in color producers since we have no real media files
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
mlt_content = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<mlt LC_NUMERIC="C" version="7.0.0" profile="atsc_720p_25">
|
||||
@@ -638,8 +649,226 @@ class TestMeltRenderE2E:
|
||||
"vcodec=libx264", "acodec=aac", "ar=48000", "channels=2"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
assert result.returncode == 0, f"melt failed: {result.stderr[-500:]}"
|
||||
|
||||
assert os.path.exists(output_path)
|
||||
size = os.path.getsize(output_path)
|
||||
assert size > 0
|
||||
print(f"\n Kdenlive MLT render: {output_path} ({size:,} bytes)")
|
||||
|
||||
|
||||
# ── Kdenlive Gen 5 Format Validation Tests ─────────────────────
|
||||
|
||||
class TestKdenliveGen5Format:
|
||||
"""Validate Kdenlive Gen 5 (doc version 1.1) XML structure."""
|
||||
|
||||
def _make_simple_project(self):
|
||||
proj = create_project(name="Gen5Test", profile="hd1080p30")
|
||||
import_clip(proj, "/video.mp4", name="Video", duration=30.0)
|
||||
import_clip(proj, "/audio.mp3", name="Audio", duration=60.0, clip_type="audio")
|
||||
add_track(proj, name="V1", track_type="video")
|
||||
add_track(proj, name="A1", track_type="audio")
|
||||
add_clip_to_track(proj, 0, "clip0", position=0.0, out_point=15.0)
|
||||
add_clip_to_track(proj, 1, "clip1", position=0.0, out_point=30.0)
|
||||
add_transition(proj, "dissolve", 0, 1, position=5.0, duration=2.0)
|
||||
add_guide(proj, 10.0, label="Marker")
|
||||
return proj
|
||||
|
||||
def _parse(self, proj=None):
|
||||
proj = proj or self._make_simple_project()
|
||||
return ET.fromstring(generate_kdenlive_xml(proj))
|
||||
|
||||
def test_mlt_root_producer_is_main_bin(self):
|
||||
root = self._parse()
|
||||
assert root.get("producer") == "main_bin"
|
||||
|
||||
def test_has_main_bin_playlist(self):
|
||||
root = self._parse()
|
||||
assert root.find(".//playlist[@id='main_bin']") is not None
|
||||
|
||||
def test_main_bin_has_docproperties(self):
|
||||
root = self._parse()
|
||||
main_bin = root.find(".//playlist[@id='main_bin']")
|
||||
props = {p.get("name"): p.text for p in main_bin.findall("property")}
|
||||
assert props.get("kdenlive:docproperties.version") == "1.1"
|
||||
assert "kdenlive:docproperties.uuid" in props
|
||||
|
||||
def test_main_bin_lists_bin_clip_chains(self):
|
||||
root = self._parse()
|
||||
main_bin = root.find(".//playlist[@id='main_bin']")
|
||||
entries = main_bin.findall("entry")
|
||||
producer_refs = [e.get("producer") for e in entries]
|
||||
# Bin clips should be referenced as chainN (not *_bin)
|
||||
bin_refs = [r for r in producer_refs if r.startswith("chain")]
|
||||
assert len(bin_refs) == 2
|
||||
|
||||
def test_main_bin_lists_sequence(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
assert seq is not None
|
||||
seq_id = seq.get("id")
|
||||
main_bin = root.find(".//playlist[@id='main_bin']")
|
||||
entries = main_bin.findall("entry")
|
||||
producer_refs = [e.get("producer") for e in entries]
|
||||
assert seq_id in producer_refs
|
||||
|
||||
def test_per_track_tractor_wraps_playlists(self):
|
||||
root = self._parse()
|
||||
tractor0 = root.find(".//tractor[@id='tractor0']")
|
||||
assert tractor0 is not None
|
||||
tracks = tractor0.findall("track")
|
||||
assert len(tracks) == 2 # dual playlist
|
||||
producers = [t.get("producer") for t in tracks]
|
||||
assert any(p.startswith("playlist") for p in producers)
|
||||
|
||||
def test_video_track_hides_audio(self):
|
||||
root = self._parse()
|
||||
tractor0 = root.find(".//tractor[@id='tractor0']")
|
||||
tracks = tractor0.findall("track")
|
||||
assert all(t.get("hide") == "audio" for t in tracks)
|
||||
|
||||
def test_audio_track_hides_video(self):
|
||||
root = self._parse()
|
||||
tractor1 = root.find(".//tractor[@id='tractor1']")
|
||||
tracks = tractor1.findall("track")
|
||||
assert all(t.get("hide") == "video" for t in tracks)
|
||||
|
||||
def test_sequence_tractor_has_uuid(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
assert seq is not None
|
||||
uuid_val = seq.get("id")
|
||||
assert re.match(r'^\{[0-9a-f-]+\}$', uuid_val)
|
||||
uuid_prop = seq.find("property[@name='kdenlive:uuid']")
|
||||
assert uuid_prop is not None
|
||||
assert uuid_prop.text == uuid_val
|
||||
|
||||
def test_sequence_tractor_references_track_tractors(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
track_refs = [t.get("producer") for t in seq.findall("track")]
|
||||
assert "producer0" in track_refs # black track first
|
||||
assert "tractor0" in track_refs
|
||||
assert "tractor1" in track_refs
|
||||
|
||||
def test_project_tractor_exists(self):
|
||||
root = self._parse()
|
||||
assert root.find(".//tractor[@id='tractor_project']") is not None
|
||||
|
||||
def test_project_tractor_has_property(self):
|
||||
root = self._parse()
|
||||
proj_tractor = root.find(".//tractor[@id='tractor_project']")
|
||||
props = {p.get("name"): p.text for p in proj_tractor.findall("property")}
|
||||
assert props.get("kdenlive:projectTractor") == "1"
|
||||
|
||||
def test_project_tractor_references_sequence(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
seq_id = seq.get("id")
|
||||
proj_tractor = root.find(".//tractor[@id='tractor_project']")
|
||||
track = proj_tractor.find("track")
|
||||
assert track.get("producer") == seq_id
|
||||
|
||||
def test_internal_transitions_in_sequence(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
transitions = seq.findall("transition")
|
||||
mix_trans = [t for t in transitions if t.find("property[@name='mlt_service']") is not None and t.find("property[@name='mlt_service']").text == "mix"]
|
||||
blend_trans = [t for t in transitions if t.find("property[@name='mlt_service']") is not None and t.find("property[@name='mlt_service']").text == "qtblend"]
|
||||
assert len(mix_trans) >= 1 # audio track gets mix
|
||||
assert len(blend_trans) >= 1 # video track gets qtblend
|
||||
|
||||
def test_user_transition_in_sequence(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
luma = seq.findall("transition[@mlt_service='luma']")
|
||||
assert len(luma) == 1
|
||||
|
||||
def test_guides_in_sequence_tractor(self):
|
||||
root = self._parse()
|
||||
seq = _find_sequence(root)
|
||||
guides_prop = seq.find("property[@name='kdenlive:sequenceproperties.guides']")
|
||||
assert guides_prop is not None
|
||||
data = json.loads(guides_prop.text)
|
||||
assert len(data) == 1
|
||||
assert data[0]["comment"] == "Marker"
|
||||
|
||||
def test_empty_project_has_gen5_structure(self):
|
||||
proj = create_project()
|
||||
root = ET.fromstring(generate_kdenlive_xml(proj))
|
||||
assert root.get("producer") == "main_bin"
|
||||
assert root.find(".//playlist[@id='main_bin']") is not None
|
||||
assert root.find(".//tractor[@id='tractor_project']") is not None
|
||||
assert _find_sequence(root) is not None
|
||||
|
||||
def test_no_kdenlivedoc_element(self):
|
||||
xml = generate_kdenlive_xml(self._make_simple_project())
|
||||
assert "<kdenlivedoc>" not in xml
|
||||
|
||||
def test_project_tractor_is_last_element(self):
|
||||
root = self._parse()
|
||||
last = root[-1]
|
||||
assert last.get("id") == "tractor_project"
|
||||
|
||||
def test_black_track_producer_exists(self):
|
||||
root = self._parse()
|
||||
black = root.find("producer[@id='producer0']")
|
||||
assert black is not None
|
||||
resource = black.find("property[@name='resource']")
|
||||
assert resource.text == "black"
|
||||
|
||||
def test_bin_clip_chains_use_avformat(self):
|
||||
root = self._parse()
|
||||
# Bin chains are listed in main_bin entries with chainN ids
|
||||
main_bin = root.find(".//playlist[@id='main_bin']")
|
||||
bin_producers = [e.get("producer") for e in main_bin.findall("entry")
|
||||
if e.get("producer", "").startswith("chain")]
|
||||
for prod_id in bin_producers:
|
||||
chain = root.find(f".//chain[@id='{prod_id}']")
|
||||
assert chain is not None
|
||||
svc = chain.find("property[@name='mlt_service']")
|
||||
assert svc is not None
|
||||
assert svc.text == "avformat-novalidate"
|
||||
|
||||
def test_audio_track_tractor_has_internal_filters(self):
|
||||
root = self._parse()
|
||||
tractor1 = root.find(".//tractor[@id='tractor1']")
|
||||
filters = tractor1.findall("filter")
|
||||
services = [f.find("property[@name='mlt_service']") for f in filters]
|
||||
services = [s.text for s in services if s is not None]
|
||||
assert "volume" in services
|
||||
assert "panner" in services
|
||||
assert "audiolevel" in services
|
||||
|
||||
def test_main_bin_has_xml_retain(self):
|
||||
root = self._parse()
|
||||
main_bin = root.find(".//playlist[@id='main_bin']")
|
||||
retain = main_bin.find("property[@name='xml_retain']")
|
||||
assert retain is not None
|
||||
assert retain.text == "1"
|
||||
|
||||
def test_render_gen5_xml_through_melt(self):
|
||||
from cli_anything.kdenlive.utils.melt_backend import find_melt
|
||||
import subprocess
|
||||
|
||||
melt = find_melt()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
mlt_path = os.path.join(tmp_dir, "gen5_test.kdenlive")
|
||||
output_path = os.path.join(tmp_dir, "rendered.mp4")
|
||||
|
||||
proj = create_project(name="Gen5MeltTest", profile="hd1080p30")
|
||||
import_clip(proj, "color:red", name="Red", duration=2.0)
|
||||
add_track(proj, track_type="video")
|
||||
add_clip_to_track(proj, 0, "clip0", out_point=2.0)
|
||||
|
||||
xml = generate_kdenlive_xml(proj)
|
||||
with open(mlt_path, 'w') as f:
|
||||
f.write(xml)
|
||||
|
||||
ET.fromstring(xml)
|
||||
|
||||
cmd = [melt, mlt_path, "-consumer", f"avformat:{output_path}",
|
||||
"vcodec=libx264", "acodec=aac", "ar=48000", "channels=2"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
assert result.returncode == 0, f"melt failed: {result.stderr[-500:]}"
|
||||
assert os.path.exists(output_path)
|
||||
assert os.path.getsize(output_path) > 0
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Kdenlive CLI - MLT XML generation helpers and timecode conversions."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import Dict, Any, List, Optional
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Dict, Any
|
||||
|
||||
from cli_anything.kdenlive.core.filters import FILTER_REGISTRY
|
||||
|
||||
|
||||
|
||||
def xml_escape(s: str) -> str:
|
||||
"""Escape special characters for XML."""
|
||||
s = s.replace("&", "&")
|
||||
s = s.replace("<", "<")
|
||||
s = s.replace(">", ">")
|
||||
@@ -15,8 +19,15 @@ def xml_escape(s: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def _add_disabled_filter(parent, service, counter):
|
||||
f = ET.SubElement(parent, "filter", {"id": f"filter{counter}"})
|
||||
_add_prop(f, "mlt_service", service)
|
||||
_add_prop(f, "internal_added", "237")
|
||||
_add_prop(f, "disable", "1")
|
||||
return counter + 1
|
||||
|
||||
|
||||
def seconds_to_timecode(seconds: float) -> str:
|
||||
"""Convert seconds (float) to HH:MM:SS.mmm timecode string."""
|
||||
if seconds < 0:
|
||||
raise ValueError(f"Seconds must be non-negative: {seconds}")
|
||||
hours = int(seconds // 3600)
|
||||
@@ -29,11 +40,6 @@ def seconds_to_timecode(seconds: float) -> str:
|
||||
|
||||
|
||||
def timecode_to_seconds(tc: str) -> float:
|
||||
"""Convert HH:MM:SS.mmm timecode to seconds (float).
|
||||
|
||||
Also accepts plain float strings.
|
||||
"""
|
||||
# Try plain float first
|
||||
try:
|
||||
return float(tc)
|
||||
except ValueError:
|
||||
@@ -46,356 +52,41 @@ def timecode_to_seconds(tc: str) -> float:
|
||||
hours = int(m.group(1))
|
||||
minutes = int(m.group(2))
|
||||
secs = int(m.group(3))
|
||||
millis = int(m.group(4)) if m.group(4) else 0
|
||||
# Pad millis to 3 digits
|
||||
millis_str = m.group(4) if m.group(4) else "0"
|
||||
millis = int(millis_str.ljust(3, '0'))
|
||||
return hours * 3600 + minutes * 60 + secs + millis / 1000.0
|
||||
|
||||
|
||||
def seconds_to_frames(seconds: float, fps_num: int = 30, fps_den: int = 1) -> int:
|
||||
"""Convert seconds to frame count."""
|
||||
fps = fps_num / max(fps_den, 1)
|
||||
return int(round(seconds * fps))
|
||||
|
||||
|
||||
def frames_to_seconds(frames: int, fps_num: int = 30, fps_den: int = 1) -> float:
|
||||
"""Convert frame count to seconds."""
|
||||
fps = fps_num / max(fps_den, 1)
|
||||
return frames / fps
|
||||
|
||||
|
||||
def _indent(text: str, level: int) -> str:
|
||||
"""Indent text by level."""
|
||||
prefix = " " * level
|
||||
return prefix + text
|
||||
def _add_prop(parent: ET.Element, name: str, value) -> ET.Element:
|
||||
prop = ET.SubElement(parent, "property")
|
||||
prop.set("name", name)
|
||||
prop.text = str(value)
|
||||
return prop
|
||||
|
||||
|
||||
def _order_tracks(tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Order tracks for Kdenlive: audio tracks first, then video tracks."""
|
||||
audio = [t for t in tracks if t.get("type") == "audio"]
|
||||
video = [t for t in tracks if t.get("type") != "audio"]
|
||||
return audio + video
|
||||
|
||||
|
||||
def build_mlt_xml(project: Dict[str, Any]) -> str:
|
||||
"""Build a complete MLT XML document from a project dictionary.
|
||||
|
||||
Generates valid Kdenlive MLT XML matching the real Kdenlive file format:
|
||||
- Separate producer instances for bin (master) vs timeline (service copies)
|
||||
- Each track is a tractor wrapping A/B playlists
|
||||
- Main tractor references track tractors
|
||||
- Internal mix/composite transitions and track filters
|
||||
"""
|
||||
profile = project.get("profile", {})
|
||||
width = profile.get("width", 1920)
|
||||
height = profile.get("height", 1080)
|
||||
fps_num = profile.get("fps_num", 30)
|
||||
fps_den = profile.get("fps_den", 1)
|
||||
progressive = profile.get("progressive", True)
|
||||
dar_num = profile.get("dar_num", 16)
|
||||
dar_den = profile.get("dar_den", 9)
|
||||
|
||||
# Calculate SAR (sample aspect ratio)
|
||||
sar_num = dar_num * height
|
||||
sar_den = dar_den * width
|
||||
|
||||
# Order tracks: audio first, then video (Kdenlive convention)
|
||||
tracks = _order_tracks(project.get("tracks", []))
|
||||
|
||||
# Compute timeline duration for black track
|
||||
timeline_duration_frames = 0
|
||||
for track in tracks:
|
||||
for clip_entry in track.get("clips", []):
|
||||
clip_end = clip_entry.get("position", 0) + (clip_entry.get("out", 0) - clip_entry.get("in", 0))
|
||||
end_frames = seconds_to_frames(clip_end, fps_num, fps_den)
|
||||
if end_frames > timeline_duration_frames:
|
||||
timeline_duration_frames = end_frames
|
||||
if timeline_duration_frames == 0:
|
||||
timeline_duration_frames = seconds_to_frames(300, fps_num, fps_den)
|
||||
|
||||
# Build lookup: clip_id -> bin clip data & kdenlive numeric id
|
||||
bin_clips = project.get("bin", [])
|
||||
clip_kdenlive_ids = {}
|
||||
clip_sources = {}
|
||||
for idx, clip in enumerate(bin_clips):
|
||||
kid = idx + 2 # kdenlive:id starts from 2 (0,1 reserved)
|
||||
clip_kdenlive_ids[clip["id"]] = kid
|
||||
clip_sources[clip["id"]] = clip
|
||||
|
||||
lines = []
|
||||
lines.append('<?xml version="1.0" encoding="utf-8"?>')
|
||||
lines.append('<mlt LC_NUMERIC="C" version="7.0.0" '
|
||||
f'title="{xml_escape(project.get("name", "untitled"))}" '
|
||||
f'producer="main_bin">')
|
||||
|
||||
# ── Profile ─────────────────────────────────────────────────
|
||||
lines.append(f' <profile description="{xml_escape(profile.get("name", "custom"))}" '
|
||||
f'width="{width}" height="{height}" '
|
||||
f'progressive="{1 if progressive else 0}" '
|
||||
f'sample_aspect_num="{sar_num}" sample_aspect_den="{sar_den}" '
|
||||
f'display_aspect_num="{dar_num}" display_aspect_den="{dar_den}" '
|
||||
f'frame_rate_num="{fps_num}" frame_rate_den="{fps_den}" '
|
||||
f'colorspace="709"/>')
|
||||
|
||||
# ── Bin producers (master copies, referenced from main_bin) ──
|
||||
for clip in bin_clips:
|
||||
clip_id = xml_escape(clip["id"])
|
||||
source = xml_escape(clip.get("source", ""))
|
||||
duration_frames = seconds_to_frames(clip.get("duration", 0), fps_num, fps_den)
|
||||
kid = clip_kdenlive_ids[clip["id"]]
|
||||
lines.append(f' <producer id="{clip_id}" in="0" out="{max(duration_frames - 1, 0)}">')
|
||||
lines.append(f' <property name="resource">{source}</property>')
|
||||
lines.append(f' <property name="kdenlive:clipname">{xml_escape(clip.get("name", ""))}</property>')
|
||||
lines.append(f' <property name="kdenlive:clip_type">{_clip_type_num(clip.get("type", "video"))}</property>')
|
||||
lines.append(f' <property name="length">{duration_frames}</property>')
|
||||
lines.append(f' <property name="kdenlive:folderid">-1</property>')
|
||||
lines.append(f' <property name="kdenlive:id">{kid}</property>')
|
||||
lines.append(' </producer>')
|
||||
|
||||
# ── main_bin playlist (project bin) ──────────────────────────
|
||||
doc_uuid = str(uuid.uuid4())
|
||||
lines.append(' <playlist id="main_bin">')
|
||||
lines.append(' <property name="kdenlive:docproperties.version">1.04</property>')
|
||||
lines.append(f' <property name="kdenlive:docproperties.profile">{xml_escape(profile.get("name", "custom"))}</property>')
|
||||
lines.append(f' <property name="kdenlive:docproperties.uuid">{doc_uuid}</property>')
|
||||
lines.append(' <property name="kdenlive:docproperties.kdenliveversion">24.02</property>')
|
||||
lines.append(' <property name="xml_retain">1</property>')
|
||||
for clip in bin_clips:
|
||||
clip_id = xml_escape(clip["id"])
|
||||
duration_frames = seconds_to_frames(clip.get("duration", 0), fps_num, fps_den)
|
||||
lines.append(f' <entry producer="{clip_id}" in="0" out="{max(duration_frames - 1, 0)}"/>')
|
||||
lines.append(' </playlist>')
|
||||
|
||||
# ── Black track producer (no kdenlive:id — internal) ─────────
|
||||
lines.append(f' <producer id="black_track" in="0" out="{timeline_duration_frames}">')
|
||||
lines.append(' <property name="length">2147483647</property>')
|
||||
lines.append(' <property name="eof">continue</property>')
|
||||
lines.append(' <property name="resource">black</property>')
|
||||
lines.append(' <property name="aspect_ratio">1</property>')
|
||||
lines.append(' <property name="mlt_service">color</property>')
|
||||
lines.append(' <property name="kdenlive:playlistid">black_track</property>')
|
||||
lines.append(' <property name="mlt_image_format">rgba</property>')
|
||||
lines.append(' <property name="set.test_audio">0</property>')
|
||||
lines.append(' </producer>')
|
||||
|
||||
# ── Per-track: service producers, A/B playlists, tractor ─────
|
||||
svc_producer_counter = 0
|
||||
filter_counter = 0
|
||||
playlist_counter = 0
|
||||
tractor_counter = 0
|
||||
track_tractor_ids = []
|
||||
|
||||
for track in tracks:
|
||||
is_audio = track.get("type") == "audio"
|
||||
hide_sub = "video" if is_audio else "audio"
|
||||
|
||||
playlist_a_id = f"playlist{playlist_counter}"
|
||||
playlist_b_id = f"playlist{playlist_counter + 1}"
|
||||
tractor_id = f"tractor{tractor_counter}"
|
||||
|
||||
# Create separate "service" producers for each clip on this track
|
||||
# and map them to playlist entries
|
||||
clip_entries_for_playlist = []
|
||||
for clip_entry in track.get("clips", []):
|
||||
src_clip_id = clip_entry.get("clip_id", "")
|
||||
src_clip = clip_sources.get(src_clip_id)
|
||||
if not src_clip:
|
||||
continue
|
||||
|
||||
kid = clip_kdenlive_ids.get(src_clip_id, 0)
|
||||
source = xml_escape(src_clip.get("source", ""))
|
||||
duration_frames = seconds_to_frames(src_clip.get("duration", 0), fps_num, fps_den)
|
||||
svc_id = f"svc_producer{svc_producer_counter}"
|
||||
svc_producer_counter += 1
|
||||
|
||||
# Service producer: a copy of the bin clip for timeline use
|
||||
lines.append(f' <producer id="{svc_id}" in="0" out="{max(duration_frames - 1, 0)}">')
|
||||
lines.append(f' <property name="resource">{source}</property>')
|
||||
lines.append(f' <property name="kdenlive:clipname">{xml_escape(src_clip.get("name", ""))}</property>')
|
||||
lines.append(f' <property name="kdenlive:clip_type">{_clip_type_num(src_clip.get("type", "video"))}</property>')
|
||||
lines.append(f' <property name="length">{duration_frames}</property>')
|
||||
lines.append(f' <property name="kdenlive:id">{kid}</property>')
|
||||
lines.append(f' <property name="mlt_service">avformat-novalidate</property>')
|
||||
if is_audio:
|
||||
lines.append(' <property name="set.test_audio">0</property>')
|
||||
lines.append(' <property name="set.test_image">1</property>')
|
||||
else:
|
||||
lines.append(' <property name="set.test_audio">1</property>')
|
||||
lines.append(' <property name="set.test_image">0</property>')
|
||||
lines.append(' </producer>')
|
||||
|
||||
clip_entries_for_playlist.append((svc_id, clip_entry, kid))
|
||||
|
||||
# Playlist A (has clip entries)
|
||||
lines.append(f' <playlist id="{playlist_a_id}">')
|
||||
if is_audio:
|
||||
lines.append(' <property name="kdenlive:audio_track">1</property>')
|
||||
|
||||
prev_end = 0.0
|
||||
for svc_id, clip_entry, kid in clip_entries_for_playlist:
|
||||
pos = clip_entry.get("position", 0.0)
|
||||
gap = pos - prev_end
|
||||
if gap > 0.001:
|
||||
gap_frames = seconds_to_frames(gap, fps_num, fps_den)
|
||||
lines.append(f' <blank length="{gap_frames}"/>')
|
||||
|
||||
in_frames = seconds_to_frames(clip_entry.get("in", 0), fps_num, fps_den)
|
||||
out_frames = seconds_to_frames(clip_entry.get("out", 0), fps_num, fps_den)
|
||||
lines.append(f' <entry producer="{svc_id}" in="{in_frames}" out="{max(out_frames - 1, 0)}">')
|
||||
lines.append(f' <property name="kdenlive:id">{kid}</property>')
|
||||
|
||||
for filt in clip_entry.get("filters", []):
|
||||
mlt_svc = xml_escape(filt.get("mlt_service", ""))
|
||||
lines.append(f' <filter mlt_service="{mlt_svc}">')
|
||||
lines.append(f' <property name="kdenlive:filter_name">{xml_escape(filt.get("name", ""))}</property>')
|
||||
for pk, pv in filt.get("params", {}).items():
|
||||
lines.append(f' <property name="{xml_escape(pk)}">{xml_escape(str(pv))}</property>')
|
||||
lines.append(' </filter>')
|
||||
|
||||
lines.append(' </entry>')
|
||||
clip_dur = clip_entry.get("out", 0) - clip_entry.get("in", 0)
|
||||
prev_end = pos + clip_dur
|
||||
|
||||
lines.append(' </playlist>')
|
||||
|
||||
# Playlist B (always empty — used for transitions/split edits)
|
||||
lines.append(f' <playlist id="{playlist_b_id}"/>')
|
||||
|
||||
# Track tractor
|
||||
lines.append(f' <tractor id="{tractor_id}" in="0">')
|
||||
if is_audio:
|
||||
lines.append(' <property name="kdenlive:audio_track">1</property>')
|
||||
lines.append(' <property name="kdenlive:trackheight">67</property>')
|
||||
lines.append(' <property name="kdenlive:timeline_active">1</property>')
|
||||
lines.append(' <property name="kdenlive:collapsed">0</property>')
|
||||
lines.append(f' <track hide="{hide_sub}" producer="{playlist_a_id}"/>')
|
||||
lines.append(f' <track hide="{hide_sub}" producer="{playlist_b_id}"/>')
|
||||
# Internal track filters (volume, panner, audiolevel)
|
||||
lines.append(f' <filter id="filter{filter_counter}">')
|
||||
lines.append(' <property name="window">75</property>')
|
||||
lines.append(' <property name="max_gain">20dB</property>')
|
||||
lines.append(' <property name="mlt_service">volume</property>')
|
||||
lines.append(' <property name="internal_added">237</property>')
|
||||
lines.append(' <property name="disable">1</property>')
|
||||
lines.append(' </filter>')
|
||||
filter_counter += 1
|
||||
lines.append(f' <filter id="filter{filter_counter}">')
|
||||
lines.append(' <property name="channel">-1</property>')
|
||||
lines.append(' <property name="mlt_service">panner</property>')
|
||||
lines.append(' <property name="internal_added">237</property>')
|
||||
lines.append(' <property name="start">0.5</property>')
|
||||
lines.append(' <property name="disable">1</property>')
|
||||
lines.append(' </filter>')
|
||||
filter_counter += 1
|
||||
lines.append(f' <filter id="filter{filter_counter}">')
|
||||
lines.append(' <property name="iec_scale">0</property>')
|
||||
lines.append(' <property name="mlt_service">audiolevel</property>')
|
||||
lines.append(' <property name="peak">1</property>')
|
||||
lines.append(' <property name="disable">1</property>')
|
||||
lines.append(' </filter>')
|
||||
filter_counter += 1
|
||||
lines.append(' </tractor>')
|
||||
|
||||
track_tractor_ids.append(tractor_id)
|
||||
playlist_counter += 2
|
||||
tractor_counter += 1
|
||||
|
||||
# ── Main tractor (timeline) ──────────────────────────────────
|
||||
main_tractor_id = f"tractor{tractor_counter}"
|
||||
lines.append(f' <tractor id="{main_tractor_id}" in="0" out="{timeline_duration_frames}">')
|
||||
|
||||
# Black track first (direct reference, no wrapper playlist)
|
||||
lines.append(' <track producer="black_track"/>')
|
||||
for tid in track_tractor_ids:
|
||||
lines.append(f' <track producer="{tid}"/>')
|
||||
|
||||
# Internal Kdenlive transitions (mix for audio, composite for video)
|
||||
trans_counter = 0
|
||||
for tractor_idx, track in enumerate(tracks, start=1):
|
||||
is_audio = track.get("type") == "audio"
|
||||
if is_audio:
|
||||
lines.append(f' <transition id="transition{trans_counter}">')
|
||||
lines.append(' <property name="a_track">0</property>')
|
||||
lines.append(f' <property name="b_track">{tractor_idx}</property>')
|
||||
lines.append(' <property name="mlt_service">mix</property>')
|
||||
lines.append(' <property name="kdenlive_id">mix</property>')
|
||||
lines.append(' <property name="internal_added">237</property>')
|
||||
lines.append(' <property name="always_active">1</property>')
|
||||
lines.append(' <property name="accepts_blanks">1</property>')
|
||||
lines.append(' <property name="sum">1</property>')
|
||||
lines.append(' </transition>')
|
||||
else:
|
||||
lines.append(f' <transition id="transition{trans_counter}">')
|
||||
lines.append(' <property name="a_track">0</property>')
|
||||
lines.append(f' <property name="b_track">{tractor_idx}</property>')
|
||||
lines.append(' <property name="version">0.1</property>')
|
||||
lines.append(' <property name="mlt_service">frei0r.cairoblend</property>')
|
||||
lines.append(' <property name="kdenlive_id">frei0r.cairoblend</property>')
|
||||
lines.append(' <property name="internal_added">237</property>')
|
||||
lines.append(' <property name="always_active">1</property>')
|
||||
lines.append(' </transition>')
|
||||
trans_counter += 1
|
||||
|
||||
# User-defined transitions (track indices shifted +1 for black track)
|
||||
for trans in project.get("transitions", []):
|
||||
mlt_svc = xml_escape(trans.get("mlt_service", ""))
|
||||
pos_frames = seconds_to_frames(trans.get("position", 0), fps_num, fps_den)
|
||||
dur_frames = seconds_to_frames(trans.get("duration", 1), fps_num, fps_den)
|
||||
a_idx = _track_index(tracks, trans["track_a"]) + 1
|
||||
b_idx = _track_index(tracks, trans["track_b"]) + 1
|
||||
lines.append(f' <transition mlt_service="{mlt_svc}" '
|
||||
f'in="{pos_frames}" out="{pos_frames + dur_frames}" '
|
||||
f'a_track="{a_idx}" b_track="{b_idx}">')
|
||||
for pk, pv in trans.get("params", {}).items():
|
||||
if pk in ("duration",):
|
||||
continue
|
||||
lines.append(f' <property name="{xml_escape(pk)}">{xml_escape(str(pv))}</property>')
|
||||
lines.append(' </transition>')
|
||||
|
||||
# Master volume/panner/audiolevel filters on main tractor
|
||||
lines.append(f' <filter id="filter{filter_counter}">')
|
||||
lines.append(' <property name="window">75</property>')
|
||||
lines.append(' <property name="max_gain">20dB</property>')
|
||||
lines.append(' <property name="mlt_service">volume</property>')
|
||||
lines.append(' <property name="internal_added">237</property>')
|
||||
lines.append(' <property name="disable">1</property>')
|
||||
lines.append(' </filter>')
|
||||
filter_counter += 1
|
||||
lines.append(f' <filter id="filter{filter_counter}">')
|
||||
lines.append(' <property name="channel">-1</property>')
|
||||
lines.append(' <property name="mlt_service">panner</property>')
|
||||
lines.append(' <property name="internal_added">237</property>')
|
||||
lines.append(' <property name="start">0.5</property>')
|
||||
lines.append(' <property name="disable">1</property>')
|
||||
lines.append(' </filter>')
|
||||
filter_counter += 1
|
||||
lines.append(f' <filter id="filter{filter_counter}">')
|
||||
lines.append(' <property name="iec_scale">0</property>')
|
||||
lines.append(' <property name="mlt_service">audiolevel</property>')
|
||||
lines.append(' <property name="peak">1</property>')
|
||||
lines.append(' <property name="disable">1</property>')
|
||||
lines.append(' </filter>')
|
||||
|
||||
lines.append(' </tractor>')
|
||||
|
||||
# ── Kdenlive document metadata (guides) ──────────────────────
|
||||
guides = project.get("guides", [])
|
||||
if guides:
|
||||
lines.append(' <kdenlivedoc>')
|
||||
for g in guides:
|
||||
pos_frames = seconds_to_frames(g["position"], fps_num, fps_den)
|
||||
lines.append(f' <guide pos="{pos_frames}" '
|
||||
f'comment="{xml_escape(g.get("label", ""))}" '
|
||||
f'type="{xml_escape(g.get("type", "default"))}"/>')
|
||||
lines.append(' </kdenlivedoc>')
|
||||
|
||||
lines.append('</mlt>')
|
||||
return '\n'.join(lines)
|
||||
def _compute_track_duration(track: dict, fps_num: int, fps_den: int) -> int:
|
||||
max_end = 0.0
|
||||
for clip_entry in track.get("clips", []):
|
||||
clip_end = clip_entry.get("position", 0.0) + (
|
||||
clip_entry.get("out", 0) - clip_entry.get("in", 0)
|
||||
)
|
||||
max_end = max(max_end, clip_end)
|
||||
if max_end <= 0:
|
||||
return 0
|
||||
return max(seconds_to_frames(max_end, fps_num, fps_den) - 1, 0)
|
||||
|
||||
|
||||
def _clip_type_num(clip_type: str) -> int:
|
||||
"""Convert clip type string to Kdenlive type number."""
|
||||
mapping = {
|
||||
"video": 0,
|
||||
"audio": 1,
|
||||
@@ -406,9 +97,352 @@ def _clip_type_num(clip_type: str) -> int:
|
||||
return mapping.get(clip_type, 0)
|
||||
|
||||
|
||||
def _avformat_indexes(parent: ET.Element, clip_type: str):
|
||||
if clip_type == "audio":
|
||||
_add_prop(parent, "audio_index", "0")
|
||||
_add_prop(parent, "video_index", "-1")
|
||||
elif clip_type == "image":
|
||||
_add_prop(parent, "audio_index", "-1")
|
||||
_add_prop(parent, "video_index", "0")
|
||||
else:
|
||||
_add_prop(parent, "audio_index", "1")
|
||||
_add_prop(parent, "video_index", "0")
|
||||
|
||||
|
||||
def _set_producer_props(parent: ET.Element, clip_type: str):
|
||||
if clip_type == "color":
|
||||
_add_prop(parent, "mlt_service", "color")
|
||||
else:
|
||||
_add_prop(parent, "mlt_service", "avformat-novalidate")
|
||||
_add_prop(parent, "seekable", "1")
|
||||
_avformat_indexes(parent, clip_type)
|
||||
|
||||
|
||||
def _track_index(tracks: list, track_id: int) -> int:
|
||||
"""Find 0-based index of track by ID."""
|
||||
for i, t in enumerate(tracks):
|
||||
if t["id"] == track_id:
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
_SEQUENCE_FOLDER_ID = "2"
|
||||
_SEQUENCE_KDENLIVE_ID = "3"
|
||||
_CLIP_KDENLIVE_ID_START = 4
|
||||
|
||||
|
||||
def build_mlt_xml(project: Dict[str, Any]) -> str:
|
||||
"""Build a Kdenlive Gen 5 (doc version 1.1) compatible MLT XML document."""
|
||||
profile = project.get("profile", {})
|
||||
width = profile.get("width", 1920)
|
||||
height = profile.get("height", 1080)
|
||||
fps_num = profile.get("fps_num", 30)
|
||||
fps_den = profile.get("fps_den", 1)
|
||||
progressive = profile.get("progressive", True)
|
||||
dar_num = profile.get("dar_num", 16)
|
||||
dar_den = profile.get("dar_den", 9)
|
||||
|
||||
sar_num = dar_num * height
|
||||
sar_den = dar_den * width
|
||||
|
||||
bin_clips = project.get("bin", [])
|
||||
tracks = project.get("tracks", [])
|
||||
guides = project.get("guides", [])
|
||||
|
||||
# Map clip IDs to numeric kdenlive IDs (4, 5, 6, ...)
|
||||
clip_kid = {}
|
||||
clip_data_by_id = {}
|
||||
for i, clip in enumerate(bin_clips):
|
||||
clip_kid[clip["id"]] = str(_CLIP_KDENLIVE_ID_START + i)
|
||||
clip_data_by_id[clip["id"]] = clip
|
||||
|
||||
max_dur = 0
|
||||
track_durs = {}
|
||||
for track in tracks:
|
||||
td = _compute_track_duration(track, fps_num, fps_den)
|
||||
track_durs[track["id"]] = td
|
||||
max_dur = max(max_dur, td)
|
||||
if max_dur == 0:
|
||||
max_dur = seconds_to_frames(300, fps_num, fps_den)
|
||||
|
||||
root = ET.Element("mlt")
|
||||
root.set("LC_NUMERIC", "C")
|
||||
root.set("version", "7.0.0")
|
||||
root.set("title", project.get("name", "untitled"))
|
||||
root.set("producer", "main_bin")
|
||||
|
||||
ET.SubElement(root, "profile", {
|
||||
"description": profile.get("name", "custom"),
|
||||
"width": str(width),
|
||||
"height": str(height),
|
||||
"progressive": str(1 if progressive else 0),
|
||||
"sample_aspect_num": str(sar_num),
|
||||
"sample_aspect_den": str(sar_den),
|
||||
"display_aspect_num": str(dar_num),
|
||||
"display_aspect_den": str(dar_den),
|
||||
"frame_rate_num": str(fps_num),
|
||||
"frame_rate_den": str(fps_den),
|
||||
"colorspace": "709",
|
||||
})
|
||||
|
||||
# Black track producer
|
||||
black = ET.SubElement(root, "producer", {"id": "producer0", "in": "0", "out": str(max_dur)})
|
||||
_add_prop(black, "resource", "black")
|
||||
_add_prop(black, "mlt_service", "color")
|
||||
_add_prop(black, "kdenlive:playlistid", "black_track")
|
||||
_add_prop(black, "set.test_audio", "0")
|
||||
|
||||
# Sort tracks: audio first, video last so video appears on top in kdenlive
|
||||
audio_tracks = [t for t in tracks if t.get("type") == "audio"]
|
||||
video_tracks = [t for t in tracks if t.get("type") != "audio"]
|
||||
sorted_tracks = audio_tracks + video_tracks
|
||||
|
||||
# Mapping: original track index in project["tracks"] -> sequence position
|
||||
orig_to_seq = {}
|
||||
for seq_idx, t in enumerate(sorted_tracks):
|
||||
orig_idx = tracks.index(t)
|
||||
orig_to_seq[orig_idx] = seq_idx
|
||||
|
||||
# Per-track: chains, dual playlists, wrapping tractor
|
||||
chain_counter = 0
|
||||
filter_counter = 0
|
||||
transition_counter = 0
|
||||
track_tractor_ids = []
|
||||
|
||||
for track in sorted_tracks:
|
||||
track_type = track.get("type", "video")
|
||||
is_audio = track_type == "audio"
|
||||
tractor_id = f"tractor{track['id']}"
|
||||
|
||||
# Chain per unique clip in this track
|
||||
track_clip_chains = {}
|
||||
for clip_entry in track.get("clips", []):
|
||||
clip_id = clip_entry.get("clip_id", "")
|
||||
if clip_id in track_clip_chains:
|
||||
continue
|
||||
chain_id = f"chain{chain_counter}"
|
||||
chain_counter += 1
|
||||
clip_data = clip_data_by_id.get(clip_id)
|
||||
if not clip_data:
|
||||
track_clip_chains[clip_id] = clip_id
|
||||
continue
|
||||
dur = seconds_to_frames(clip_data.get("duration", 0), fps_num, fps_den)
|
||||
chain = ET.SubElement(root, "chain", {"id": chain_id, "in": "0", "out": str(max(dur - 1, 0))})
|
||||
_add_prop(chain, "length", str(dur))
|
||||
_add_prop(chain, "eof", "pause")
|
||||
_add_prop(chain, "resource", clip_data.get("source", ""))
|
||||
_set_producer_props(chain, clip_data.get("type", "video"))
|
||||
_add_prop(chain, "kdenlive:folderid", "-1")
|
||||
_add_prop(chain, "kdenlive:id", clip_kid.get(clip_id, clip_id))
|
||||
_add_prop(chain, "mute_on_pause", "0")
|
||||
_add_prop(chain, "kdenlive:clip_type", str(_clip_type_num(clip_data.get("type", "video"))))
|
||||
if is_audio:
|
||||
_add_prop(chain, "set.test_audio", "0")
|
||||
_add_prop(chain, "set.test_image", "1")
|
||||
else:
|
||||
_add_prop(chain, "set.test_audio", "1")
|
||||
_add_prop(chain, "set.test_image", "0")
|
||||
track_clip_chains[clip_id] = chain_id
|
||||
|
||||
# Playlist 1: clip entries
|
||||
pl1 = ET.SubElement(root, "playlist", {"id": f"playlist{track['id'] * 2}"})
|
||||
if is_audio:
|
||||
_add_prop(pl1, "kdenlive:audio_track", "1")
|
||||
|
||||
prev_end = 0.0
|
||||
for clip_entry in track.get("clips", []):
|
||||
cid = clip_entry.get("clip_id", "")
|
||||
if cid not in clip_data_by_id:
|
||||
continue
|
||||
pos = clip_entry.get("position", 0.0)
|
||||
gap = pos - prev_end
|
||||
if gap > 0.001:
|
||||
ET.SubElement(pl1, "blank", {"length": str(seconds_to_frames(gap, fps_num, fps_den))})
|
||||
|
||||
in_f = seconds_to_frames(clip_entry.get("in", 0), fps_num, fps_den)
|
||||
out_f = seconds_to_frames(clip_entry.get("out", 0), fps_num, fps_den)
|
||||
ref = track_clip_chains.get(cid, cid)
|
||||
entry = ET.SubElement(pl1, "entry", {"producer": ref, "in": str(in_f), "out": str(max(out_f - 1, 0))})
|
||||
|
||||
for filt in clip_entry.get("filters", []):
|
||||
f_el = ET.SubElement(entry, "filter", {"id": f"filter{filter_counter}"})
|
||||
filter_counter += 1
|
||||
_add_prop(f_el, "mlt_service", filt.get("mlt_service", ""))
|
||||
kdenlive_id = FILTER_REGISTRY.get(filt.get("name", ""), {}).get("kdenlive_name", filt.get("mlt_service", ""))
|
||||
_add_prop(f_el, "kdenlive_id", kdenlive_id)
|
||||
for pk, pv in filt.get("params", {}).items():
|
||||
_add_prop(f_el, pk, str(pv))
|
||||
|
||||
_add_prop(entry, "kdenlive:id", clip_kid.get(cid, cid))
|
||||
prev_end = pos + clip_entry.get("out", 0) - clip_entry.get("in", 0)
|
||||
|
||||
# Playlist 2: empty (dual playlist for mixes)
|
||||
pl2 = ET.SubElement(root, "playlist", {"id": f"playlist{track['id'] * 2 + 1}"})
|
||||
if is_audio:
|
||||
_add_prop(pl2, "kdenlive:audio_track", "1")
|
||||
|
||||
# Wrapping tractor
|
||||
track_dur = track_durs[track["id"]]
|
||||
tractor = ET.SubElement(root, "tractor", {"id": tractor_id, "in": "0", "out": str(track_dur)})
|
||||
if is_audio:
|
||||
_add_prop(tractor, "kdenlive:audio_track", "1")
|
||||
if track.get("name"):
|
||||
_add_prop(tractor, "kdenlive:track_name", track["name"])
|
||||
|
||||
if track.get("hide"):
|
||||
hide = "both"
|
||||
elif is_audio and track.get("mute"):
|
||||
hide = "both"
|
||||
elif is_audio:
|
||||
hide = "video"
|
||||
else:
|
||||
hide = "audio"
|
||||
ET.SubElement(tractor, "track", {"hide": hide, "producer": pl1.get("id")})
|
||||
ET.SubElement(tractor, "track", {"hide": hide, "producer": pl2.get("id")})
|
||||
|
||||
if is_audio:
|
||||
filter_counter = _add_disabled_filter(tractor, "volume", filter_counter)
|
||||
filter_counter = _add_disabled_filter(tractor, "panner", filter_counter)
|
||||
filter_counter = _add_disabled_filter(tractor, "audiolevel", filter_counter)
|
||||
|
||||
track_tractor_ids.append(tractor_id)
|
||||
|
||||
# Sequence tractor (UUID id)
|
||||
doc_uuid = f"{{{uuid.uuid4()}}}"
|
||||
sequence_uuid = doc_uuid
|
||||
video_count = len(video_tracks)
|
||||
audio_count = len(audio_tracks)
|
||||
|
||||
seq = ET.SubElement(root, "tractor", {"id": sequence_uuid, "in": "0", "out": str(max_dur)})
|
||||
_add_prop(seq, "kdenlive:uuid", sequence_uuid)
|
||||
_add_prop(seq, "kdenlive:clipname", "Sequence 1")
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.hasAudio", "1" if audio_count > 0 else "0")
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.hasVideo", "1" if video_count > 0 else "0")
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.activeTrack", str(len(tracks) - 1 if tracks else 0))
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.tracksCount", str(len(tracks)))
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.documentuuid", sequence_uuid)
|
||||
dur_tc = seconds_to_timecode(frames_to_seconds(max_dur + 1, fps_num, fps_den)) if max_dur > 0 else "00:00:00.000"
|
||||
_add_prop(seq, "kdenlive:duration", dur_tc)
|
||||
_add_prop(seq, "kdenlive:maxduration", str(max_dur + 1))
|
||||
_add_prop(seq, "kdenlive:producer_type", "17")
|
||||
_add_prop(seq, "kdenlive:id", _SEQUENCE_KDENLIVE_ID)
|
||||
_add_prop(seq, "kdenlive:clip_type", "0")
|
||||
_add_prop(seq, "kdenlive:file_size", "0")
|
||||
_add_prop(seq, "kdenlive:folderid", _SEQUENCE_FOLDER_ID)
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.videoTarget", str(len(sorted_tracks) - 1 if video_tracks else -1))
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.audioTarget", str(audio_count - 1 if audio_count > 0 else -1))
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.tracks", str(len(tracks)))
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.zoom", "8")
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.zonein", "0")
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.zoneout", str(max_dur))
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.groups", "[]")
|
||||
|
||||
guides_data = [
|
||||
{"pos": seconds_to_frames(g["position"], fps_num, fps_den),
|
||||
"comment": g.get("label", ""),
|
||||
"type": g.get("type", "default")}
|
||||
for g in guides
|
||||
]
|
||||
_add_prop(seq, "kdenlive:sequenceproperties.guides", json.dumps(guides_data))
|
||||
|
||||
# Tracks: black track first, then per-track tractors
|
||||
ET.SubElement(seq, "track", {"producer": "producer0"})
|
||||
for tid in track_tractor_ids:
|
||||
ET.SubElement(seq, "track", {"producer": tid})
|
||||
|
||||
# Internal mix transitions for audio tracks
|
||||
for i, track in enumerate(sorted_tracks):
|
||||
if track.get("type") == "audio":
|
||||
t = ET.SubElement(seq, "transition", {"id": f"transition{transition_counter}"})
|
||||
transition_counter += 1
|
||||
_add_prop(t, "a_track", "0")
|
||||
_add_prop(t, "b_track", str(i + 1))
|
||||
_add_prop(t, "mlt_service", "mix")
|
||||
_add_prop(t, "kdenlive_id", "mix")
|
||||
_add_prop(t, "internal_added", "237")
|
||||
_add_prop(t, "always_active", "1")
|
||||
_add_prop(t, "accepts_blanks", "1")
|
||||
_add_prop(t, "sum", "1")
|
||||
|
||||
# Internal qtblend transitions for video tracks
|
||||
for i, track in enumerate(sorted_tracks):
|
||||
if track.get("type") != "audio":
|
||||
t = ET.SubElement(seq, "transition", {"id": f"transition{transition_counter}"})
|
||||
transition_counter += 1
|
||||
_add_prop(t, "a_track", "0")
|
||||
_add_prop(t, "b_track", str(i + 1))
|
||||
_add_prop(t, "mlt_service", "qtblend")
|
||||
_add_prop(t, "kdenlive_id", "qtblend")
|
||||
_add_prop(t, "internal_added", "237")
|
||||
_add_prop(t, "always_active", "1")
|
||||
_add_prop(t, "compositing", "0")
|
||||
_add_prop(t, "distort", "0")
|
||||
_add_prop(t, "rotate_center", "0")
|
||||
|
||||
# User transitions
|
||||
for td in project.get("transitions", []):
|
||||
pos_f = seconds_to_frames(td.get("position", 0), fps_num, fps_den)
|
||||
dur_f = seconds_to_frames(td.get("duration", 1), fps_num, fps_den)
|
||||
a_orig = _track_index(tracks, td["track_a"])
|
||||
b_orig = _track_index(tracks, td["track_b"])
|
||||
a_idx = orig_to_seq.get(a_orig, a_orig) + 1
|
||||
b_idx = orig_to_seq.get(b_orig, b_orig) + 1
|
||||
trans = ET.SubElement(seq, "transition", {
|
||||
"id": f"transition{transition_counter}",
|
||||
"mlt_service": td.get("mlt_service", ""),
|
||||
"in": str(pos_f),
|
||||
"out": str(pos_f + dur_f),
|
||||
"a_track": str(a_idx),
|
||||
"b_track": str(b_idx),
|
||||
})
|
||||
transition_counter += 1
|
||||
_add_prop(trans, "kdenlive_id", td.get("type", ""))
|
||||
for pk, pv in td.get("params", {}).items():
|
||||
if pk == "duration":
|
||||
continue
|
||||
_add_prop(trans, pk, str(pv))
|
||||
|
||||
# Sequence-level volume and panner filters
|
||||
filter_counter = _add_disabled_filter(seq, "volume", filter_counter)
|
||||
filter_counter = _add_disabled_filter(seq, "panner", filter_counter)
|
||||
|
||||
# Bin clip chains (sequential numbering continues)
|
||||
bin_chain_ids = {}
|
||||
for clip in bin_clips:
|
||||
bin_chain_id = f"chain{chain_counter}"
|
||||
chain_counter += 1
|
||||
bin_chain_ids[clip["id"]] = bin_chain_id
|
||||
dur = seconds_to_frames(clip.get("duration", 0), fps_num, fps_den)
|
||||
chain = ET.SubElement(root, "chain", {"id": bin_chain_id, "in": "0", "out": str(max(dur - 1, 0))})
|
||||
_add_prop(chain, "length", str(dur))
|
||||
_add_prop(chain, "eof", "pause")
|
||||
_add_prop(chain, "resource", clip.get("source", ""))
|
||||
_set_producer_props(chain, clip.get("type", "video"))
|
||||
_add_prop(chain, "kdenlive:folderid", "-1")
|
||||
_add_prop(chain, "kdenlive:id", clip_kid[clip["id"]])
|
||||
_add_prop(chain, "mute_on_pause", "0")
|
||||
_add_prop(chain, "kdenlive:clip_type", str(_clip_type_num(clip.get("type", "video"))))
|
||||
if clip.get("name"):
|
||||
_add_prop(chain, "kdenlive:clipname", clip["name"])
|
||||
|
||||
# Main bin playlist
|
||||
main_bin = ET.SubElement(root, "playlist", {"id": "main_bin"})
|
||||
_add_prop(main_bin, "kdenlive:folder.-1.2", "Sequences")
|
||||
_add_prop(main_bin, "kdenlive:sequenceFolder", _SEQUENCE_FOLDER_ID)
|
||||
_add_prop(main_bin, "kdenlive:docproperties.version", "1.1")
|
||||
_add_prop(main_bin, "kdenlive:docproperties.uuid", doc_uuid)
|
||||
_add_prop(main_bin, "kdenlive:docproperties.opensequences", sequence_uuid)
|
||||
_add_prop(main_bin, "kdenlive:docproperties.activetimeline", sequence_uuid)
|
||||
_add_prop(main_bin, "xml_retain", "1")
|
||||
|
||||
ET.SubElement(main_bin, "entry", {"in": "0", "out": "0", "producer": sequence_uuid})
|
||||
for clip in bin_clips:
|
||||
dur = seconds_to_frames(clip.get("duration", 0), fps_num, fps_den)
|
||||
ET.SubElement(main_bin, "entry", {"in": "0", "out": str(max(dur - 1, 0)), "producer": bin_chain_ids[clip["id"]]})
|
||||
|
||||
# Project tractor (last element — what melt plays)
|
||||
proj_tr = ET.SubElement(root, "tractor", {"id": "tractor_project", "in": "0", "out": str(max_dur)})
|
||||
_add_prop(proj_tr, "kdenlive:projectTractor", "1")
|
||||
ET.SubElement(proj_tr, "track", {"producer": sequence_uuid, "in": "0", "out": str(max_dur)})
|
||||
|
||||
ET.indent(root, space=" ")
|
||||
return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode")
|
||||
|
||||
Reference in New Issue
Block a user