|
| 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