Skip to content
Browse files

Add Kdenlive adapter (#618)

  • Loading branch information
vpinon authored and jminor committed Dec 3, 2019
1 parent 6d32687 commit 409bdde3099b1dd6a99f4299185b73f40f38b5cc
@@ -56,6 +56,13 @@
"execution_scope": "in process",
"filepath": "",
"suffixes": ["xges"]
"OTIO_SCHEMA": "Adapter.1",
"name": "kdenlive",
"execution_scope": "in process",
"filepath": "",
"suffixes": ["kdenlive"]
"schemadefs" : [
@@ -0,0 +1,350 @@
# Copyright (C) 2019 Kdenlive developers <>
# Licensed under the Apache License, Version 2.0 (the "Apache License")
# with the following modification; you may not use this file except in
# compliance with the Apache License and the following modification to it:
# Section 6. Trademarks. is deleted and replaced with:
# 6. Trademarks. This License does not grant permission to use the trade
# names, trademarks, service marks, or product names of the Licensor
# and its affiliates, except as required to comply with Section 4(c) of
# the License and to reproduce the content of the NOTICE file.
# You may obtain a copy of the Apache License at
# Unless required by applicable law or agreed to in writing, software
# distributed under the Apache License with the above modification is
# KIND, either express or implied. See the Apache License for the specific
# language governing permissions and limitations under the Apache License.

"""Kdenlive (MLT XML) Adapter."""
import re
import os
from xml.etree import ElementTree as ET
import opentimelineio as otio
import datetime

def read_property(element, name):
"""Decode an MLT item property
which value is contained in a "property" XML element
with matching "name" attribute"""
return element.findtext("property[@name='{}']".format(name), '')

def time(clock, fps):
"""Decode an MLT time
which is either a frame count or a timecode string
after format hours:minutes:seconds.floatpart"""
hms = [float(x) for x in clock.replace(',', '.').split(':')]
f = 0
m = fps if len(hms) > 1 else 1 # no delimiter, it is a frame number
for x in reversed(hms):
f = f + x * m
m = m * 60
return otio.opentime.RationalTime(round(f, 3), fps)

def read_keyframes(kfstring, rate):
"""Decode MLT keyframes
which are in a semicolon (;) separated list of time/value pair
separated by = (linear interp) or ~= (spline) or |= (step)
becomes a dict with RationalTime keys"""
return dict((time(t, rate), v)
for (t, v) in re.findall('([^|~=;]*)[|~]?=([^;]*)', kfstring))

def read_from_string(input_str):
"""Read a Kdenlive project (MLT XML)
Kdenlive uses a given MLT project layout, similar to Shotcut,
combining a "main_bin" playlist to organize source media,
and a "global_feed" tractor for timeline.
(in Kdenlive 19.x, timeline tracks include virtual sub-track, unused for now)"""
mlt, byid = ET.XMLID(input_str)
profile = mlt.find('profile')
rate = (float(profile.get('frame_rate_num'))
/ float(profile.get('frame_rate_den', 1)))
timeline = otio.schema.Timeline(
name=mlt.get('name', 'Kdenlive imported timeline'))

maintractor = mlt.find("tractor[@global_feed='1']")
for maintrack in maintractor.findall('track'):
if maintrack.get('producer') == 'black_track':
subtractor = byid[maintrack.get('producer')]
track = otio.schema.Track(
name=read_property(subtractor, 'kdenlive:track_name'))
if bool(read_property(subtractor, 'kdenlive:audio_track')):
track.kind = otio.schema.TrackKind.Audio
track.kind = otio.schema.TrackKind.Video
for subtrack in subtractor.findall('track'):
playlist = byid[subtrack.get('producer')]
for item in playlist.iter():
if item.tag == 'blank':
gap = otio.schema.Gap(
duration=time(item.get('length'), rate))
elif item.tag == 'entry':
producer = byid[item.get('producer')]
service = read_property(producer, 'mlt_service')
available_range = otio.opentime.TimeRange(
start_time=time(producer.get('in'), rate),
duration=time(producer.get('out'), rate)
- time(producer.get('in'), rate))
source_range = otio.opentime.TimeRange(
start_time=time(item.get('in'), rate),
duration=time(item.get('out'), rate)
- time(item.get('in'), rate))
# media reference clip
reference = None
if service in ['avformat', 'avformat-novalidate', 'qimage']:
reference = otio.schema.ExternalReference(
producer, 'kdenlive:originalurl') or
read_property(producer, 'resource'),
elif service == 'color':
reference = otio.schema.GeneratorReference(
parameters={'color': read_property(producer, 'resource')},
clip = otio.schema.Clip(
name=read_property(producer, 'kdenlive:clipname'),
media_reference=reference or otio.schema.MissingReference())
for effect in item.findall('filter'):
kdenlive_id = read_property(effect, 'kdenlive_id')
if kdenlive_id in ['fadein', 'fade_from_black',
'fadeout', 'fade_to_black']:
time(effect.get('out'), rate)
- time(effect.get('in',
producer.get('in')), rate)}))
elif kdenlive_id in ['volume', 'brightness']:
metadata={'keyframes': read_keyframes(
read_property(effect, 'level'), rate)}))

