commit d755d3afc417208535b1fcc5983f8aa03e56ef6a Author: j <0x006A@0x2620.org> Date: Mon Feb 24 18:19:00 2014 +0530 edit2fcp diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..18c545a --- /dev/null +++ b/README.txt @@ -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 + diff --git a/edl/__init__.py b/edl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/edl/fcp.py b/edl/fcp.py new file mode 100644 index 0000000..841b95e --- /dev/null +++ b/edl/fcp.py @@ -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, '') diff --git a/edl/m3u.py b/edl/m3u.py new file mode 100644 index 0000000..e74a111 --- /dev/null +++ b/edl/m3u.py @@ -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) diff --git a/edl/utils.py b/edl/utils.py new file mode 100644 index 0000000..6f73e1b --- /dev/null +++ b/edl/utils.py @@ -0,0 +1,18 @@ +from ox.utils import ET +from lxml import etree +from StringIO import StringIO + +def tostring(xml, doctype=None): + head = '' + 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) + diff --git a/pandora_cut.py b/pandora_cut.py new file mode 100755 index 0000000..24273be --- /dev/null +++ b/pandora_cut.py @@ -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