mirror of
https://github.com/ggambetta/mlt2fcp.git
synced 2025-12-05 14:19:58 +01:00
404 lines
12 KiB
Python
Executable File
404 lines
12 KiB
Python
Executable File
#!/usr/bin/python
|
|
|
|
#
|
|
# Quick and dirty MLT (Kdenlive / Shotcut, etc) to FCPXML (Final Cut Pro, Davinci Resolve) converter.
|
|
#
|
|
# (C) Gabriel Gambetta 2019.
|
|
#
|
|
# http://gabrielgambetta.com (tech), http://gabrielgambetta.biz (filmmaking), http://twitter.com/gabrielgambetta
|
|
#
|
|
# Licensed under GPL v3.
|
|
#
|
|
|
|
import re
|
|
import os
|
|
import os.path
|
|
import sys
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
# Whether to turn an embedded .kdenlive file into a compound clip, or leave it alone.
|
|
# Doesn't work in all cases. A possible workaround is to convert the embedded .kdenlive independently,
|
|
# import it as a timeline in Resolve, and replacing the missing clip with the imported timeline.
|
|
EMBEDDED_MLT_TO_COMPOUND_CLIP = False
|
|
|
|
# Unclear whether these are necessary, since every clip has an absolute offset anyway.
|
|
ADD_GAP_NODES = True
|
|
|
|
# =============================================================================
|
|
# Data model.
|
|
# =============================================================================
|
|
class ClipFile:
|
|
def __init__(self, resource_path):
|
|
self.resource_path = resource_path
|
|
|
|
class Clip:
|
|
def __init__(self, clip_id, name, resource, duration):
|
|
self.clip_id = clip_id
|
|
self.name = name
|
|
self.resource = resource
|
|
self.duration = duration
|
|
|
|
class Entry:
|
|
def __init__(self, clip, in_time, out_time):
|
|
self.clip = clip
|
|
self.in_time = in_time
|
|
self.out_time = out_time
|
|
|
|
class Track:
|
|
def __init__(self, is_audio):
|
|
self.is_audio = is_audio
|
|
self.entries = []
|
|
|
|
def addEntry(self, entry):
|
|
self.entries.append(entry)
|
|
|
|
global_embed_counter = 0
|
|
|
|
class Project:
|
|
def __init__(self):
|
|
self.clips = {}
|
|
self.tracks = []
|
|
self.frame_rate = None
|
|
|
|
global global_embed_counter
|
|
if global_embed_counter == 0:
|
|
self.id_prefix = "ROOT_" if EMBEDDED_MLT_TO_COMPOUND_CLIP else ""
|
|
else:
|
|
self.id_prefix = "EMB_%02d_" % global_embed_counter
|
|
|
|
global_embed_counter += 1
|
|
|
|
def addClip(self, clip_id, clip):
|
|
self.clips[clip_id] = clip
|
|
|
|
def getClip(self, clip_id):
|
|
return self.clips[clip_id]
|
|
|
|
def addTrack(self, track):
|
|
self.tracks.append(track)
|
|
|
|
|
|
# =============================================================================
|
|
# Kdenlive reader.
|
|
# =============================================================================
|
|
def selectFirst(node, selector):
|
|
values = node.select(selector)
|
|
if values:
|
|
return values[0]
|
|
return None
|
|
|
|
TIME_PATTERN = re.compile(r"(\d\d):(\d\d):(\d\d).(\d\d\d)")
|
|
def parseTime(time_str):
|
|
match = TIME_PATTERN.match(time_str)
|
|
hours = int(match.group(1))
|
|
minutes = int(match.group(2))
|
|
seconds = int(match.group(3))
|
|
millis = int(match.group(4))
|
|
return hours*3600 + minutes*60 + seconds + millis/1000.0
|
|
|
|
class KdenliveReader:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def read(self, filename):
|
|
input_file = file(filename, "rb")
|
|
self.soup = BeautifulSoup(input_file, "lxml-xml")
|
|
self.project = Project()
|
|
|
|
self._parseSettings()
|
|
self._parseProducers()
|
|
self._parseTracks()
|
|
|
|
return self.project
|
|
|
|
|
|
def _parseSettings(self):
|
|
profile = selectFirst(self.soup, "mlt > profile")
|
|
self.project.frame_rate_num = int(profile["frame_rate_num"])
|
|
self.project.frame_rate_den = int(profile["frame_rate_den"])
|
|
self.project.frame_rate = float(self.project.frame_rate_num) / float(self.project.frame_rate_den)
|
|
|
|
self.project.width = int(profile["width"])
|
|
self.project.height = int(profile["height"])
|
|
print "Project format is %d x %d @ %.2f" % (self.project.width, self.project.height, self.project.frame_rate)
|
|
|
|
|
|
def _parseProducers(self):
|
|
self.resource_path_to_canonical_clip = {}
|
|
self.clip_id_to_canonical_clip_id = {}
|
|
|
|
resource_root = selectFirst(self.soup, "mlt")["root"]
|
|
|
|
producers = self.soup.select("producer")
|
|
for producer in producers:
|
|
clip_id = producer["id"]
|
|
if clip_id == "black_track":
|
|
continue
|
|
clip_id = self.project.id_prefix + clip_id
|
|
|
|
resource_name = selectFirst(producer, "property[name=resource]").text
|
|
original = selectFirst(producer, "property[name=kdenlive:originalurl]")
|
|
if original:
|
|
resource_name = original.text
|
|
|
|
duration = parseTime(producer["out"])
|
|
|
|
if re.match(r"[0-9.]+:", resource_name):
|
|
# TODO: preserve slow motion information somewhere.
|
|
colon_idx = resource_name.find(":")
|
|
resource_name = resource_name[colon_idx+1:]
|
|
|
|
resource_path = os.path.join(resource_root, resource_name)
|
|
if resource_path in self.resource_path_to_canonical_clip:
|
|
canonical_clip = self.resource_path_to_canonical_clip[resource_path]
|
|
self.clip_id_to_canonical_clip_id[clip_id] = canonical_clip
|
|
else:
|
|
clip_name = os.path.split(resource_path)[1]
|
|
clip_name_attr = selectFirst(producer, "property[name=kdenlive:clipname]")
|
|
if clip_name_attr:
|
|
clip_name = clip_name_attr.text
|
|
if not clip_name:
|
|
clip_name = clip_id
|
|
|
|
if EMBEDDED_MLT_TO_COMPOUND_CLIP and resource_path.endswith(".kdenlive"):
|
|
reader = KdenliveReader()
|
|
print "Reading embedded project:", resource_path
|
|
resource = reader.read(resource_path)
|
|
else:
|
|
resource = ClipFile(resource_path)
|
|
|
|
clip = Clip(clip_id, clip_name, resource, duration)
|
|
|
|
self.clip_id_to_canonical_clip_id[clip_id] = clip_id
|
|
self.resource_path_to_canonical_clip[resource_path] = clip_id
|
|
self.project.addClip(clip_id, clip)
|
|
print clip_id, "->", resource_path
|
|
|
|
print "Parsed", len(self.project.clips), "clips."
|
|
|
|
|
|
def _parseTracks(self):
|
|
audio_playlist_ids = set()
|
|
tractors = self.soup.select("tractor")
|
|
for tractor in tractors:
|
|
is_audio_track = selectFirst(tractor, "property[name=kdenlive:audio_track]")
|
|
if is_audio_track:
|
|
print tractor["id"], "is audio"
|
|
tracks = tractor.select("track")
|
|
for track in tracks:
|
|
audio_playlist_ids.add(track["producer"])
|
|
|
|
print "Audio playlists:", audio_playlist_ids
|
|
|
|
playlists = self.soup.select("playlist")
|
|
|
|
for playlist in playlists:
|
|
playlist_id = playlist["id"]
|
|
if playlist_id == "main_bin":
|
|
continue
|
|
|
|
is_audio = playlist_id in audio_playlist_ids
|
|
track = Track(is_audio)
|
|
#self.project.addTrack(track)
|
|
|
|
print "Playlist:", playlist_id, ("[audio]" if is_audio else "[video]")
|
|
for entry in playlist.contents:
|
|
if entry.name == "blank":
|
|
length = parseTime(entry["length"])
|
|
print "\tBlank:", length
|
|
entry = Entry(None, 0, length)
|
|
track.addEntry(entry)
|
|
elif entry.name == "entry":
|
|
producer = self.project.id_prefix + entry["producer"]
|
|
clip_id = self.clip_id_to_canonical_clip_id[producer]
|
|
clip = self.project.getClip(clip_id)
|
|
in_time = parseTime(entry["in"])
|
|
out_time = parseTime(entry["out"])
|
|
print "\t%s [%.3f - %.3f]" % (producer, in_time, out_time)
|
|
entry = Entry(clip, in_time, out_time)
|
|
track.addEntry(entry)
|
|
|
|
if track.entries:
|
|
self.project.addTrack(track)
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# FCP XML writer.
|
|
# =============================================================================
|
|
class FcpXmlWriter:
|
|
def __init__(self, project):
|
|
self.xml = BeautifulSoup(features="xml")
|
|
self.added_embedded_resources = set()
|
|
self.project = project
|
|
|
|
def write(self, filename):
|
|
root = self._addTag(self.xml, "fcpxml", version="1.5")
|
|
|
|
resources_tag = self._addTag(root, "resources")
|
|
self._addFormats(resources_tag)
|
|
self._addResources(resources_tag)
|
|
|
|
library = self._addTag(root, "library")
|
|
self._addLibrary(library)
|
|
|
|
file(filename, "wb").write(self.xml.prettify())
|
|
|
|
|
|
def _addFormats(self, resources_tag):
|
|
format_tag = self._addTag(resources_tag, "format")
|
|
format_tag["width"] = self.project.width
|
|
format_tag["height"] = self.project.height
|
|
format_tag["id"] = "r0"
|
|
format_tag["frameDuration"] = "1000/%ds" % int(self.project.frame_rate * 1000)
|
|
|
|
|
|
def _addResources(self, resources_tag):
|
|
for clip_id, clip in self.project.clips.iteritems():
|
|
resource = clip.resource
|
|
if isinstance(resource, ClipFile):
|
|
asset = self._addTag(resources_tag, "asset")
|
|
asset["name"] = clip.name
|
|
asset["id"] = clip_id
|
|
asset["src"] = "file://" + resource.resource_path
|
|
asset["hasVideo"] = 1
|
|
asset["duration"] = self._formatTime(clip.duration)
|
|
elif isinstance(resource, Project):
|
|
self._addEmbeddedTimeline(clip_id, resources_tag)
|
|
|
|
|
|
def _addEmbeddedTimeline(self, clip_id, resources_tag):
|
|
clip = project.clips[clip_id]
|
|
embedded_project = clip.resource
|
|
writer = FcpXmlWriter(embedded_project)
|
|
if embedded_project not in self.added_embedded_resources:
|
|
self.added_embedded_resources.add(embedded_project)
|
|
writer._addResources(resources_tag)
|
|
|
|
media = self._addTag(resources_tag, "media")
|
|
media["name"] = clip.name
|
|
media["id"] = clip_id
|
|
writer._addSequence(media, True)
|
|
|
|
|
|
def _addLibrary(self, library_tag):
|
|
event = self._addTag(library_tag, "event")
|
|
event["name"] = "Timeline 1"
|
|
project_tag = self._addTag(event, "project")
|
|
project_tag["name"] = "Timeline 1"
|
|
self._addSequence(project_tag, False)
|
|
|
|
|
|
def _addSequence(self, project_tag, wrap_in_clip):
|
|
sequence = self._addTag(project_tag, "sequence")
|
|
sequence["format"] = "r0"
|
|
spine = self._addTag(sequence, "spine")
|
|
|
|
if wrap_in_clip:
|
|
wrapper = self._addTag(spine, "clip")
|
|
wrapper["duration"] = self._formatTime(self._getProjectLength())
|
|
else:
|
|
wrapper = spine
|
|
|
|
index = 0
|
|
for track in self.project.tracks:
|
|
self._addTrack(track, wrapper, index)
|
|
index += 1
|
|
|
|
|
|
# Computes the project length, given by the latest out-time it contains.
|
|
def _getProjectLength(self):
|
|
latest_out = 0
|
|
for track in self.project.tracks:
|
|
for entry in track.entries:
|
|
latest_out = max(latest_out, entry.out_time)
|
|
return latest_out
|
|
|
|
|
|
def _addTrack(self, track, spine, index):
|
|
offset = 0
|
|
for entry in track.entries:
|
|
clip = entry.clip
|
|
duration = entry.out_time - entry.in_time
|
|
|
|
if clip is None:
|
|
if ADD_GAP_NODES:
|
|
clip_node = self._addTag(spine, "gap")
|
|
else:
|
|
offset += duration
|
|
continue
|
|
else:
|
|
resource = clip.resource
|
|
if isinstance(resource, ClipFile):
|
|
clip_tag_name = "audio" if track.is_audio else "video"
|
|
clip_node = self._addTag(spine, clip_tag_name)
|
|
one_frame_in_sec = 0.001 + 1.0 / self.project.frame_rate # TODO: should use frame rate of clip
|
|
duration += + one_frame_in_sec
|
|
elif isinstance(resource, Project):
|
|
clip_node = self._addTag(spine, "ref-clip")
|
|
clip_node["srcEnable"] = "audio" if track.is_audio else "video"
|
|
self._addFakeTimemap(clip_node)
|
|
|
|
clip_node["name"] = clip.name
|
|
clip_node["ref"] = clip.clip_id
|
|
|
|
clip_node["start"] = self._formatTime(entry.in_time)
|
|
clip_node["duration"] = self._formatTime(duration)
|
|
clip_node["offset"] = self._formatTime(offset)
|
|
if index > 0:
|
|
clip_node["lane"] = index
|
|
|
|
offset += duration
|
|
|
|
|
|
# Adds a <timeMap> node that forces Resolve to create a compound clip, although the speed we set is 100%.
|
|
def _addFakeTimemap(self, clip_node):
|
|
timemap = self._addTag(clip_node, "timeMap")
|
|
|
|
timept = self._addTag(timemap, "timept")
|
|
timept["time"] = "0/1s"
|
|
timept["value"] = "0/1s"
|
|
|
|
timept = self._addTag(timemap, "timept")
|
|
timept["time"] = "1/1s"
|
|
timept["value"] = "10/10s"
|
|
|
|
|
|
def _formatTime(self, seconds):
|
|
# TODO: use frame rate? Use GCD?
|
|
if seconds == int(seconds):
|
|
return str(int(seconds)) + "/1s"
|
|
return "%d/1000s" % int(seconds * 1000)
|
|
|
|
|
|
def _addTag(self, node, tagName, **attributes):
|
|
tag = self.xml.new_tag(tagName, **attributes)
|
|
node.append(tag)
|
|
return tag
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# Main
|
|
# =============================================================================
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print "Usage: mlt2fcp.py input.kdenlive [output.fcpxml]"
|
|
print
|
|
print "If the output filename is not given, replaces the extension of the input file with .fcpxml."
|
|
sys.exit(1)
|
|
|
|
input_filename = sys.argv[1]
|
|
if len(sys.argv) > 2:
|
|
output_filename = sys.argv[2]
|
|
else:
|
|
output_filename = os.path.splitext(input_filename)[0] + ".fcpxml"
|
|
|
|
reader = KdenliveReader()
|
|
project = reader.read(input_filename)
|
|
|
|
writer = FcpXmlWriter(project)
|
|
writer.write(output_filename)
|