for transition in maintractor.findall('transition'):
kdenlive_id = read_property(transition, 'kdenlive_id')
if kdenlive_id == 'wipe':
timeline.tracks[int(read_property(transition, 'b_track')) - 1].append(
in_offset=time(transition.get('in'), rate),
out_offset=time(transition.get('out'), rate)))

return timeline

def write_property(element, name, value):
"""Store an MLT property
value contained in a "property" sub element
with defined "name" attribute"""
property = ET.SubElement(element, 'property', {'name': name})
property.text = value

def clock(time):
"""Encode time to an MLT timecode string
after format hours:minutes:seconds.floatpart"""
return str(datetime.timedelta(seconds=time.value / time.rate))

def write_keyframes(kfdict):
"""Build a MLT keyframe string"""
return ';'.join('{}={}'.format(str(int(t.value))).format(v)
for t, v in kfdict.items())

def write_to_string(input_otio):
"""Write a timeline to Kdenlive project
Re-creating the bin storing all used source clips
and constructing the tracks"""
if not isinstance(input_otio, otio.schema.Timeline) and len(input_otio) > 1:
print('WARNING: Only one timeline supported, using the first one.')
input_otio = input_otio[0]
# Project header & metadata
mlt = ET.Element('mlt', {
'version': '6.16.0',
'LC_NUMERIC': 'en_US.UTF-8',
'producer': 'main_bin'})
rate = input_otio.duration().rate
(rate_num, rate_den) = {
23.98: (24000, 1001),
29.97: (30000, 1001),
59.94: (60000, 1001)
}.get(round(float(rate), 2), (int(rate), 1))
ET.SubElement(mlt, 'profile', {
'description': 'HD 1080p {} fps'.format(rate),
'frame_rate_num': str(rate_num),
'frame_rate_den': str(rate_den),
'width': '1920',
'height': '1080',
'display_aspect_num': '16',
'display_aspect_den': '9',
'sample_aspect_num': '1',
'sample_aspect_den': '1',
'colorspace': '709',
'progressive': '1'})

