Skip to content

Commit 33b9f04

Browse files
committed
Add Kdenlive adapter
1 parent 6d32687 commit 33b9f04

File tree

4 files changed

+1390
-0
lines changed

4 files changed

+1390
-0
lines changed

contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@
5656
"execution_scope": "in process",
5757
"filepath": "xges.py",
5858
"suffixes": ["xges"]
59+
},
60+
{
61+
"OTIO_SCHEMA": "Adapter.1",
62+
"name": "kdenlive",
63+
"execution_scope": "in process",
64+
"filepath": "kdenlive.py",
65+
"suffixes": ["kdenlive"]
5966
}
6067
],
6168
"schemadefs" : [
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
#
2+
# Copyright (C) 2019 Kdenlive developers <[email protected]>
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "Apache License")
5+
# with the following modification; you may not use this file except in
6+
# compliance with the Apache License and the following modification to it:
7+
# Section 6. Trademarks. is deleted and replaced with:
8+
#
9+
# 6. Trademarks. This License does not grant permission to use the trade
10+
# names, trademarks, service marks, or product names of the Licensor
11+
# and its affiliates, except as required to comply with Section 4(c) of
12+
# the License and to reproduce the content of the NOTICE file.
13+
#
14+
# You may obtain a copy of the Apache License at
15+
#
16+
# http://www.apache.org/licenses/LICENSE-2.0
17+
#
18+
# Unless required by applicable law or agreed to in writing, software
19+
# distributed under the Apache License with the above modification is
20+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
21+
# KIND, either express or implied. See the Apache License for the specific
22+
# language governing permissions and limitations under the Apache License.
23+
#
24+
25+
"""OpenTimelineIO Kdenlive (MLT) XML Adapter. """
26+
import re
27+
import os
28+
from xml.etree import ElementTree as ET
29+
import opentimelineio as otio
30+
import datetime
31+
32+
33+
def read_property(element, name):
34+
""" Decode an MLT item property
35+
which value is contained in a "property" XML element
36+
with matching "name" attribute """
37+
return element.findtext(f"property[@name='{name}']", "")
38+
39+
40+
def time(clock, fps):
41+
""" Decode an MLT time
42+
which is either a frame count or a timecode string
43+
after format hours:minutes:seconds.floatpart """
44+
hms = [float(x) for x in clock.replace(",", ".").split(":")]
45+
f = 0
46+
m = fps if len(hms) > 1 else 1 # no delimiter, it is a frame number
47+
for x in reversed(hms):
48+
f = f + x * m
49+
m = m * 60
50+
return otio.opentime.RationalTime(round(f, 3), fps)
51+
52+
53+
def read_keyframes(kfstring, rate):
54+
""" Decode MLT keyframes
55+
which are in a semicolon (;) separated list of time/value pair
56+
separated by = (linear interp) or ~= (spline) or |= (step)
57+
becomes a dict with RationalTime keys """
58+
return dict((time(t, rate), v)
59+
for (t, v) in re.findall("([^|~=;]*)[|~]?=([^;]*)", kfstring))
60+
61+
62+
def read_from_string(input_str):
63+
""" Read a Kdenlive project (MLT XML)
64+
MLT main timeline is the "tractor" element with "global_feed" attribute.
65+
It comprises "track" elements which refer to "producer" elements by ids.
66+
In Kdenlive >= 19.x, these producers are also "tractor" tags.
67+
They have sub "track" elements.
68+
Track producers are referring to "playlist" elements.
69+
Playlist contain a suite of "blank" (gap) and "entry" (clip) elements.
70+
Clips contain effects as "filter" elements """
71+
mlt, byid = ET.XMLID(input_str)
72+
profile = mlt.find("profile")
73+
rate = (float(profile.get("frame_rate_num"))
74+
/ float(profile.get("frame_rate_den", 1)))
75+
timeline = otio.schema.Timeline(
76+
name=mlt.get("name", "Kdenlive imported timeline"))
77+
78+
maintractor = mlt.find("tractor[@global_feed='1']")
79+
for maintrack in maintractor.findall("track"):
80+
if maintrack.get("producer") == 'black_track':
81+
continue
82+
subtractor = byid[maintrack.get("producer")]
83+
track = otio.schema.Track(
84+
name=read_property(subtractor, "kdenlive:track_name"))
85+
if bool(read_property(subtractor, "kdenlive:audio_track")):
86+
track.kind = otio.schema.TrackKind.Audio
87+
else:
88+
track.kind = otio.schema.TrackKind.Video
89+
for subtrack in subtractor.findall("track"):
90+
playlist = byid[subtrack.get("producer")]
91+
for item in playlist.iter():
92+
if item.tag == 'blank':
93+
gap = otio.schema.Gap(
94+
duration=time(item.get("length"), rate))
95+
track.append(gap)
96+
elif item.tag == 'entry':
97+
producer = byid[item.get("producer")]
98+
service = read_property(producer, "mlt_service")
99+
available_range = otio.opentime.TimeRange(
100+
start_time=time(producer.get("in"), rate),
101+
duration=time(producer.get("out"), rate)
102+
- time(producer.get("in"), rate))
103+
source_range = otio.opentime.TimeRange(
104+
start_time=time(item.get("in"), rate),
105+
duration=time(item.get("out"), rate)
106+
- time(item.get("in"), rate))
107+
# media reference clip
108+
reference = None
109+
if service in ["avformat", "avformat-novalidate", "qimage"]:
110+
reference = otio.schema.ExternalReference(
111+
target_url=read_property(
112+
producer, 'kdenlive:originalurl') or
113+
read_property(producer, 'resource'),
114+
available_range=available_range)
115+
elif service == "color":
116+
reference = otio.schema.GeneratorReference(
117+
generator_kind="SolidColor",
118+
parameters={"color": read_property(producer, "resource")},
119+
available_range=available_range)
120+
clip = otio.schema.Clip(
121+
name=read_property(producer, 'kdenlive:clipname'),
122+
source_range=source_range,
123+
media_reference=reference or otio.schema.MissingReference())
124+
for effect in item.findall("filter"):
125+
kdenlive_id = read_property(effect, "kdenlive_id")
126+
if kdenlive_id in ["fadein", "fade_from_black",
127+
"fadeout", "fade_to_black"]:
128+
clip.effects.append(otio.schema.Effect(
129+
effect_name=kdenlive_id,
130+
metadata={'duration': time(effect.get("out"), rate)
131+
- time(effect.get("in",
132+
producer.get("in")), rate)}))
133+
elif kdenlive_id in ["volume", "brightness"]:
134+
clip.effects.append(otio.schema.Effect(
135+
effect_name=kdenlive_id,
136+
metadata={'keyframes': read_keyframes(
137+
read_property(effect, "level"), rate)}))
138+
track.append(clip)
139+
timeline.tracks.append(track)
140+
141+
for transition in maintractor.findall("transition"):
142+
kdenlive_id = read_property(transition, "kdenlive_id")
143+
if kdenlive_id == "wipe":
144+
timeline.tracks[int(read_property(transition, "b_track")) - 1].append(
145+
otio.schema.Transition(
146+
transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve,
147+
in_offset=time(transition.get("in"), rate),
148+
out_offset=time(transition.get("out"), rate)))
149+
150+
return timeline
151+
152+
153+
def write_property(element, name, value):
154+
""" Store an MLT property
155+
value contained in a "property" sub element
156+
with defined "name" attribute """
157+
property = ET.SubElement(element, "property", {"name": name})
158+
property.text = value
159+
160+
161+
def clock(time):
162+
""" Encode time to an MLT timecode string
163+
after format hours:minutes:seconds.floatpart """
164+
return str(datetime.timedelta(seconds=time.value / time.rate))
165+
166+
167+
def write_keyframes(kfdict):
168+
""" Build a MLT keyframe string """
169+
return ";".join(f'{str(int(t.value))}={v}' for t, v in kfdict.items())
170+
171+
172+
def write_to_string(input_otio):
173+
""" Write a timeline to Kdenlive project
174+
Re-creating the bin storing all source clips
175+
and constructing the tracks """
176+
if not isinstance(input_otio, otio.schema.Timeline) and len(input_otio) > 1:
177+
print("WARNING: Only one timeline supported, using the first one.")
178+
input_otio = input_otio[0]
179+
# Project header & metadata
180+
mlt = ET.Element("mlt", {
181+
"version": "6.16.0",
182+
"title": input_otio.name,
183+
"LC_NUMERIC": "en_US.UTF-8",
184+
"producer": "main_bin"})
185+
rate = input_otio.duration().rate
186+
(rate_num, rate_den) = {
187+
23.98: (24000, 1001),
188+
29.97: (30000, 1001),
189+
59.94: (60000, 1001)
190+
}.get(round(float(rate), 2), (int(rate), 1))
191+
ET.SubElement(mlt, "profile", {
192+
"description": f"HD 1080p {rate} fps",
193+
"frame_rate_num": str(rate_num),
194+
"frame_rate_den": str(rate_den),
195+
"width": "1920",
196+
"height": "1080",
197+
"display_aspect_num": "16",
198+
"display_aspect_den": "9",
199+
"sample_aspect_num": "1",
200+
"sample_aspect_den": "1",
201+
"colorspace": "709",
202+
"progressive": "1"})
203+
204+
# Build media library, indexed by url
205+
main_bin = ET.Element("playlist", {"id": "main_bin"})
206+
write_property(main_bin, "kdenlive:docproperties.decimalPoint", ".")
207+
write_property(main_bin, "kdenlive:docproperties.version", "0.98")
208+
write_property(main_bin, "xml_retain", "1")
209+
media_prod = {}
210+
for clip in input_otio.each_clip():
211+
service = None
212+
resource = None
213+
if isinstance(clip.media_reference, otio.schema.ExternalReference):
214+
resource = clip.media_reference.target_url
215+
service = "qimage" if os.path.splitext(resource)[1].lower() \
216+
in [".png", ".jpg", ".jpeg"] else "avformat"
217+
elif isinstance(clip.media_reference, otio.schema.GeneratorReference) \
218+
and clip.media_reference.generator_kind == "SolidColor":
219+
service = "color"
220+
resource = clip.media_reference.parameters["color"]
221+
if not (service and resource) or (resource in media_prod.keys()):
222+
continue
223+
producer = ET.SubElement(mlt, "producer", {
224+
"id": f"producer{len(media_prod)}",
225+
"in": clock(clip.media_reference.available_range.start_time),
226+
"out": clock((clip.media_reference.available_range.start_time +
227+
clip.media_reference.available_range.duration))})
228+
ET.SubElement(main_bin, "entry", {"producer": f"producer{len(media_prod)}"})
229+
write_property(producer, "mlt_service", service)
230+
write_property(producer, "resource", resource)
231+
if clip.name:
232+
write_property(producer, "kdenlive:clipname", clip.name)
233+
media_prod[resource] = producer
234+
235+
# Substitute source clip to be referred to when meeting an unsupported clip
236+
unsupported = ET.SubElement(mlt, "producer",
237+
{"id": "unsupported", "in": "0", "out": "10000"})
238+
write_property(unsupported, "mlt_service", "qtext")
239+
write_property(unsupported, "family", "Courier")
240+
write_property(unsupported, "fgcolour", "#ff808080")
241+
write_property(unsupported, "bgcolour", "#00000000")
242+
write_property(unsupported, "text", "Unsupported clip type")
243+
ET.SubElement(main_bin, "entry", {"producer": "unsupported"})
244+
mlt.append(main_bin)
245+
246+
# Background clip
247+
black = ET.SubElement(mlt, "producer", {"id": "black_track"})
248+
write_property(black, "resource", "black")
249+
write_property(black, "mlt_service", "color")
250+
251+
# Timeline & tracks
252+
maintractor = ET.Element("tractor", {"global_feed": "1"})
253+
ET.SubElement(maintractor, "track", {"producer": "black_track"})
254+
track_count = 0
255+
for track in input_otio.tracks:
256+
track_count = track_count + 1
257+
258+
ET.SubElement(maintractor, "track",
259+
{"producer": f"tractor{track_count}"})
260+
subtractor = ET.Element("tractor", {"id": f"tractor{track_count}"})
261+
write_property(subtractor, "kdenlive:track_name", track.name)
262+
263+
ET.SubElement(subtractor, "track", {
264+
"producer": f"playlist{track_count}_1",
265+
"hide": "audio" if track.kind == otio.schema.TrackKind.Video
266+
else "video"})
267+
ET.SubElement(subtractor, "track", {
268+
"producer": f"playlist{track_count}_2",
269+
"hide": "audio" if track.kind == otio.schema.TrackKind.Video
270+
else "video"})
271+
playlist = ET.SubElement(mlt, "playlist",
272+
{"id": f"playlist{track_count}_1"})
273+
playlist_ = ET.SubElement(mlt, "playlist",
274+
{"id": f"playlist{track_count}_2"})
275+
if track.kind == otio.schema.TrackKind.Audio:
276+
write_property(subtractor, "kdenlive:audio_track", "1")
277+
write_property(playlist, "kdenlive:audio_track", "1")
278+
write_property(playlist_, "kdenlive:audio_track", "1")
279+
280+
# Track playlist
281+
for item in track:
282+
if isinstance(item, otio.schema.Gap):
283+
ET.SubElement(playlist, "blank",
284+
{"length": clock(item.duration())})
285+
elif isinstance(item, otio.schema.Clip):
286+
if isinstance(item.media_reference,
287+
otio.schema.MissingReference):
288+
resource = "unhandled_type"
289+
if isinstance(item.media_reference,
290+
otio.schema.ExternalReference):
291+
resource = item.media_reference.target_url
292+
elif isinstance(item.media_reference,
293+
otio.schema.GeneratorReference) \
294+
and item.media_reference.generator_kind == "SolidColor":
295+
resource = item.media_reference.parameters["color"]
296+
clip_in = item.source_range.start_time
297+
clip_out = item.source_range.duration + clip_in
298+
clip = ET.SubElement(playlist, "entry", {
299+
"producer": media_prod[resource].attrib["id"]
300+
if item.media_reference and
301+
not item.media_reference.is_missing_reference
302+
else "unsupported",
303+
"in": clock(clip_in), "out": clock(clip_out)})
304+
# Clip effects
305+
for effect in item.effects:
306+
kid = effect.effect_name
307+
if kid in ["fadein", "fade_from_black"]:
308+
filt = ET.SubElement(clip, "filter", {
309+
'in': clock(clip_in),
310+
'out': clock(clip_in + effect.metadata['duration'])})
311+
write_property(filt, 'kdenlive_id', kid)
312+
write_property(filt, 'end', '1')
313+
if kid == 'fadein':
314+
write_property(filt, 'mlt_service', 'volume')
315+
write_property(filt, 'gain', '0')
316+
else:
317+
write_property(filt, 'mlt_service', 'brightness')
318+
write_property(filt, 'start', '0')
319+
elif effect.effect_name in ["fadeout", "fade_to_black"]:
320+
filt = ET.SubElement(clip, "filter", {
321+
'in': clock(clip_out - effect.metadata['duration']),
322+
'out': clock(clip_out)})
323+
write_property(filt, 'kdenlive_id', kid)
324+
write_property(filt, 'end', '0')
325+
if kid == 'fadeout':
326+
write_property(filt, 'mlt_service', 'volume')
327+
write_property(filt, 'gain', '1')
328+
else:
329+
write_property(filt, 'mlt_service', 'brightness')
330+
write_property(filt, 'start', '1')
331+
elif effect.effect_name in ["volume", "brightness"]:
332+
filt = ET.SubElement(clip, "filter")
333+
write_property(filt, 'kdenlive_id', kid)
334+
write_property(filt, 'mlt_service', kid)
335+
write_property(filt, 'level',
336+
write_keyframes(effect.metadata['keyframes']))
337+
elif isinstance(item, otio.schema.Transition):
338+
print("Transitions handling to be added")
339+
mlt.append(subtractor)
340+
mlt.append(maintractor)
341+
342+
return "<?xml version='1.0' encoding='utf-8'?>\n" \
343+
+ ET.tostring(mlt, encoding="unicode")
344+
345+
346+
if __name__ == "__main__":
347+
# timeline = otio.adapters.read_from_file(
348+
# "tests/sample_data/kdenlive_example.kdenlive")
349+
timeline = read_from_string(
350+
open("tests/sample_data/kdenlive_example.kdenlive", "r").read())
351+
# print(otio.adapters.write_to_string(timeline, "otio_json"))
352+
print(str(timeline).replace("otio.schema", "\notio.schema"))
353+
xml = write_to_string(timeline)
354+
xml = xml.replace("><", ">\n<")
355+
print(xml)

0 commit comments

Comments
 (0)