edit2fcp
This commit is contained in:
commit
d755d3afc4
6 changed files with 506 additions and 0 deletions
32
README.txt
Normal file
32
README.txt
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
pandora_cut - export pan.do/ra edits into NLE formats
|
||||||
|
|
||||||
|
Suppported outputs:
|
||||||
|
|
||||||
|
Final Cut Pro 7 (fcp)
|
||||||
|
VLC (m3u)
|
||||||
|
Final Cut Pro X (planned)
|
||||||
|
kdenlive (planned)
|
||||||
|
Pitivi (planned)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
pandora_cut [options] edit_url > output.xml
|
||||||
|
|
||||||
|
pandora_cut --help for more information
|
||||||
|
|
||||||
|
|
||||||
|
== Final Cut Pro 7 ==
|
||||||
|
|
||||||
|
Final Cut Pro only supports videos with 23.97,29,97 or integers as framerates.
|
||||||
|
Its important to convert all videos to the framerate of the project before import.
|
||||||
|
|
||||||
|
In addition HD editing works best if mateiral is converted to ProRes.
|
||||||
|
|
||||||
|
FFmpeg cna be used to prepare most videos accordingly:
|
||||||
|
|
||||||
|
$ ffmpeg -i input.mkv -vcodec prores -profile:v 2 -qscale:v 13 -vendor ap10 prores/output.mov
|
||||||
|
|
||||||
|
if material is not in the right framerate, i.e. conform to NTSC(29.97 fps):
|
||||||
|
|
||||||
|
$ ffmpeg -i input.mkv -vcodec prores -r ntsc -profile:v 2 -qscale:v 13 -vendor ap10 prores/output.mov
|
||||||
|
|
0
edl/__init__.py
Normal file
0
edl/__init__.py
Normal file
273
edl/fcp.py
Normal file
273
edl/fcp.py
Normal file
|
@ -0,0 +1,273 @@
|
||||||
|
from ox.utils import ET
|
||||||
|
from urllib import quote
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from utils import tostring
|
||||||
|
|
||||||
|
def xmlroot(root, key, data):
|
||||||
|
if isinstance(data, list) or \
|
||||||
|
isinstance(data, tuple):
|
||||||
|
e = ET.SubElement(root, key.split(':')[0])
|
||||||
|
for value in data:
|
||||||
|
xmlroot(e, key, value)
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
for k in data:
|
||||||
|
if k.startswith('_'):
|
||||||
|
root.attrib[k[1:]] = data[k]
|
||||||
|
xmlroot(root, k, data[k])
|
||||||
|
else:
|
||||||
|
if key.startswith('_'):
|
||||||
|
root.attrib[key[1:]] = unicode(data)
|
||||||
|
else:
|
||||||
|
e = ET.SubElement(root, key.split(':')[0])
|
||||||
|
if isinstance(data, bool):
|
||||||
|
e.text = 'TRUE' if data else 'FALSE'
|
||||||
|
else:
|
||||||
|
e.text = unicode(data)
|
||||||
|
|
||||||
|
class Project(object):
|
||||||
|
duration = 0
|
||||||
|
timebase = 30
|
||||||
|
ntsc = True
|
||||||
|
width = 1920
|
||||||
|
height = 1080
|
||||||
|
colordepth = 24
|
||||||
|
samplerate = 48000
|
||||||
|
audiodepth = 16
|
||||||
|
|
||||||
|
_clips = {
|
||||||
|
'video1': [],
|
||||||
|
'audio1': [],
|
||||||
|
'audio2': [],
|
||||||
|
}
|
||||||
|
files = {}
|
||||||
|
_files = []
|
||||||
|
clip_n = 1
|
||||||
|
|
||||||
|
def add_clip(self, clip, position):
|
||||||
|
clip_in = self.frames(clip['in'])
|
||||||
|
clip_out = self.frames(clip['out'])
|
||||||
|
duration = clip_out - clip_in
|
||||||
|
f, name = self.get_file(clip)
|
||||||
|
for track, type in ((1, 'video'), (1, 'audio'), (2, 'audio')):
|
||||||
|
self._clips['%s%s' % (type, track)].append({
|
||||||
|
'_id': 'clip%s_%s_%s' % (self.clip_n, type, track),
|
||||||
|
'name': name,
|
||||||
|
'duration': int(clip['durations'][0] * self.timebase),
|
||||||
|
'rate': [{
|
||||||
|
'ntsc': self.ntsc,
|
||||||
|
'timebase': self.timebase
|
||||||
|
}],
|
||||||
|
#clip in/out
|
||||||
|
'in': clip_in,
|
||||||
|
'out': clip_out,
|
||||||
|
#timeline position
|
||||||
|
'start': position,
|
||||||
|
'end': position + duration,
|
||||||
|
'file': [f],
|
||||||
|
'sourcetrack': [{
|
||||||
|
'mediatype': type,
|
||||||
|
'trackindex': track
|
||||||
|
}],
|
||||||
|
'comments': [{
|
||||||
|
'mastercomment1': clip['url'],
|
||||||
|
'clipcommenta': clip['url'],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.clip_n += 1
|
||||||
|
return position + duration
|
||||||
|
|
||||||
|
def frames(self, seconds):
|
||||||
|
return int((30000.0/1001 if self.ntsc else self.timebase) * seconds)
|
||||||
|
|
||||||
|
def get_file(self, clip):
|
||||||
|
path = clip['path']
|
||||||
|
if path in self.files:
|
||||||
|
f = {'_id': self.files[path]['_id']}
|
||||||
|
name = self.files[path]['name']
|
||||||
|
else:
|
||||||
|
info = {
|
||||||
|
'width': clip['width'],
|
||||||
|
'height': clip['height'],
|
||||||
|
'channels': clip['channels'],
|
||||||
|
'samplerate': clip['samplerate'],
|
||||||
|
'duration': self.frames(clip['file_duration']),
|
||||||
|
}
|
||||||
|
|
||||||
|
name = os.path.splitext(path.split('/')[-1])[0]
|
||||||
|
pathurl = '%s%s' % (self.base, quote(path))
|
||||||
|
f = self.files[path] = {
|
||||||
|
'_id': name.replace('.', '_') + '1',
|
||||||
|
'name': name,
|
||||||
|
'pathurl': pathurl,
|
||||||
|
'rate': [{
|
||||||
|
'timebase': self.timebase,
|
||||||
|
'ntsc': self.ntsc,
|
||||||
|
}],
|
||||||
|
'duration': info['duration'],
|
||||||
|
'media': [{
|
||||||
|
'video': [{
|
||||||
|
'duration': info['duration'],
|
||||||
|
'samplecharacteristics': [{
|
||||||
|
'width': info['width'],
|
||||||
|
'height': info['height'],
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
'audio': [{
|
||||||
|
'samplecharacteristics': [{
|
||||||
|
'samplerate': info['samplerate'],
|
||||||
|
'depth': 16
|
||||||
|
}],
|
||||||
|
'channelcount': info['channels']
|
||||||
|
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
self._files.append({
|
||||||
|
'_id': name.replace('.', '_'),
|
||||||
|
'name': name,
|
||||||
|
'duration': info['duration'],
|
||||||
|
'rate': [{
|
||||||
|
'timebase': self.timebase,
|
||||||
|
'ntsc': self.ntsc,
|
||||||
|
}],
|
||||||
|
'file': [f]
|
||||||
|
})
|
||||||
|
return f, name
|
||||||
|
|
||||||
|
def __init__(self, clips, base):
|
||||||
|
self.uuid = str(uuid.uuid1()).upper()
|
||||||
|
self.clips = clips
|
||||||
|
self.base = 'file://localhost%s' % quote(base)
|
||||||
|
self.duration = 0
|
||||||
|
for clip in self.clips:
|
||||||
|
self.duration = self.add_clip(clip, self.duration)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
xmeml = ET.Element("xmeml", {
|
||||||
|
"version": "5"
|
||||||
|
})
|
||||||
|
name = 'Sequence 1'
|
||||||
|
sequence = ET.SubElement(xmeml, "sequence", {
|
||||||
|
"id": "%s " % name
|
||||||
|
})
|
||||||
|
xmlroot(sequence, 'sequence', {
|
||||||
|
'uuid': self.uuid,
|
||||||
|
'updatebehavior': 'add',
|
||||||
|
'name': name,
|
||||||
|
'duration': self.duration,
|
||||||
|
'rate': [{
|
||||||
|
'ntsc': self.ntsc,
|
||||||
|
'timebase': self.timebase
|
||||||
|
}],
|
||||||
|
'timecode': [{
|
||||||
|
'rate': [{
|
||||||
|
'ntsc': self.ntsc,
|
||||||
|
'timebase': self.timebase
|
||||||
|
}],
|
||||||
|
'string': '01:00:00;00',
|
||||||
|
'frame': self.frames(3600),
|
||||||
|
'source': 'source',
|
||||||
|
'displayformat': 'DF'
|
||||||
|
}],
|
||||||
|
'in': -1,
|
||||||
|
'out': -1,
|
||||||
|
'media': [{
|
||||||
|
'video': [{
|
||||||
|
'format': [{
|
||||||
|
'samplecharacteristics': [{
|
||||||
|
'width': self.width,
|
||||||
|
'height': self.height,
|
||||||
|
'anamorphic': False,
|
||||||
|
'pixelaspectratio': 'Square',
|
||||||
|
'fielddominance': 'none',
|
||||||
|
'rate': [{
|
||||||
|
'ntsc': self.ntsc,
|
||||||
|
'timebase': self.timebase
|
||||||
|
}],
|
||||||
|
'colordepth': self.colordepth,
|
||||||
|
'codec': [{
|
||||||
|
'name': 'Apple ProRes 422',
|
||||||
|
'appspecificdata': [{
|
||||||
|
'appname': 'Final Cut Pro',
|
||||||
|
'appmanufacturer': 'Apple Inc.',
|
||||||
|
'appversion': '7.0',
|
||||||
|
'data': [{
|
||||||
|
'qtcodec': [{
|
||||||
|
'codecname': 'Apple ProRes 422',
|
||||||
|
'codectypename': 'Apple ProRes 422',
|
||||||
|
'codectypecode': 'apcn',
|
||||||
|
'codecvendorcode': 'appl',
|
||||||
|
'spatialquality': 1024,
|
||||||
|
'temporalquality': 0,
|
||||||
|
'keyframerate': 0,
|
||||||
|
'datarate': 0,
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
|
||||||
|
}],
|
||||||
|
'appspecificdata': [{
|
||||||
|
'appname': 'Final Cut Pro',
|
||||||
|
'appmanufacturer': 'Apple Inc.',
|
||||||
|
'appversion': '7.0',
|
||||||
|
'data': [{
|
||||||
|
'fcpimageprocessing': [{
|
||||||
|
'useyuv': True,
|
||||||
|
'usesuperwhite': False,
|
||||||
|
'rendermode': 'Float10BPP',
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
'track': [{'clipitem': [clip]} for clip in self._clips['video1']]
|
||||||
|
}],
|
||||||
|
'audio': [{
|
||||||
|
'format': [{
|
||||||
|
'samplecharacteristics': [{
|
||||||
|
'depth': self.audiodepth,
|
||||||
|
'samplerate': self.samplerate,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
'outputs': [{
|
||||||
|
'group': [{
|
||||||
|
'index': 1,
|
||||||
|
'numchannels': 2,
|
||||||
|
'downmix': 0,
|
||||||
|
'channel:1': {
|
||||||
|
'channel': [{'index': 1}]
|
||||||
|
},
|
||||||
|
'channel:2': {
|
||||||
|
'channel': [{'index': 2}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
'in': -1,
|
||||||
|
'out': -1,
|
||||||
|
'track:1': [{'clipitem': [clip]} for clip in self._clips['audio1']]
|
||||||
|
+ [{
|
||||||
|
'enabled': True,
|
||||||
|
'locked': False,
|
||||||
|
'outputchannelindex': 1,
|
||||||
|
}]
|
||||||
|
,
|
||||||
|
'track:2': [{'clipitem': [clip]} for clip in self._clips['audio2']]
|
||||||
|
+ [{
|
||||||
|
'enabled': True,
|
||||||
|
'locked': False,
|
||||||
|
'outputchannelindex': 2,
|
||||||
|
}]
|
||||||
|
,
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
'ismasterclip': False
|
||||||
|
})
|
||||||
|
b = ET.SubElement(xmeml, "bin")
|
||||||
|
ET.SubElement(b, "name").text = "Resources"
|
||||||
|
children = ET.SubElement(b, "children")
|
||||||
|
for clip in self._files:
|
||||||
|
xmlroot(children, 'clip', [clip])
|
||||||
|
|
||||||
|
return tostring(xmeml, '<!DOCTYPE xmeml>')
|
13
edl/m3u.py
Normal file
13
edl/m3u.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
|
||||||
|
class Project(object):
|
||||||
|
def __init__(self, clips):
|
||||||
|
self.clips = clips
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
m3u = ['#EXTM3U']
|
||||||
|
for clip in self.clips:
|
||||||
|
m3u.append('#EXTINF:%d, %s' % (int(clip['duration']), clip['url']))
|
||||||
|
m3u.append('#EXTVLCOPT:start-time=%0.3f' % clip['in'])
|
||||||
|
m3u.append('#EXTVLCOPT:stop-time=%0.3f' % clip['out'])
|
||||||
|
m3u.append(clip['path'])
|
||||||
|
return '\n'.join(m3u)
|
18
edl/utils.py
Normal file
18
edl/utils.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from ox.utils import ET
|
||||||
|
from lxml import etree
|
||||||
|
from StringIO import StringIO
|
||||||
|
|
||||||
|
def tostring(xml, doctype=None):
|
||||||
|
head = '<?xml version="1.0" encoding="UTF-8" ?>'
|
||||||
|
if doctype:
|
||||||
|
head += '\n' + doctype
|
||||||
|
else:
|
||||||
|
doctype = ''
|
||||||
|
|
||||||
|
x = etree.parse(StringIO(ET.tostring(xml)))
|
||||||
|
return head + '\n' \
|
||||||
|
+ etree.tostring(x, pretty_print=True)
|
||||||
|
|
||||||
|
return head + '\n' \
|
||||||
|
+ ET.tostring(xml)
|
||||||
|
|
170
pandora_cut.py
Executable file
170
pandora_cut.py
Executable file
|
@ -0,0 +1,170 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
from __future__ import division
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from optparse import OptionParser
|
||||||
|
from urlparse import urlparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import ox
|
||||||
|
|
||||||
|
|
||||||
|
base = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
formats = ['fcp', 'fcpx', 'kdenlive', 'm3u', 'pitivi']
|
||||||
|
formats = ['fcp', 'm3u']
|
||||||
|
|
||||||
|
usage = "usage: %prog [options] edit_url > result.xml"
|
||||||
|
parser = OptionParser(usage=usage)
|
||||||
|
parser.add_option('-b', '--base', dest='base',
|
||||||
|
help='base path', default=base, type='string')
|
||||||
|
parser.add_option('-c', '--cache', dest='cache',
|
||||||
|
help='cache api requests', default='', type='string')
|
||||||
|
parser.add_option('-f', '--format', dest='format',
|
||||||
|
help='output format: %s' % (', '.join(formats)), default='fcp', type='string')
|
||||||
|
|
||||||
|
parser.add_option('-p', '--prores', dest='prores',
|
||||||
|
help='use prores proxies', action="store_true")
|
||||||
|
(opts, args) = parser.parse_args()
|
||||||
|
if not args:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if opts.format not in formats:
|
||||||
|
sys.stderr.write('Format "%s" unsupported, please provide supported format: %s\n' % (opts.format, ', '.join(formats)))
|
||||||
|
sys.exit(1)
|
||||||
|
elif opts.format == 'fcp':
|
||||||
|
from edl.fcp import Project
|
||||||
|
elif opts.format == 'fcpx':
|
||||||
|
from edl.fcpx import Project
|
||||||
|
elif opts.format == 'm3u':
|
||||||
|
from edl.m3u import Project
|
||||||
|
elif opts.format == 'pitivi':
|
||||||
|
from edl.pitivi import Project
|
||||||
|
elif opts.format == 'kdenlive':
|
||||||
|
from edl.pitivi import Project
|
||||||
|
|
||||||
|
u = urlparse(args[0])
|
||||||
|
base_url = '%s://%s/' % (u.scheme, u.netloc)
|
||||||
|
api = ox.API('%sapi/'%base_url)
|
||||||
|
|
||||||
|
edit_id = args[0].split('/')[-1].replace('_', ' ')
|
||||||
|
|
||||||
|
prores = opts.prores
|
||||||
|
prores_prefix = 'prores'
|
||||||
|
base = opts.base
|
||||||
|
if not base.endswith('/'):
|
||||||
|
base += '/'
|
||||||
|
|
||||||
|
edit = None
|
||||||
|
files = {}
|
||||||
|
|
||||||
|
if opts.cache:
|
||||||
|
files_json = os.path.join(opts.cache, 'files.json')
|
||||||
|
edit_json = os.path.join(opts.cache, 'edit.json')
|
||||||
|
|
||||||
|
if os.path.exists(edit_json):
|
||||||
|
with open(edit_json) as fd:
|
||||||
|
edit = json.load(fd)
|
||||||
|
|
||||||
|
if os.path.exists(files_json):
|
||||||
|
with open(files_json) as fd:
|
||||||
|
files = json.load(fd)
|
||||||
|
|
||||||
|
if not edit:
|
||||||
|
edit = api.getEdit(id=edit_id)['data']
|
||||||
|
if opts.cache:
|
||||||
|
with open(edit_json, 'w') as fd:
|
||||||
|
json.dump(edit, fd, indent=2)
|
||||||
|
|
||||||
|
update = False
|
||||||
|
for c in edit['clips']:
|
||||||
|
item = c['item'] if 'item' in c else c['annotatoin'].split('/')[0]
|
||||||
|
if item not in files:
|
||||||
|
r = api.findMedia({
|
||||||
|
'query': {
|
||||||
|
'conditions': [{
|
||||||
|
'key': 'id', 'value': item, 'operator': '=='
|
||||||
|
}],
|
||||||
|
'operator': '&'
|
||||||
|
},
|
||||||
|
'keys': [
|
||||||
|
'id',
|
||||||
|
"selected", "type", "duration",
|
||||||
|
'instances', 'path', 'resolution',
|
||||||
|
'samplerate', 'channels'
|
||||||
|
],
|
||||||
|
'range': [0, 100]
|
||||||
|
})
|
||||||
|
item_files = r['data']['items']
|
||||||
|
item_files = filter(lambda f: f['type'] == 'video' and f['selected'], item_files)
|
||||||
|
for f in item_files:
|
||||||
|
f['path'] = f['instances'][0]['path']
|
||||||
|
del f['instances']
|
||||||
|
files[item] = item_files
|
||||||
|
update = True
|
||||||
|
|
||||||
|
if update and opts.cache:
|
||||||
|
with open(files_json, 'w') as fd:
|
||||||
|
json.dump(files, fd, indent=2)
|
||||||
|
|
||||||
|
clips = []
|
||||||
|
for clip in edit['clips']:
|
||||||
|
item = clip['item'] if 'item' in clip else clip['annotation'].split('/')[0]
|
||||||
|
position = 0
|
||||||
|
for part, f in enumerate(files[item]):
|
||||||
|
if clip['in'] < position + f['duration']:
|
||||||
|
clip_in = clip['in'] - position
|
||||||
|
clip_out = clip['out'] - position
|
||||||
|
|
||||||
|
c = clip.copy()
|
||||||
|
c['in'] = clip_in
|
||||||
|
c['out'] = clip_out
|
||||||
|
if c['out'] > f['duration']:
|
||||||
|
more_clips = True
|
||||||
|
position += f['duration']
|
||||||
|
clip['in'] = position
|
||||||
|
c['out'] = f['duration']
|
||||||
|
else:
|
||||||
|
more_clips = False
|
||||||
|
c['duration'] = c['out'] - c['in']
|
||||||
|
c['oshash'] = f['id']
|
||||||
|
path = f['path']
|
||||||
|
name = os.path.dirname(path).split('/')[-1]
|
||||||
|
ext = path.split('.')[-1]
|
||||||
|
if prores:
|
||||||
|
#fixme, custom hack
|
||||||
|
# FCP fails to open video with 'invalid video'
|
||||||
|
# error if file has more than one , (comma) in name
|
||||||
|
if name.startswith('001'):
|
||||||
|
name = name.replace(',', '')
|
||||||
|
ext = 'mov'
|
||||||
|
path = os.path.join(prores_prefix, '%s.%s' % (name, ext))
|
||||||
|
|
||||||
|
def format_time(t):
|
||||||
|
t = ox.format_duration(t * 1000)
|
||||||
|
if t.endswith('.000'):
|
||||||
|
t = t[:-4]
|
||||||
|
return t
|
||||||
|
|
||||||
|
c['url'] = '%s%s/editor/%s,%s' % (
|
||||||
|
base_url, item,
|
||||||
|
format_time(c['in']),
|
||||||
|
format_time(c['out'])
|
||||||
|
)
|
||||||
|
c['path'] = path
|
||||||
|
c['width'] = f['resolution'][0]
|
||||||
|
c['file_duration'] = f['duration']
|
||||||
|
c['height'] = f['resolution'][1]
|
||||||
|
c['samplerate'] = f['samplerate']
|
||||||
|
c['channels'] = f.get('channels', 2)
|
||||||
|
clips.append(c)
|
||||||
|
if not more_clips:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
position += f['duration']
|
||||||
|
|
||||||
|
edit = Project(clips, base)
|
||||||
|
edit.width = 1280
|
||||||
|
edit.height = 720
|
||||||
|
print edit
|
Loading…
Reference in a new issue