# Build media library, indexed by url
main_bin = ET.Element('playlist', {'id': 'main_bin'})
write_property(main_bin, 'kdenlive:docproperties.decimalPoint', '.')
write_property(main_bin, 'kdenlive:docproperties.version', '0.98')
write_property(main_bin, 'xml_retain', '1')
media_prod = {}
for clip in input_otio.each_clip():
service = None
resource = None
if isinstance(clip.media_reference, otio.schema.ExternalReference):
resource = clip.media_reference.target_url
service = 'qimage' if os.path.splitext(resource)[1].lower() \
in ['.png', '.jpg', '.jpeg'] else 'avformat'
elif isinstance(clip.media_reference, otio.schema.GeneratorReference) \
and clip.media_reference.generator_kind == 'SolidColor':
service = 'color'
resource = clip.media_reference.parameters['color']
if not (service and resource) or (resource in media_prod.keys()):
producer = ET.SubElement(mlt, 'producer', {
'id': 'producer{}'.format(len(media_prod)),
'in': clock(clip.media_reference.available_range.start_time),
'out': clock((clip.media_reference.available_range.start_time +
ET.SubElement(main_bin, 'entry',
{'producer': 'producer{}'.format(len(media_prod))})
write_property(producer, 'mlt_service', service)
write_property(producer, 'resource', resource)
write_property(producer, 'kdenlive:clipname',
media_prod[resource] = producer

# Substitute source clip to be referred to when meeting an unsupported clip
unsupported = ET.SubElement(mlt, 'producer',
{'id': 'unsupported', 'in': '0', 'out': '10000'})
write_property(unsupported, 'mlt_service', 'qtext')
write_property(unsupported, 'family', 'Courier')
write_property(unsupported, 'fgcolour', '#ff808080')
write_property(unsupported, 'bgcolour', '#00000000')
write_property(unsupported, 'text', 'Unsupported clip type')
ET.SubElement(main_bin, 'entry', {'producer': 'unsupported'})

# Background clip
black = ET.SubElement(mlt, 'producer', {'id': 'black_track'})
write_property(black, 'resource', 'black')
write_property(black, 'mlt_service', 'color')

# Timeline & tracks
maintractor = ET.Element('tractor', {'global_feed': '1'})
ET.SubElement(maintractor, 'track', {'producer': 'black_track'})
track_count = 0
for track in input_otio.tracks:
track_count = track_count + 1

ET.SubElement(maintractor, 'track',
{'producer': 'tractor{}'.format(track_count)})
subtractor = ET.Element('tractor', {'id': 'tractor{}'.format(track_count)})
write_property(subtractor, 'kdenlive:track_name',

ET.SubElement(subtractor, 'track', {
'producer': 'playlist{}_1'.format(track_count),
'hide': 'audio' if track.kind == otio.schema.TrackKind.Video
else 'video'})
ET.SubElement(subtractor, 'track', {
'producer': 'playlist{}_2'.format(track_count),
'hide': 'audio' if track.kind == otio.schema.TrackKind.Video
else 'video'})
playlist = ET.SubElement(mlt, 'playlist',
{'id': 'playlist{}_1'.format(track_count)})
playlist_ = ET.SubElement(mlt, 'playlist',
{'id': 'playlist{}_2'.format(track_count)})
if track.kind == otio.schema.TrackKind.Audio:
write_property(subtractor, 'kdenlive:audio_track', '1')
write_property(playlist, 'kdenlive:audio_track', '1')
write_property(playlist_, 'kdenlive:audio_track', '1')

# Track playlist
for item in track:
if isinstance(item, otio.schema.Gap):
ET.SubElement(playlist, 'blank',
{'length': clock(item.duration())})
elif isinstance(item, otio.schema.Clip):
if isinstance(item.media_reference,
resource = 'unhandled_type'
if isinstance(item.media_reference,
resource = item.media_reference.target_url
elif isinstance(item.media_reference,
otio.schema.GeneratorReference) \
and item.media_reference.generator_kind == 'SolidColor':
resource = item.media_reference.parameters['color']
clip_in = item.source_range.start_time
clip_out = item.source_range.duration + clip_in
clip = ET.SubElement(playlist, 'entry', {
'producer': media_prod[resource].attrib['id']
if item.media_reference and
not item.media_reference.is_missing_reference
else 'unsupported',
'in': clock(clip_in), 'out': clock(clip_out)})
# Clip effects
for effect in item.effects:
kid = effect.effect_name
if kid in ['fadein', 'fade_from_black']:
filt = ET.SubElement(clip, 'filter', {
'in': clock(clip_in),
'out': clock(clip_in + effect.metadata['duration'])})
write_property(filt, 'kdenlive_id', kid)
write_property(filt, 'end', '1')
if kid == 'fadein':
write_property(filt, 'mlt_service', 'volume')
write_property(filt, 'gain', '0')
write_property(filt, 'mlt_service', 'brightness')
write_property(filt, 'start', '0')
elif effect.effect_name in ['fadeout', 'fade_to_black']:
filt = ET.SubElement(clip, 'filter', {
'in': clock(clip_out - effect.metadata['duration']),
'out': clock(clip_out)})
write_property(filt, 'kdenlive_id', kid)
write_property(filt, 'end', '0')
if kid == 'fadeout':
write_property(filt, 'mlt_service', 'volume')
write_property(filt, 'gain', '1')
write_property(filt, 'mlt_service', 'brightness')
write_property(filt, 'start', '1')
elif effect.effect_name in ['volume', 'brightness']:
filt = ET.SubElement(clip, 'filter')
write_property(filt, 'kdenlive_id', kid)
write_property(filt, 'mlt_service', kid)
write_property(filt, 'level',
elif isinstance(item, otio.schema.Transition):
print('Transitions handling to be added')

return ET.tostring(mlt, encoding='utf-8')

if __name__ == '__main__':
timeline = read_from_string(
open('tests/sample_data/kdenlive_example.kdenlive', 'r').read())
print(str(timeline).replace('otio.schema', "\notio.schema"))
xml = write_to_string(timeline)
print(str(xml).replace("><", ">\n<"))

0 comments on commit 409bdde

Please sign in to comment.
You can’t perform that action at this time.