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