From 052aeb7ba4e3f63828a906831b27bc9807190b30 Mon Sep 17 00:00:00 2001 From: j <0x006A@0x2620.org> Date: Fri, 3 Sep 2010 15:28:44 +0200 Subject: [PATCH] stream, videosupport --- pandora/archive/models.py | 113 ++++---------- pandora/archive/views.py | 44 +++--- pandora/backend/extract.py | 77 --------- pandora/backend/models.py | 146 +++++++++++++++--- pandora/backend/utils.py | 10 +- pandora/settings.py | 2 +- .../static/js/jquery/jquery.videosupport.js | 18 +++ pandora/templates/index.html | 1 + 8 files changed, 203 insertions(+), 208 deletions(-) delete mode 100644 pandora/backend/extract.py create mode 100644 pandora/static/js/jquery/jquery.videosupport.js diff --git a/pandora/archive/models.py b/pandora/archive/models.py index 050ba0e..d21e313 100644 --- a/pandora/archive/models.py +++ b/pandora/archive/models.py @@ -34,6 +34,10 @@ def parse_decimal(string): d = string.split('/') return Decimal(d[0]) / Decimal(d[1]) +def file_path(f, name): + h = f.oshash + return os.path.join('file', h[:2], h[2:4], h[4:6], h[6:], name) + class File(models.Model): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) @@ -136,11 +140,32 @@ class File(models.Model): r[k] = unicode(self[k]) return r + #upload and data handling + video_available = models.BooleanField(default=False) + video = models.FileField(null=True, blank=True, upload_to=lambda f, x: file_path(f, '%s.webm'%settings.VIDEO_PROFILE)) + data = models.FileField(null=True, blank=True, upload_to=lambda f, x: file_path(f, 'data.raw')) + def contents(self): - if self.contents_set.count() > 0: - return self.contents_set.all()[0].data + if self.data: + return self.data.read() return None + def editable(self, user): + #FIXME: check that user has instance of this file + return True + + def save_chunk(self, chunk, chunk_id=-1): + if not self.video_available: + if not self.video: + self.video.save('%s.webm'%settings.VIDEO_PROFILE, chunk) + else: + f = open(self.video.path, 'a') + #FIXME: should check that chunk_id/offset is right + f.write(chunk.read()) + f.close() + return True + return False + class Volume(models.Model): class Meta: unique_together = ("user", "name") @@ -198,88 +223,4 @@ class Frame(models.Model): def __unicode__(self): return u'%s at %s' % (self.file, self.position) -def stream_path(f): - h = f.file.oshash - return os.path.join('stream', h[:2], h[2:4], h[4:6], h[6:], f.profile) - -class Stream(models.Model): - class Meta: - unique_together = ("file", "profile") - - file = models.ForeignKey(File, related_name='streams') - profile = models.CharField(max_length=255, default='96p.webm') - video = models.FileField(default=None, blank=True, upload_to=lambda f, x: stream_path(f)) - source = models.ForeignKey('Stream', related_name='derivatives', default=None, null=True) - available = models.BooleanField(default=False) - - def __unicode__(self): - return self.video - - def extract_derivates(self): - if settings.VIDEO_H264: - profile = self.profile.replace('.webm', '.mp4') - if Stream.objects.filter(profile=profile, source=self).count() == 0: - derivate = Stream(file=self.file, source=self, profile=profile) - derivate.video.name = self.video.name.replace(self.profile, profile) - derivate.encode() - - for p in settings.VIDEO_DERIVATIVES: - profile = p + '.webm' - target = self.video.path.replace(self.profile, profile) - if Stream.objects.filter(profile=profile, source=self).count() == 0: - derivate = Stream(file=self.file, source=self, profile=profile) - derivate.video.name = self.video.name.replace(self.profile, profile) - derivate.encode() - - if settings.VIDEO_H264: - profile = p + '.mp4' - if Stream.objects.filter(profile=profile, source=self).count() == 0: - derivate = Stream(file=self.file, source=self, profile=profile) - derivate.video.name = self.video.name.replace(self.profile, profile) - derivate.encode() - return True - - def encode(self): - if self.source: - video = self.source.video.path - target = self.video.path - profile = self.profile - info = self.file.info - if extract.stream(video, target, profile, info): - self.available=True - self.save() - - def editable(self, user): - #FIXME: possibly needs user setting for stream - return True - - def save_chunk(self, chunk, chunk_id=-1): - if not self.available: - if not self.video: - self.video.save(self.profile, chunk) - else: - f = open(self.video.path, 'a') - #FIXME: should check that chunk_id/offset is right - f.write(chunk.read()) - f.close() - return True - return False - - def save(self, *args, **kwargs): - if self.available and not self.file.available: - self.file.available = True - self.file.save() - super(Stream, self).save(*args, **kwargs) - -class FileContents(models.Model): - created = models.DateTimeField(auto_now_add=True) - modified = models.DateTimeField(auto_now=True) - file = models.ForeignKey(File, related_name="contents_set") - data = models.TextField(default=u'') - - def save(self, *args, **kwargs): - if self.data and not self.file.available: - self.file.available = True - self.file.save() - super(FileContents, self).save(*args, **kwargs) diff --git a/pandora/archive/views.py b/pandora/archive/views.py index c063db5..da4c798 100644 --- a/pandora/archive/views.py +++ b/pandora/archive/views.py @@ -183,10 +183,8 @@ def api_upload(request): else: response = json_response(status=403, text='permissino denied') if 'file' in request.FILES: - if f.contents.count() == 0: - contents = models.FileContents(file=f) - contents.data = request.FILES['file'].read() - contents.save() + if not f.data: + f.data.save('data.raw', request.FILES['file']) response = json_response({}) else: response = json_response(status=403, text='permissino denied') @@ -203,44 +201,42 @@ def firefogg_upload(request): oshash = request.GET['oshash'] #handle video upload if request.method == 'POST': - #init upload #post next chunk if 'chunk' in request.FILES and oshash: - stream = get_object_or_404(models.Stream, file__oshash=oshash, profile=profile) - + f = get_object_or_404(models.File, oshash=oshash) form = VideoChunkForm(request.POST, request.FILES) - if form.is_valid() and stream.editable(request.user): + if form.is_valid() and profile == settings.VIDEO_PROFILE and f.editable(request.user): c = form.cleaned_data['chunk'] chunk_id = form.cleaned_data['chunkId'] response = { 'result': 1, 'resultUrl': request.build_absolute_uri('/') } - if not stream.save_chunk(c, chunk_id): + if not f.save_chunk(c, chunk_id): response['result'] = -1 elif form.cleaned_data['done']: #FIXME: send message to encode deamon to create derivates instead - stream.available = True - stream.save() + f.available = True + f.save() response['result'] = 1 response['done'] = 1 return render_to_json_response(response) - #FIXME: check for valid profile - elif oshash: + #init upload + elif oshash and profile == settings.VIDEO_PROFILE: #404 if oshash is not know, files must be registered via update api first f = get_object_or_404(models.File, oshash=oshash) - stream, created = models.Stream.objects.get_or_create(file=f, profile=profile) - if stream.video: #FIXME: check permission here instead of just starting over - stream.video.delete() - stream.available = False - stream.save() - response = { - #is it possible to no hardcode url here? - 'uploadUrl': request.build_absolute_uri('/api/upload/?oshash=%s&profile=%s' % (f.oshash, profile)), - 'result': 1 - } - return render_to_json_response(response) + if f.editable(request.user): + if f.video: + f.video.delete() + f.video_available = False + f.save() + response = { + #is it possible to no hardcode url here? + 'uploadUrl': request.build_absolute_uri('/api/upload/?oshash=%s&profile=%s' % (f.oshash, profile)), + 'result': 1 + } + return render_to_json_response(response) response = json_response(status=400, text='this request requires POST') return render_to_json_response(response) diff --git a/pandora/backend/extract.py b/pandora/backend/extract.py deleted file mode 100644 index 58f6fd9..0000000 --- a/pandora/backend/extract.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf-8 -*- -# vi:si:et:sw=4:sts=4:ts=4 -# GPL 2010 -from __future__ import division -import re -import os -from os.path import abspath, join, dirname, exists -import shutil -import time -import warnings -import subprocess - -import ox -import Image -import simplejson as json - - -img_extension='jpg' - -def run_command(cmd, timeout=10): - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - while timeout > 0: - time.sleep(0.2) - timeout -= 0.2 - if p.poll() != None: - return p.returncode - if p.poll() == None: - os.kill(p.pid, 9) - killedpid, stat = os.waitpid(p.pid, os.WNOHANG) - return p.returncode - -def frame(videoFile, position, baseFolder, width=128, redo=False): - ''' - params: - videoFile - position as float in seconds - baseFolder to write frames to - width of frame - redo boolean to extract file even if it exists - ''' - def frame_path(size): - return os.path.join(baseFolder, "%s.%s.%s" % (ox.ms2time(position*1000), size, img_extension)) - - #not using input file, to slow to extract frame right now - base_size = 320 - frame = frame_path(base_size) - - if exists(videoFile): - if redo or not exists(frame): - if not exists(baseFolder): - os.makedirs(baseFolder) - cmd = ['oggThumb', '-t', str(position), '-n', frame, '-s', '%dx0'%base_size, videoFile] - run_command(cmd) - if width != base_size: - frame_base = frame - frame = frame_path(width) - if not exists(frame): - resize_image(frame_base, frame, width) - return frame - -def resize_image(image_source, image_output, width): - if exists(image_source): - source = Image.open(image_source) - source_width = source.size[0] - source_height = source.size[1] - - height = int(width / (float(source_width) / source_height)) - height = height - height % 2 - - if width < source_width: - resize_method = Image.ANTIALIAS - else: - resize_method = Image.BICUBIC - output = source.resize((width, height), resize_method) - output.save(image_output) - - diff --git a/pandora/backend/models.py b/pandora/backend/models.py index 1a06dce..4a1ef52 100644 --- a/pandora/backend/models.py +++ b/pandora/backend/models.py @@ -4,8 +4,10 @@ from __future__ import division, with_statement from datetime import datetime import os.path +import math import random import re +import subprocess from django.db import models from django.db.models import Q @@ -23,7 +25,7 @@ from firefogg import Firefogg import managers import load import utils -import extract +from archive import extract def getMovie(info): @@ -204,14 +206,28 @@ class Movie(models.Model): movie[pub_key] = value() else: movie[pub_key] = value - movie['poster'] = self.get_poster() - + if not fields: + movie['poster'] = self.get_poster() + movie['stream'] = self.get_stream() if fields: for f in fields: if f.endswith('.length') and f[:-7] in ('cast', 'genre', 'trivia'): movie[f] = getattr(self.sort, f[:-7]) return movie + def get_stream(self): + stream = {} + if self.streams.all().count(): + s = self.streams.all()[0] + if s.video and s.info: + stream['duration'] = s.info['duration'] + if 'video' in s.info and s.info['video']: + stream['aspectRatio'] = s.info['video'][0]['width'] / s.info['video'][0]['height'] + + stream['baseUrl'] = os.path.dirname(s.video.url) + stream['profiles'] = list(set(map(lambda s: int(os.path.splitext(s['profile'])[0][:-1]), self.streams.all().values('profile')))) + return stream + def fields(self): fields = {} for f in self._meta.fields: @@ -227,13 +243,6 @@ class Movie(models.Model): self.get('series title', ''), self.get('episode title', ''), self.get('season', ''), self.get('episode', '')) - def streams(self): - streams = [] - for f in self.files.filter(is_main=True, available=True): - for s in f.streams.all(): - streams.append(s.video.url) - return streams - def frame(self, position, width=128): #FIXME: compute offset and so on f = self.files.all()[0] @@ -292,15 +301,7 @@ class Movie(models.Model): #title title = canonicalTitle(self.get('title')) - title = re.sub(u'[\'!¿¡,\.;\-"\:\*\[\]]', '', title) - title = title.replace(u'Æ', 'Ae') - #pad numbered titles - numbers = re.compile('^(\d+)').findall(title) - if numbers: - padded = '%010d' % int(numbers[0]) - title = re.sub('^(\d+)', padded, title) - - s.title = title.strip() + s.title = utils.sort_title(title) s.country = ','.join(self.get('countries', [])) s.year = self.get('year', '') @@ -373,6 +374,55 @@ class Movie(models.Model): else: Facet.objects.filter(movie=self, key='year').delete() + def updatePoster(self): + n = self.files.count() * 3 + frame = int(math.floor(n/2)) + part = 1 + for f in self.files.filter(is_main=True, video_available=True): + for frame in f.frames.all(): + path = os.path.abspath(os.path.join(settings.MEDIA_ROOT, poster_path(self))) + path = path.replace('.jpg', '%s.%s.jpg'%(part, frame.pos)) + cmd = ['oxposter', + '-t', self.get('title'), + '-d', self.get('director'), + '-f', frame.frame.path, + '-p', path + ] + if len(self.movieId) == 7: + cmd += ['-i', self.movieId] + else: + cmd += ['-o', self.movieId] + print cmd + subprocess.Popen(cmd) + part += 1 + + def updateStreams(self): + files = {} + for f in self.files.filter(is_main=True, video_available=True): + files[utils.sort_title(f.name)] = f.video.path + + if files: + stream, created = Stream.objects.get_or_create(movie=self, profile='%s.webm' % settings.VIDEO_PROFILE) + stream.video.name = stream_path(stream) + cmd = [] + + for f in sorted(files): + if not cmd: + cmd.append(files[f]) + else: + cmd.append('+') + cmd.append(files[f]) + cmd = [ 'mkvmerge', '-o', stream.video.path ] + cmd + subprocess.Popen(cmd) + stream.save() + + extract.timeline(stream.video.path, os.path.join(stream.video.path[:-len(stream.profile)], 'timeline')) + stream.extract_derivatives() + + #something with poster + self.available = True + self.save() + class MovieFind(models.Model): """ used to search movies, all search values are in here @@ -638,3 +688,61 @@ class Collection(models.Model): def editable(self, user): return self.users.filter(id=user.id).count() > 0 + +def stream_path(f): + h = f.movie.movieId + return os.path.join('stream', h[:2], h[2:4], h[4:6], h[6:], f.profile) + +class Stream(models.Model): + class Meta: + unique_together = ("movie", "profile") + + movie = models.ForeignKey(Movie, related_name='streams') + profile = models.CharField(max_length=255, default='96p.webm') + video = models.FileField(default=None, blank=True, upload_to=lambda f, x: stream_path(f)) + source = models.ForeignKey('Stream', related_name='derivatives', default=None, null=True) + available = models.BooleanField(default=False) + info = fields.DictField(default={}) + + #def __unicode__(self): + # return self.video + + def extract_derivatives(self): + if settings.VIDEO_H264: + profile = self.profile.replace('.webm', '.mp4') + if Stream.objects.filter(profile=profile, source=self).count() == 0: + derivative = Stream(movie=self.movie, source=self, profile=profile) + derivative.video.name = self.video.name.replace(self.profile, profile) + derivative.encode() + + for p in settings.VIDEO_DERIVATIVES: + profile = p + '.webm' + target = self.video.path.replace(self.profile, profile) + if Stream.objects.filter(profile=profile, source=self).count() == 0: + derivative = Stream(movie=movie.file, source=self, profile=profile) + derivative.video.name = self.video.name.replace(self.profile, profile) + derivative.encode() + + if settings.VIDEO_H264: + profile = p + '.mp4' + if Stream.objects.filter(profile=profile, source=self).count() == 0: + derivative = Stream(movie=self.movie, source=self, profile=profile) + derivative.video.name = self.video.name.replace(self.profile, profile) + derivative.encode() + return True + + def encode(self): + if self.source: + video = self.source.video.path + target = self.video.path + profile = self.profile + info = ox.avinfo(video) + if extract.stream(video, target, profile, info): + self.available=True + self.save() + + def save(self, *args, **kwargs): + if self.video and not self.info: + self.info = ox.avinfo(self.video.path) + super(Stream, self).save(*args, **kwargs) + diff --git a/pandora/backend/utils.py b/pandora/backend/utils.py index 5a55af3..a289dee 100644 --- a/pandora/backend/utils.py +++ b/pandora/backend/utils.py @@ -10,7 +10,7 @@ import hashlib import ox import ox.iso -from ox.normalize import normalizeName, normalizeTitle +from ox.normalize import normalizeName, normalizeTitle, canonicalTitle def plural_key(term): @@ -149,3 +149,11 @@ def parse_path(path): season=r['season'], episode=r['episode']) return r +def sort_title(title): + #title + title = re.sub(u'[\'!¿¡,\.;\-"\:\*\[\]]', '', title) + title = title.replace(u'Æ', 'Ae') + #pad numbered titles + title = re.sub('(\d+)', lambda x: '%010d' % int(x.group(0)), title) + return title.strip() + diff --git a/pandora/settings.py b/pandora/settings.py index c8b5af7..abaa3a2 100644 --- a/pandora/settings.py +++ b/pandora/settings.py @@ -69,7 +69,7 @@ TESTS_ROOT = join(PROJECT_ROOT, 'tests') # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash if there is a path component (optional in other cases). # Examples: "http://media.lawrence.com", "http://example.com/media/" -MEDIA_URL = '' +MEDIA_URL = '/media/' STATIC_URL = '/static/' diff --git a/pandora/static/js/jquery/jquery.videosupport.js b/pandora/static/js/jquery/jquery.videosupport.js new file mode 100644 index 0000000..ae999c3 --- /dev/null +++ b/pandora/static/js/jquery/jquery.videosupport.js @@ -0,0 +1,18 @@ +jQuery.support.video = function() { + jQuery.browser.chrome = /chrome/.test(navigator.userAgent.toLowerCase()); + var video = {}; + var v = document.createElement('video'); + if (v) { + video.support = true; + video.webm = !!(v.canPlayType && v.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, '')); + //Disable WebM on Safari/Perian, seeking does not work + if(video.webm && jQuery.browser.safari && !jQuery.browser.chrome) + video.webm = false; + video.h264 = !!(v.canPlayType && v.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, '')); + video.ogg = !!(v.canPlayType && v.canPlayType('video/ogg; codecs="theora, vorbis"').replace(/no/, '')); + } else { + video.support = false; + } + return video; +}(); + diff --git a/pandora/templates/index.html b/pandora/templates/index.html index 423e763..db3e985 100644 --- a/pandora/templates/index.html +++ b/pandora/templates/index.html @@ -6,6 +6,7 @@ +