This commit is contained in:
j 2014-02-24 18:19:00 +05:30
commit d755d3afc4
6 changed files with 506 additions and 0 deletions

32
README.txt Normal file
View 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
View file

273
edl/fcp.py Normal file
View 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
View 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
View 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
View 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