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:
Feng Yu
2026-04-14 03:32:31 +08:00
parent 18f75791f3
commit b64ee841bc
3 changed files with 749 additions and 481 deletions
@@ -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 '&lt;' in xml
assert '&gt;' in xml
assert '&quot;' 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("&", "&amp;")
s = s.replace("<", "&lt;")
s = s.replace(">", "&gt;")
@@ -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")