diff options
39 files changed, 2407 insertions, 405 deletions
diff --git a/docs/source/media-types.rst b/docs/source/media-types.rst index ec068422..647f2b42 100644 --- a/docs/source/media-types.rst +++ b/docs/source/media-types.rst @@ -30,7 +30,7 @@ To enable video, first install gstreamer and the python-gstreamer bindings (as well as whatever gstremaer extensions you want, good/bad/ugly). On Debianoid systems:: - sudo apt-get install python-gst0.10 + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-{base,bad,good,ugly} gstreamer0.10-ffmpeg Next, modify (and possibly copy over from ``mediagoblin.ini``) your ``mediagoblin_local.ini``. Uncomment this line in the ``[mediagoblin]`` diff --git a/extlib/freesound/audioprocessing.py b/extlib/freesound/audioprocessing.py new file mode 100644 index 00000000..c1dfe2eb --- /dev/null +++ b/extlib/freesound/audioprocessing.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python +# processing.py -- various audio processing functions +# Copyright (C) 2008 MUSIC TECHNOLOGY GROUP (MTG) +# UNIVERSITAT POMPEU FABRA +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Authors: +# Bram de Jong <bram.dejong at domain.com where domain in gmail> +# 2012, Joar Wandborg <first name at last name dot se> + +import Image, ImageDraw, ImageColor #@UnresolvedImport +from functools import partial +import math +import numpy +import os +import re +import signal + + +def get_sound_type(input_filename): + sound_type = os.path.splitext(input_filename.lower())[1].strip(".") + + if sound_type == "fla": + sound_type = "flac" + elif sound_type == "aif": + sound_type = "aiff" + + return sound_type + + +try: + import scikits.audiolab as audiolab +except ImportError: + print "WARNING: audiolab is not installed so wav2png will not work" +import subprocess + +class AudioProcessingException(Exception): + pass + +class TestAudioFile(object): + """A class that mimics audiolab.sndfile but generates noise instead of reading + a wave file. Additionally it can be told to have a "broken" header and thus crashing + in the middle of the file. Also useful for testing ultra-short files of 20 samples.""" + def __init__(self, num_frames, has_broken_header=False): + self.seekpoint = 0 + self.nframes = num_frames + self.samplerate = 44100 + self.channels = 1 + self.has_broken_header = has_broken_header + + def seek(self, seekpoint): + self.seekpoint = seekpoint + + def read_frames(self, frames_to_read): + if self.has_broken_header and self.seekpoint + frames_to_read > self.num_frames / 2: + raise RuntimeError() + + num_frames_left = self.num_frames - self.seekpoint + will_read = num_frames_left if num_frames_left < frames_to_read else frames_to_read + self.seekpoint += will_read + return numpy.random.random(will_read)*2 - 1 + + +def get_max_level(filename): + max_value = 0 + buffer_size = 4096 + audio_file = audiolab.Sndfile(filename, 'r') + n_samples_left = audio_file.nframes + + while n_samples_left: + to_read = min(buffer_size, n_samples_left) + + try: + samples = audio_file.read_frames(to_read) + except RuntimeError: + # this can happen with a broken header + break + + # convert to mono by selecting left channel only + if audio_file.channels > 1: + samples = samples[:,0] + + max_value = max(max_value, numpy.abs(samples).max()) + + n_samples_left -= to_read + + audio_file.close() + + return max_value + +class AudioProcessor(object): + """ + The audio processor processes chunks of audio an calculates the spectrac centroid and the peak + samples in that chunk of audio. + """ + def __init__(self, input_filename, fft_size, window_function=numpy.hanning): + max_level = get_max_level(input_filename) + + self.audio_file = audiolab.Sndfile(input_filename, 'r') + self.fft_size = fft_size + self.window = window_function(self.fft_size) + self.spectrum_range = None + self.lower = 100 + self.higher = 22050 + self.lower_log = math.log10(self.lower) + self.higher_log = math.log10(self.higher) + self.clip = lambda val, low, high: min(high, max(low, val)) + + # figure out what the maximum value is for an FFT doing the FFT of a DC signal + fft = numpy.fft.rfft(numpy.ones(fft_size) * self.window) + max_fft = (numpy.abs(fft)).max() + # set the scale to normalized audio and normalized FFT + self.scale = 1.0/max_level/max_fft if max_level > 0 else 1 + + def read(self, start, size, resize_if_less=False): + """ read size samples starting at start, if resize_if_less is True and less than size + samples are read, resize the array to size and fill with zeros """ + + # number of zeros to add to start and end of the buffer + add_to_start = 0 + add_to_end = 0 + + if start < 0: + # the first FFT window starts centered around zero + if size + start <= 0: + return numpy.zeros(size) if resize_if_less else numpy.array([]) + else: + self.audio_file.seek(0) + + add_to_start = -start # remember: start is negative! + to_read = size + start + + if to_read > self.audio_file.nframes: + add_to_end = to_read - self.audio_file.nframes + to_read = self.audio_file.nframes + else: + self.audio_file.seek(start) + + to_read = size + if start + to_read >= self.audio_file.nframes: + to_read = self.audio_file.nframes - start + add_to_end = size - to_read + + try: + samples = self.audio_file.read_frames(to_read) + except RuntimeError: + # this can happen for wave files with broken headers... + return numpy.zeros(size) if resize_if_less else numpy.zeros(2) + + # convert to mono by selecting left channel only + if self.audio_file.channels > 1: + samples = samples[:,0] + + if resize_if_less and (add_to_start > 0 or add_to_end > 0): + if add_to_start > 0: + samples = numpy.concatenate((numpy.zeros(add_to_start), samples), axis=1) + + if add_to_end > 0: + samples = numpy.resize(samples, size) + samples[size - add_to_end:] = 0 + + return samples + + + def spectral_centroid(self, seek_point, spec_range=110.0): + """ starting at seek_point read fft_size samples, and calculate the spectral centroid """ + + samples = self.read(seek_point - self.fft_size/2, self.fft_size, True) + + samples *= self.window + fft = numpy.fft.rfft(samples) + spectrum = self.scale * numpy.abs(fft) # normalized abs(FFT) between 0 and 1 + length = numpy.float64(spectrum.shape[0]) + + # scale the db spectrum from [- spec_range db ... 0 db] > [0..1] + db_spectrum = ((20*(numpy.log10(spectrum + 1e-60))).clip(-spec_range, 0.0) + spec_range)/spec_range + + energy = spectrum.sum() + spectral_centroid = 0 + + if energy > 1e-60: + # calculate the spectral centroid + + if self.spectrum_range == None: + self.spectrum_range = numpy.arange(length) + + spectral_centroid = (spectrum * self.spectrum_range).sum() / (energy * (length - 1)) * self.audio_file.samplerate * 0.5 + + # clip > log10 > scale between 0 and 1 + spectral_centroid = (math.log10(self.clip(spectral_centroid, self.lower, self.higher)) - self.lower_log) / (self.higher_log - self.lower_log) + + return (spectral_centroid, db_spectrum) + + + def peaks(self, start_seek, end_seek): + """ read all samples between start_seek and end_seek, then find the minimum and maximum peak + in that range. Returns that pair in the order they were found. So if min was found first, + it returns (min, max) else the other way around. """ + + # larger blocksizes are faster but take more mem... + # Aha, Watson, a clue, a tradeof! + block_size = 4096 + + max_index = -1 + max_value = -1 + min_index = -1 + min_value = 1 + + if start_seek < 0: + start_seek = 0 + + if end_seek > self.audio_file.nframes: + end_seek = self.audio_file.nframes + + if end_seek <= start_seek: + samples = self.read(start_seek, 1) + return (samples[0], samples[0]) + + if block_size > end_seek - start_seek: + block_size = end_seek - start_seek + + for i in range(start_seek, end_seek, block_size): + samples = self.read(i, block_size) + + local_max_index = numpy.argmax(samples) + local_max_value = samples[local_max_index] + + if local_max_value > max_value: + max_value = local_max_value + max_index = local_max_index + + local_min_index = numpy.argmin(samples) + local_min_value = samples[local_min_index] + + if local_min_value < min_value: + min_value = local_min_value + min_index = local_min_index + + return (min_value, max_value) if min_index < max_index else (max_value, min_value) + + +def interpolate_colors(colors, flat=False, num_colors=256): + """ given a list of colors, create a larger list of colors interpolating + the first one. If flatten is True a list of numers will be returned. If + False, a list of (r,g,b) tuples. num_colors is the number of colors wanted + in the final list """ + + palette = [] + + for i in range(num_colors): + index = (i * (len(colors) - 1))/(num_colors - 1.0) + index_int = int(index) + alpha = index - float(index_int) + + if alpha > 0: + r = (1.0 - alpha) * colors[index_int][0] + alpha * colors[index_int + 1][0] + g = (1.0 - alpha) * colors[index_int][1] + alpha * colors[index_int + 1][1] + b = (1.0 - alpha) * colors[index_int][2] + alpha * colors[index_int + 1][2] + else: + r = (1.0 - alpha) * colors[index_int][0] + g = (1.0 - alpha) * colors[index_int][1] + b = (1.0 - alpha) * colors[index_int][2] + + if flat: + palette.extend((int(r), int(g), int(b))) + else: + palette.append((int(r), int(g), int(b))) + + return palette + + +def desaturate(rgb, amount): + """ + desaturate colors by amount + amount == 0, no change + amount == 1, grey + """ + luminosity = sum(rgb) / 3.0 + desat = lambda color: color - amount * (color - luminosity) + + return tuple(map(int, map(desat, rgb))) + + +class WaveformImage(object): + """ + Given peaks and spectral centroids from the AudioProcessor, this class will construct + a wavefile image which can be saved as PNG. + """ + def __init__(self, image_width, image_height, palette=1): + if image_height % 2 == 0: + raise AudioProcessingException, "Height should be uneven: images look much better at uneven height" + + if palette == 1: + background_color = (0,0,0) + colors = [ + (50,0,200), + (0,220,80), + (255,224,0), + (255,70,0), + ] + elif palette == 2: + background_color = (0,0,0) + colors = [self.color_from_value(value/29.0) for value in range(0,30)] + elif palette == 3: + background_color = (213, 217, 221) + colors = map( partial(desaturate, amount=0.7), [ + (50,0,200), + (0,220,80), + (255,224,0), + ]) + elif palette == 4: + background_color = (213, 217, 221) + colors = map( partial(desaturate, amount=0.8), [self.color_from_value(value/29.0) for value in range(0,30)]) + + self.image = Image.new("RGB", (image_width, image_height), background_color) + + self.image_width = image_width + self.image_height = image_height + + self.draw = ImageDraw.Draw(self.image) + self.previous_x, self.previous_y = None, None + + self.color_lookup = interpolate_colors(colors) + self.pix = self.image.load() + + def color_from_value(self, value): + """ given a value between 0 and 1, return an (r,g,b) tuple """ + + return ImageColor.getrgb("hsl(%d,%d%%,%d%%)" % (int( (1.0 - value) * 360 ), 80, 50)) + + def draw_peaks(self, x, peaks, spectral_centroid): + """ draw 2 peaks at x using the spectral_centroid for color """ + + y1 = self.image_height * 0.5 - peaks[0] * (self.image_height - 4) * 0.5 + y2 = self.image_height * 0.5 - peaks[1] * (self.image_height - 4) * 0.5 + + line_color = self.color_lookup[int(spectral_centroid*255.0)] + + if self.previous_y != None: + self.draw.line([self.previous_x, self.previous_y, x, y1, x, y2], line_color) + else: + self.draw.line([x, y1, x, y2], line_color) + + self.previous_x, self.previous_y = x, y2 + + self.draw_anti_aliased_pixels(x, y1, y2, line_color) + + def draw_anti_aliased_pixels(self, x, y1, y2, color): + """ vertical anti-aliasing at y1 and y2 """ + + y_max = max(y1, y2) + y_max_int = int(y_max) + alpha = y_max - y_max_int + + if alpha > 0.0 and alpha < 1.0 and y_max_int + 1 < self.image_height: + current_pix = self.pix[x, y_max_int + 1] + + r = int((1-alpha)*current_pix[0] + alpha*color[0]) + g = int((1-alpha)*current_pix[1] + alpha*color[1]) + b = int((1-alpha)*current_pix[2] + alpha*color[2]) + + self.pix[x, y_max_int + 1] = (r,g,b) + + y_min = min(y1, y2) + y_min_int = int(y_min) + alpha = 1.0 - (y_min - y_min_int) + + if alpha > 0.0 and alpha < 1.0 and y_min_int - 1 >= 0: + current_pix = self.pix[x, y_min_int - 1] + + r = int((1-alpha)*current_pix[0] + alpha*color[0]) + g = int((1-alpha)*current_pix[1] + alpha*color[1]) + b = int((1-alpha)*current_pix[2] + alpha*color[2]) + + self.pix[x, y_min_int - 1] = (r,g,b) + + def save(self, filename): + # draw a zero "zero" line + a = 25 + for x in range(self.image_width): + self.pix[x, self.image_height/2] = tuple(map(lambda p: p+a, self.pix[x, self.image_height/2])) + + self.image.save(filename) + + +class SpectrogramImage(object): + """ + Given spectra from the AudioProcessor, this class will construct a wavefile image which + can be saved as PNG. + """ + def __init__(self, image_width, image_height, fft_size): + self.image_width = image_width + self.image_height = image_height + self.fft_size = fft_size + + self.image = Image.new("RGBA", (image_height, image_width)) + + colors = [ + (0, 0, 0, 0), + (58/4, 68/4, 65/4, 255), + (80/2, 100/2, 153/2, 255), + (90, 180, 100, 255), + (224, 224, 44, 255), + (255, 60, 30, 255), + (255, 255, 255, 255) + ] + self.palette = interpolate_colors(colors) + + # generate the lookup which translates y-coordinate to fft-bin + self.y_to_bin = [] + f_min = 100.0 + f_max = 22050.0 + y_min = math.log10(f_min) + y_max = math.log10(f_max) + for y in range(self.image_height): + freq = math.pow(10.0, y_min + y / (image_height - 1.0) *(y_max - y_min)) + bin = freq / 22050.0 * (self.fft_size/2 + 1) + + if bin < self.fft_size/2: + alpha = bin - int(bin) + + self.y_to_bin.append((int(bin), alpha * 255)) + + # this is a bit strange, but using image.load()[x,y] = ... is + # a lot slower than using image.putadata and then rotating the image + # so we store all the pixels in an array and then create the image when saving + self.pixels = [] + + def draw_spectrum(self, x, spectrum): + # for all frequencies, draw the pixels + for (index, alpha) in self.y_to_bin: + self.pixels.append( self.palette[int((255.0-alpha) * spectrum[index] + alpha * spectrum[index + 1])] ) + + # if the FFT is too small to fill up the image, fill with black to the top + for y in range(len(self.y_to_bin), self.image_height): #@UnusedVariable + self.pixels.append(self.palette[0]) + + def save(self, filename, quality=80): + assert filename.lower().endswith(".jpg") + self.image.putdata(self.pixels) + self.image.transpose(Image.ROTATE_90).save(filename, quality=quality) + + +def create_wave_images(input_filename, output_filename_w, output_filename_s, image_width, image_height, fft_size, progress_callback=None): + """ + Utility function for creating both wavefile and spectrum images from an audio input file. + """ + processor = AudioProcessor(input_filename, fft_size, numpy.hanning) + samples_per_pixel = processor.audio_file.nframes / float(image_width) + + waveform = WaveformImage(image_width, image_height) + spectrogram = SpectrogramImage(image_width, image_height, fft_size) + + for x in range(image_width): + + if progress_callback and x % (image_width/10) == 0: + progress_callback((x*100)/image_width) + + seek_point = int(x * samples_per_pixel) + next_seek_point = int((x + 1) * samples_per_pixel) + + (spectral_centroid, db_spectrum) = processor.spectral_centroid(seek_point) + peaks = processor.peaks(seek_point, next_seek_point) + + waveform.draw_peaks(x, peaks, spectral_centroid) + spectrogram.draw_spectrum(x, db_spectrum) + + if progress_callback: + progress_callback(100) + + waveform.save(output_filename_w) + spectrogram.save(output_filename_s) + + +class NoSpaceLeftException(Exception): + pass + +def convert_to_pcm(input_filename, output_filename): + """ + converts any audio file type to pcm audio + """ + + if not os.path.exists(input_filename): + raise AudioProcessingException, "file %s does not exist" % input_filename + + sound_type = get_sound_type(input_filename) + + if sound_type == "mp3": + cmd = ["lame", "--decode", input_filename, output_filename] + elif sound_type == "ogg": + cmd = ["oggdec", input_filename, "-o", output_filename] + elif sound_type == "flac": + cmd = ["flac", "-f", "-d", "-s", "-o", output_filename, input_filename] + else: + return False + + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (stdout, stderr) = process.communicate() + + if process.returncode != 0 or not os.path.exists(output_filename): + if "No space left on device" in stderr + " " + stdout: + raise NoSpaceLeftException + raise AudioProcessingException, "failed converting to pcm data:\n" + " ".join(cmd) + "\n" + stderr + "\n" + stdout + + return True + + +def stereofy_and_find_info(stereofy_executble_path, input_filename, output_filename): + """ + converts a pcm wave file to two channel, 16 bit integer + """ + + if not os.path.exists(input_filename): + raise AudioProcessingException, "file %s does not exist" % input_filename + + cmd = [stereofy_executble_path, "--input", input_filename, "--output", output_filename] + + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (stdout, stderr) = process.communicate() + + if process.returncode != 0 or not os.path.exists(output_filename): + if "No space left on device" in stderr + " " + stdout: + raise NoSpaceLeftException + raise AudioProcessingException, "failed calling stereofy data:\n" + " ".join(cmd) + "\n" + stderr + "\n" + stdout + + stdout = (stdout + " " + stderr).replace("\n", " ") + + duration = 0 + m = re.match(r".*#duration (?P<duration>[\d\.]+).*", stdout) + if m != None: + duration = float(m.group("duration")) + + channels = 0 + m = re.match(r".*#channels (?P<channels>\d+).*", stdout) + if m != None: + channels = float(m.group("channels")) + + samplerate = 0 + m = re.match(r".*#samplerate (?P<samplerate>\d+).*", stdout) + if m != None: + samplerate = float(m.group("samplerate")) + + bitdepth = None + m = re.match(r".*#bitdepth (?P<bitdepth>\d+).*", stdout) + if m != None: + bitdepth = float(m.group("bitdepth")) + + bitrate = (os.path.getsize(input_filename) * 8.0) / 1024.0 / duration if duration > 0 else 0 + + return dict(duration=duration, channels=channels, samplerate=samplerate, bitrate=bitrate, bitdepth=bitdepth) + + +def convert_to_mp3(input_filename, output_filename, quality=70): + """ + converts the incoming wave file to a mp3 file + """ + + if not os.path.exists(input_filename): + raise AudioProcessingException, "file %s does not exist" % input_filename + + command = ["lame", "--silent", "--abr", str(quality), input_filename, output_filename] + + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (stdout, stderr) = process.communicate() + + if process.returncode != 0 or not os.path.exists(output_filename): + raise AudioProcessingException, stdout + +def convert_to_ogg(input_filename, output_filename, quality=1): + """ + converts the incoming wave file to n ogg file + """ + + if not os.path.exists(input_filename): + raise AudioProcessingException, "file %s does not exist" % input_filename + + command = ["oggenc", "-q", str(quality), input_filename, "-o", output_filename] + + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (stdout, stderr) = process.communicate() + + if process.returncode != 0 or not os.path.exists(output_filename): + raise AudioProcessingException, stdout + +def convert_using_ffmpeg(input_filename, output_filename): + """ + converts the incoming wave file to stereo pcm using fffmpeg + """ + TIMEOUT = 3 * 60 + def alarm_handler(signum, frame): + raise AudioProcessingException, "timeout while waiting for ffmpeg" + + if not os.path.exists(input_filename): + raise AudioProcessingException, "file %s does not exist" % input_filename + + command = ["ffmpeg", "-y", "-i", input_filename, "-ac","1","-acodec", "pcm_s16le", "-ar", "44100", output_filename] + + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + signal.signal(signal.SIGALRM,alarm_handler) + signal.alarm(TIMEOUT) + (stdout, stderr) = process.communicate() + signal.alarm(0) + if process.returncode != 0 or not os.path.exists(output_filename): + raise AudioProcessingException, stdout diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index 866d799b..e30825de 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -69,11 +69,28 @@ base_url = string(default="/mgoblin_media/") storage_class = string(default="mediagoblin.storage.filestorage:BasicFileStorage") base_dir = string(default="%(here)s/user_dev/media/queue") +[media:medium] +# Dimensions used when creating media display images. +max_width = integer(default=640) +max_height = integer(default=640) + +[media:thumb] +# Dimensions used when creating media thumbnails +# This is unfortunately not implemented in the media +# types yet. You can help! +# TODO: Make plugins follow the media size settings +max_width = integer(default=180) +max_height = integer(default=180) -# Should we keep the original file? [media_type:mediagoblin.media_types.video] +# Should we keep the original file? keep_original = boolean(default=False) +[media_type:mediagoblin.media_types.audio] +# vorbisenc qualiy +quality = float(default=0.3) +create_spectrogram = boolean(default=True) + [beaker.cache] type = string(default="file") diff --git a/mediagoblin/db/sql/convert.py b/mediagoblin/db/sql/convert.py index ebf3037c..97f29bfc 100644 --- a/mediagoblin/db/sql/convert.py +++ b/mediagoblin/db/sql/convert.py @@ -16,14 +16,13 @@ from copy import copy -from mediagoblin.init import setup_global_and_app_config, setup_database -from mediagoblin.db.mongo.util import ObjectId - -from mediagoblin.db.sql.base import Base, Session -from mediagoblin.db.sql.models import (User, MediaEntry, MediaComment, - Tag, MediaTag, MediaFile, MediaAttachmentFile, MigrationData) -from mediagoblin.media_types.image.models import ImageData -from mediagoblin.media_types.video.models import VideoData +from mediagoblin.init import setup_global_and_app_config + +from mediagoblin.db.sql.base import Session +from mediagoblin.db.sql.models_v0 import Base_v0 +from mediagoblin.db.sql.models_v0 import (User, MediaEntry, MediaComment, + Tag, MediaTag, MediaFile, MediaAttachmentFile, MigrationData, + ImageData, VideoData) from mediagoblin.db.sql.open import setup_connection_and_db_from_config as \ sql_connect from mediagoblin.db.mongo.open import setup_connection_and_db_from_config as \ @@ -206,7 +205,7 @@ def run_conversion(config_name): sql_conn, sql_db = sql_connect(app_config) mk_conn, mk_db = mongo_connect(app_config) - Base.metadata.create_all(sql_db.engine) + Base_v0.metadata.create_all(sql_db.engine) convert_users(mk_db) Session.remove() diff --git a/mediagoblin/db/sql/extratypes.py b/mediagoblin/db/sql/extratypes.py index 8e078f14..f2304af0 100644 --- a/mediagoblin/db/sql/extratypes.py +++ b/mediagoblin/db/sql/extratypes.py @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. -from sqlalchemy.types import TypeDecorator, Unicode, VARCHAR +from sqlalchemy.types import TypeDecorator, Unicode, TEXT import json @@ -50,7 +50,7 @@ class PathTupleWithSlashes(TypeDecorator): class JSONEncoded(TypeDecorator): "Represents an immutable structure as a json-encoded string." - impl = VARCHAR + impl = TEXT def process_bind_param(self, value, dialect): if value is not None: diff --git a/mediagoblin/db/sql/models_v0.py b/mediagoblin/db/sql/models_v0.py new file mode 100644 index 00000000..5dd6b38b --- /dev/null +++ b/mediagoblin/db/sql/models_v0.py @@ -0,0 +1,300 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +TODO: indexes on foreignkeys, where useful. +""" + + +import datetime +import sys + +from sqlalchemy import ( + Column, Integer, Unicode, UnicodeText, DateTime, Boolean, ForeignKey, + UniqueConstraint, PrimaryKeyConstraint, SmallInteger, Float) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm.collections import attribute_mapped_collection +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.util import memoized_property + +from mediagoblin.db.sql.extratypes import PathTupleWithSlashes, JSONEncoded +from mediagoblin.db.sql.base import GMGTableBase +from mediagoblin.db.sql.base import Session + + +Base_v0 = declarative_base(cls=GMGTableBase) + + +class User(Base_v0): + """ + TODO: We should consider moving some rarely used fields + into some sort of "shadow" table. + """ + __tablename__ = "core__users" + + id = Column(Integer, primary_key=True) + username = Column(Unicode, nullable=False, unique=True) + email = Column(Unicode, nullable=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + pw_hash = Column(Unicode, nullable=False) + email_verified = Column(Boolean, default=False) + status = Column(Unicode, default=u"needs_email_verification", nullable=False) + verification_key = Column(Unicode) + is_admin = Column(Boolean, default=False, nullable=False) + url = Column(Unicode) + bio = Column(UnicodeText) # ?? + fp_verification_key = Column(Unicode) + fp_token_expire = Column(DateTime) + + ## TODO + # plugin data would be in a separate model + + +class MediaEntry(Base_v0): + """ + TODO: Consider fetching the media_files using join + """ + __tablename__ = "core__media_entries" + + id = Column(Integer, primary_key=True) + uploader = Column(Integer, ForeignKey(User.id), nullable=False, index=True) + title = Column(Unicode, nullable=False) + slug = Column(Unicode) + created = Column(DateTime, nullable=False, default=datetime.datetime.now, + index=True) + description = Column(UnicodeText) # ?? + media_type = Column(Unicode, nullable=False) + state = Column(Unicode, default=u'unprocessed', nullable=False) + # or use sqlalchemy.types.Enum? + license = Column(Unicode) + + fail_error = Column(Unicode) + fail_metadata = Column(JSONEncoded) + + queued_media_file = Column(PathTupleWithSlashes) + + queued_task_id = Column(Unicode) + + __table_args__ = ( + UniqueConstraint('uploader', 'slug'), + {}) + + get_uploader = relationship(User) + + media_files_helper = relationship("MediaFile", + collection_class=attribute_mapped_collection("name"), + cascade="all, delete-orphan" + ) + + attachment_files_helper = relationship("MediaAttachmentFile", + cascade="all, delete-orphan", + order_by="MediaAttachmentFile.created" + ) + + tags_helper = relationship("MediaTag", + cascade="all, delete-orphan" + ) + + def media_data_init(self, **kwargs): + """ + Initialize or update the contents of a media entry's media_data row + """ + session = Session() + + media_data = session.query(self.media_data_table).filter_by( + media_entry=self.id).first() + + # No media data, so actually add a new one + if media_data is None: + media_data = self.media_data_table( + media_entry=self.id, + **kwargs) + session.add(media_data) + # Update old media data + else: + for field, value in kwargs.iteritems(): + setattr(media_data, field, value) + + @memoized_property + def media_data_table(self): + # TODO: memoize this + models_module = self.media_type + '.models' + __import__(models_module) + return sys.modules[models_module].DATA_MODEL + + +class FileKeynames(Base_v0): + """ + keywords for various places. + currently the MediaFile keys + """ + __tablename__ = "core__file_keynames" + id = Column(Integer, primary_key=True) + name = Column(Unicode, unique=True) + + def __repr__(self): + return "<FileKeyname %r: %r>" % (self.id, self.name) + + @classmethod + def find_or_new(cls, name): + t = cls.query.filter_by(name=name).first() + if t is not None: + return t + return cls(name=name) + + +class MediaFile(Base_v0): + """ + TODO: Highly consider moving "name" into a new table. + TODO: Consider preloading said table in software + """ + __tablename__ = "core__mediafiles" + + media_entry = Column( + Integer, ForeignKey(MediaEntry.id), + nullable=False) + name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False) + file_path = Column(PathTupleWithSlashes) + + __table_args__ = ( + PrimaryKeyConstraint('media_entry', 'name_id'), + {}) + + def __repr__(self): + return "<MediaFile %s: %r>" % (self.name, self.file_path) + + name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True) + name = association_proxy('name_helper', 'name', + creator=FileKeynames.find_or_new + ) + + +class MediaAttachmentFile(Base_v0): + __tablename__ = "core__attachment_files" + + id = Column(Integer, primary_key=True) + media_entry = Column( + Integer, ForeignKey(MediaEntry.id), + nullable=False) + name = Column(Unicode, nullable=False) + filepath = Column(PathTupleWithSlashes) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + + +class Tag(Base_v0): + __tablename__ = "core__tags" + + id = Column(Integer, primary_key=True) + slug = Column(Unicode, nullable=False, unique=True) + + def __repr__(self): + return "<Tag %r: %r>" % (self.id, self.slug) + + @classmethod + def find_or_new(cls, slug): + t = cls.query.filter_by(slug=slug).first() + if t is not None: + return t + return cls(slug=slug) + + +class MediaTag(Base_v0): + __tablename__ = "core__media_tags" + + id = Column(Integer, primary_key=True) + media_entry = Column( + Integer, ForeignKey(MediaEntry.id), + nullable=False, index=True) + tag = Column(Integer, ForeignKey(Tag.id), nullable=False, index=True) + name = Column(Unicode) + # created = Column(DateTime, nullable=False, default=datetime.datetime.now) + + __table_args__ = ( + UniqueConstraint('tag', 'media_entry'), + {}) + + tag_helper = relationship(Tag) + slug = association_proxy('tag_helper', 'slug', + creator=Tag.find_or_new + ) + + def __init__(self, name=None, slug=None): + Base_v0.__init__(self) + if name is not None: + self.name = name + if slug is not None: + self.tag_helper = Tag.find_or_new(slug) + + +class MediaComment(Base_v0): + __tablename__ = "core__media_comments" + + id = Column(Integer, primary_key=True) + media_entry = Column( + Integer, ForeignKey(MediaEntry.id), nullable=False, index=True) + author = Column(Integer, ForeignKey(User.id), nullable=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + content = Column(UnicodeText, nullable=False) + + get_author = relationship(User) + + +class ImageData(Base_v0): + __tablename__ = "image__mediadata" + + # The primary key *and* reference to the main media_entry + media_entry = Column(Integer, ForeignKey('core__media_entries.id'), + primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref("image__media_data", cascade="all, delete-orphan")) + + width = Column(Integer) + height = Column(Integer) + exif_all = Column(JSONEncoded) + gps_longitude = Column(Float) + gps_latitude = Column(Float) + gps_altitude = Column(Float) + gps_direction = Column(Float) + + +class VideoData(Base_v0): + __tablename__ = "video__mediadata" + + # The primary key *and* reference to the main media_entry + media_entry = Column(Integer, ForeignKey('core__media_entries.id'), + primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref("video__media_data", cascade="all, delete-orphan")) + + width = Column(SmallInteger) + height = Column(SmallInteger) + + +###################################################### +# Special, migrations-tracking table +# +# Not listed in MODELS because this is special and not +# really migrated, but used for migrations (for now) +###################################################### + +class MigrationData(Base_v0): + __tablename__ = "core__migrations" + + name = Column(Unicode, primary_key=True) + version = Column(Integer, nullable=False, default=0) + +###################################################### diff --git a/mediagoblin/media_types/__init__.py b/mediagoblin/media_types/__init__.py index 5128826b..93d2319f 100644 --- a/mediagoblin/media_types/__init__.py +++ b/mediagoblin/media_types/__init__.py @@ -16,10 +16,13 @@ import os import sys +import logging +import tempfile from mediagoblin import mg_globals from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ +_log = logging.getLogger(__name__) class FileTypeNotSupported(Exception): pass @@ -28,6 +31,35 @@ class InvalidFileType(Exception): pass +def sniff_media(media): + ''' + Iterate through the enabled media types and find those suited + for a certain file. + ''' + + try: + return get_media_type_and_manager(media.filename) + except FileTypeNotSupported: + _log.info('No media handler found by file extension. Doing it the expensive way...') + # Create a temporary file for sniffers suchs as GStreamer-based + # Audio video + media_file = tempfile.NamedTemporaryFile() + media_file.write(media.file.read()) + media.file.seek(0) + + for media_type, manager in get_media_managers(): + _log.info('Sniffing {0}'.format(media_type)) + if manager['sniff_handler'](media_file, media=media): + _log.info('{0} accepts the file'.format(media_type)) + return media_type, manager + else: + _log.debug('{0} did not accept the file'.format(media_type)) + + raise FileTypeNotSupported( + # TODO: Provide information on which file types are supported + _(u'Sorry, I don\'t support that file type :(')) + + def get_media_types(): """ Generator, yields the available media types @@ -42,7 +74,7 @@ def get_media_managers(): ''' for media_type in get_media_types(): __import__(media_type) - + yield media_type, sys.modules[media_type].MEDIA_MANAGER @@ -67,22 +99,22 @@ def get_media_manager(_media_type): def get_media_type_and_manager(filename): ''' - Get the media type and manager based on a filename + Try to find the media type based on the file name, extension + specifically. This is used as a speedup, the sniffing functionality + then falls back on more in-depth bitsniffing of the source file. ''' if filename.find('.') > 0: # Get the file extension ext = os.path.splitext(filename)[1].lower() - else: - raise InvalidFileType( - _(u'Could not extract any file extension from "{filename}"').format( - filename=filename)) - for media_type, manager in get_media_managers(): - # Omit the dot from the extension and match it against - # the media manager - if ext[1:] in manager['accepted_extensions']: - return media_type, manager + for media_type, manager in get_media_managers(): + # Omit the dot from the extension and match it against + # the media manager + if ext[1:] in manager['accepted_extensions']: + return media_type, manager else: - raise FileTypeNotSupported( - # TODO: Provide information on which file types are supported - _(u'Sorry, I don\'t support that file type :(')) + _log.info('File {0} has no file extension, let\'s hope the sniffers get it.'.format( + filename)) + + raise FileTypeNotSupported( + _(u'Sorry, I don\'t support that file type :(')) diff --git a/mediagoblin/media_types/ascii/__init__.py b/mediagoblin/media_types/ascii/__init__.py index 1c8ca562..856d1d7b 100644 --- a/mediagoblin/media_types/ascii/__init__.py +++ b/mediagoblin/media_types/ascii/__init__.py @@ -14,13 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from mediagoblin.media_types.ascii.processing import process_ascii +from mediagoblin.media_types.ascii.processing import process_ascii, \ + sniff_handler MEDIA_MANAGER = { "human_readable": "ASCII", "processor": process_ascii, # alternately a string, # 'mediagoblin.media_types.image.processing'? + "sniff_handler": sniff_handler, "display_template": "mediagoblin/media_displays/ascii.html", "default_thumb": "images/media_thumbs/ascii.jpg", "accepted_extensions": [ diff --git a/mediagoblin/media_types/ascii/asciitoimage.py b/mediagoblin/media_types/ascii/asciitoimage.py index e1c4fb44..3017d2ad 100644 --- a/mediagoblin/media_types/ascii/asciitoimage.py +++ b/mediagoblin/media_types/ascii/asciitoimage.py @@ -23,6 +23,7 @@ import os _log = logging.getLogger(__name__) + class AsciiToImage(object): ''' Converter of ASCII art into image files, preserving whitespace diff --git a/mediagoblin/media_types/ascii/models.py b/mediagoblin/media_types/ascii/models.py index a35e6958..865c216c 100644 --- a/mediagoblin/media_types/ascii/models.py +++ b/mediagoblin/media_types/ascii/models.py @@ -18,8 +18,8 @@ from mediagoblin.db.sql.base import Base from sqlalchemy import ( - Column, Integer, Unicode, UnicodeText, DateTime, Boolean, ForeignKey, - UniqueConstraint) + Column, Integer, ForeignKey) +from sqlalchemy.orm import relationship, backref class AsciiData(Base): @@ -28,6 +28,8 @@ class AsciiData(Base): # The primary key *and* reference to the main media_entry media_entry = Column(Integer, ForeignKey('core__media_entries.id'), primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref("ascii__media_data", cascade="all, delete-orphan")) DATA_MODEL = AsciiData diff --git a/mediagoblin/media_types/ascii/processing.py b/mediagoblin/media_types/ascii/processing.py index 83b5ea33..a2a52e9d 100644 --- a/mediagoblin/media_types/ascii/processing.py +++ b/mediagoblin/media_types/ascii/processing.py @@ -19,11 +19,25 @@ import Image import logging from mediagoblin import mg_globals as mgg -from mediagoblin.processing import create_pub_filepath, THUMB_SIZE +from mediagoblin.processing import create_pub_filepath from mediagoblin.media_types.ascii import asciitoimage _log = logging.getLogger(__name__) +SUPPORTED_EXTENSIONS = ['txt', 'asc', 'nfo'] + + +def sniff_handler(media_file, **kw): + if kw.get('media') is not None: + name, ext = os.path.splitext(kw['media'].filename) + clean_ext = ext[1:].lower() + + if clean_ext in SUPPORTED_EXTENSIONS: + return True + + return False + + def process_ascii(entry): ''' Code to process a txt file @@ -69,7 +83,10 @@ def process_ascii(entry): queued_file.read()) with file(tmp_thumb_filename, 'w') as thumb_file: - thumb.thumbnail(THUMB_SIZE, Image.ANTIALIAS) + thumb.thumbnail( + (mgg.global_config['media:thumb']['max_width'], + mgg.global_config['media:thumb']['max_height']), + Image.ANTIALIAS) thumb.save(thumb_file) _log.debug('Copying local file to public storage') @@ -84,7 +101,6 @@ def process_ascii(entry): as original_file: original_file.write(queued_file.read()) - queued_file.seek(0) # Rewind *again* unicode_filepath = create_pub_filepath(entry, 'ascii-portable.txt') diff --git a/mediagoblin/media_types/audio/__init__.py b/mediagoblin/media_types/audio/__init__.py new file mode 100644 index 00000000..9b33f9e3 --- /dev/null +++ b/mediagoblin/media_types/audio/__init__.py @@ -0,0 +1,25 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from mediagoblin.media_types.audio.processing import process_audio, \ + sniff_handler + +MEDIA_MANAGER = { + 'human_readable': 'Audio', + 'processor': process_audio, + 'sniff_handler': sniff_handler, + 'display_template': 'mediagoblin/media_displays/audio.html', + 'accepted_extensions': ['mp3', 'flac', 'ogg', 'wav', 'm4a']} diff --git a/mediagoblin/media_types/audio/audioprocessing.py b/mediagoblin/media_types/audio/audioprocessing.py new file mode 120000 index 00000000..c5e3c52c --- /dev/null +++ b/mediagoblin/media_types/audio/audioprocessing.py @@ -0,0 +1 @@ +../../../extlib/freesound/audioprocessing.py
\ No newline at end of file diff --git a/mediagoblin/media_types/audio/migrations.py b/mediagoblin/media_types/audio/migrations.py new file mode 100644 index 00000000..f54c23ea --- /dev/null +++ b/mediagoblin/media_types/audio/migrations.py @@ -0,0 +1,17 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +MIGRATIONS = {} diff --git a/mediagoblin/media_types/audio/models.py b/mediagoblin/media_types/audio/models.py new file mode 100644 index 00000000..5f18d2c2 --- /dev/null +++ b/mediagoblin/media_types/audio/models.py @@ -0,0 +1,36 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from mediagoblin.db.sql.base import Base + +from sqlalchemy import ( + Column, Integer, ForeignKey) +from sqlalchemy.orm import relationship, backref + + +class AudioData(Base): + __tablename__ = "audio__mediadata" + + # The primary key *and* reference to the main media_entry + media_entry = Column(Integer, ForeignKey('core__media_entries.id'), + primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref("audio__media_data", cascade="all, delete-orphan")) + + +DATA_MODEL = AudioData +MODELS = [AudioData] diff --git a/mediagoblin/media_types/audio/processing.py b/mediagoblin/media_types/audio/processing.py new file mode 100644 index 00000000..c0ff7bff --- /dev/null +++ b/mediagoblin/media_types/audio/processing.py @@ -0,0 +1,130 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import logging +import tempfile +import os + +from mediagoblin import mg_globals as mgg +from mediagoblin.processing import create_pub_filepath, BadMediaFail + +from mediagoblin.media_types.audio.transcoders import AudioTranscoder, \ + AudioThumbnailer + +_log = logging.getLogger(__name__) + +def sniff_handler(media_file, **kw): + try: + transcoder = AudioTranscoder() + data = transcoder.discover(media_file.name) + except BadMediaFail: + _log.debug('Audio discovery raised BadMediaFail') + return False + + if data.is_audio == True and data.is_video == False: + return True + + return False + +def process_audio(entry): + audio_config = mgg.global_config['media_type:mediagoblin.media_types.audio'] + + workbench = mgg.workbench_manager.create_workbench() + + queued_filepath = entry.queued_media_file + queued_filename = workbench.localized_file( + mgg.queue_store, queued_filepath, + 'source') + + ogg_filepath = create_pub_filepath( + entry, + '{original}.webm'.format( + original=os.path.splitext( + queued_filepath[-1])[0])) + + transcoder = AudioTranscoder() + + with tempfile.NamedTemporaryFile() as ogg_tmp: + + transcoder.transcode( + queued_filename, + ogg_tmp.name, + quality=audio_config['quality']) + + data = transcoder.discover(ogg_tmp.name) + + _log.debug('Saving medium...') + mgg.public_store.get_file(ogg_filepath, 'wb').write( + ogg_tmp.read()) + + entry.media_files['ogg'] = ogg_filepath + + # entry.media_data_init(length=int(data.audiolength)) + + if audio_config['create_spectrogram']: + spectrogram_filepath = create_pub_filepath( + entry, + '{original}-spectrogram.jpg'.format( + original=os.path.splitext( + queued_filepath[-1])[0])) + + with tempfile.NamedTemporaryFile(suffix='.wav') as wav_tmp: + _log.info('Creating WAV source for spectrogram') + transcoder.transcode( + queued_filename, + wav_tmp.name, + mux_string='wavenc') + + thumbnailer = AudioThumbnailer() + + with tempfile.NamedTemporaryFile(suffix='.jpg') as spectrogram_tmp: + thumbnailer.spectrogram( + wav_tmp.name, + spectrogram_tmp.name, + width=mgg.global_config['media:medium']['max_width']) + + _log.debug('Saving spectrogram...') + mgg.public_store.get_file(spectrogram_filepath, 'wb').write( + spectrogram_tmp.read()) + + entry.media_files['spectrogram'] = spectrogram_filepath + + with tempfile.NamedTemporaryFile(suffix='.jpg') as thumb_tmp: + thumbnailer.thumbnail_spectrogram( + spectrogram_tmp.name, + thumb_tmp.name, + (mgg.global_config['media:thumb']['max_width'], + mgg.global_config['media:thumb']['max_height'])) + + thumb_filepath = create_pub_filepath( + entry, + '{original}-thumbnail.jpg'.format( + original=os.path.splitext( + queued_filepath[-1])[0])) + + mgg.public_store.get_file(thumb_filepath, 'wb').write( + thumb_tmp.read()) + + entry.media_files['thumb'] = thumb_filepath + else: + entry.media_files['thumb'] = ['fake', 'thumb', 'path.jpg'] + + mgg.queue_store.delete_file(queued_filepath) + + entry.save() + + # clean up workbench + workbench.destroy_self() diff --git a/mediagoblin/media_types/audio/transcoders.py b/mediagoblin/media_types/audio/transcoders.py new file mode 100644 index 00000000..be80aa0e --- /dev/null +++ b/mediagoblin/media_types/audio/transcoders.py @@ -0,0 +1,237 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import pdb +import logging +import Image + +from mediagoblin.processing import BadMediaFail +from mediagoblin.media_types.audio import audioprocessing + + +_log = logging.getLogger(__name__) + +CPU_COUNT = 2 # Just assuming for now + +# IMPORT MULTIPROCESSING +try: + import multiprocessing + try: + CPU_COUNT = multiprocessing.cpu_count() + except NotImplementedError: + _log.warning('multiprocessing.cpu_count not implemented!\n' + 'Assuming 2 CPU cores') +except ImportError: + _log.warning('Could not import multiprocessing, assuming 2 CPU cores') + +# IMPORT GOBJECT +try: + import gobject + gobject.threads_init() +except ImportError: + raise Exception('gobject could not be found') + +# IMPORT PYGST +try: + import pygst + + # We won't settle for less. For now, this is an arbitrary limit + # as we have not tested with > 0.10 + pygst.require('0.10') + + import gst + + import gst.extend.discoverer +except ImportError: + raise Exception('gst/pygst >= 0.10 could not be imported') + +import numpy + + +class AudioThumbnailer(object): + def __init__(self): + _log.info('Initializing {0}'.format(self.__class__.__name__)) + + def spectrogram(self, src, dst, **kw): + width = kw['width'] + height = int(kw.get('height', float(width) * 0.3)) + fft_size = kw.get('fft_size', 2048) + callback = kw.get('progress_callback') + + processor = audioprocessing.AudioProcessor( + src, + fft_size, + numpy.hanning) + + samples_per_pixel = processor.audio_file.nframes / float(width) + + spectrogram = audioprocessing.SpectrogramImage(width, height, fft_size) + + for x in range(width): + if callback and x % (width / 10) == 0: + callback((x * 100) / width) + + seek_point = int(x * samples_per_pixel) + + (spectral_centroid, db_spectrum) = processor.spectral_centroid( + seek_point) + + spectrogram.draw_spectrum(x, db_spectrum) + + if callback: + callback(100) + + spectrogram.save(dst) + + def thumbnail_spectrogram(self, src, dst, thumb_size): + ''' + Takes a spectrogram and creates a thumbnail from it + ''' + if not (type(thumb_size) == tuple and len(thumb_size) == 2): + raise Exception('thumb_size argument should be a tuple(width, height)') + + im = Image.open(src) + + im_w, im_h = [float(i) for i in im.size] + th_w, th_h = [float(i) for i in thumb_size] + + wadsworth_position = im_w * 0.3 + + start_x = max(( + wadsworth_position - ((im_h * (th_w / th_h)) / 2.0), + 0.0)) + + stop_x = start_x + (im_h * (th_w / th_h)) + + th = im.crop(( + int(start_x), 0, + int(stop_x), int(im_h))) + + if th.size[0] > th_w or th.size[1] > th_h: + th.thumbnail(thumb_size, Image.ANTIALIAS) + + th.save(dst) + + +class AudioTranscoder(object): + def __init__(self): + _log.info('Initializing {0}'.format(self.__class__.__name__)) + + # Instantiate MainLoop + self._loop = gobject.MainLoop() + self._failed = None + + def discover(self, src): + self._src_path = src + _log.info('Discovering {0}'.format(src)) + self._discovery_path = src + + self._discoverer = gst.extend.discoverer.Discoverer( + self._discovery_path) + self._discoverer.connect('discovered', self.__on_discovered) + self._discoverer.discover() + + self._loop.run() # Run MainLoop + + if self._failed: + raise self._failed + + # Once MainLoop has returned, return discovery data + return getattr(self, '_discovery_data', False) + + def __on_discovered(self, data, is_media): + if not is_media: + self._failed = BadMediaFail() + _log.error('Could not discover {0}'.format(self._src_path)) + self.halt() + + _log.debug('Discovered: {0}'.format(data.__dict__)) + + self._discovery_data = data + + # Gracefully shut down MainLoop + self.halt() + + def transcode(self, src, dst, **kw): + _log.info('Transcoding {0} into {1}'.format(src, dst)) + self._discovery_data = kw.get('data', self.discover(src)) + + self.__on_progress = kw.get('progress_callback') + + quality = kw.get('quality', 0.3) + + mux_string = kw.get( + 'mux_string', + 'vorbisenc quality={0} ! webmmux'.format(quality)) + + # Set up pipeline + self.pipeline = gst.parse_launch( + 'filesrc location="{src}" ! ' + 'decodebin2 ! queue ! audiorate tolerance={tolerance} ! ' + 'audioconvert ! audio/x-raw-float,channels=2 ! ' + '{mux_string} ! ' + 'progressreport silent=true ! ' + 'filesink location="{dst}"'.format( + src=src, + tolerance=80000000, + mux_string=mux_string, + dst=dst)) + + self.bus = self.pipeline.get_bus() + self.bus.add_signal_watch() + self.bus.connect('message', self.__on_bus_message) + + self.pipeline.set_state(gst.STATE_PLAYING) + + self._loop.run() + + def __on_bus_message(self, bus, message): + _log.debug(message) + + if (message.type == gst.MESSAGE_ELEMENT + and message.structure.get_name() == 'progress'): + data = dict(message.structure) + + if self.__on_progress: + self.__on_progress(data) + + _log.info('{0}% done...'.format( + data.get('percent'))) + elif message.type == gst.MESSAGE_EOS: + _log.info('Done') + self.halt() + + def halt(self): + if getattr(self, 'pipeline', False): + self.pipeline.set_state(gst.STATE_NULL) + del self.pipeline + _log.info('Quitting MainLoop gracefully...') + gobject.idle_add(self._loop.quit) + +if __name__ == '__main__': + import sys + logging.basicConfig() + _log.setLevel(logging.INFO) + + #transcoder = AudioTranscoder() + #data = transcoder.discover(sys.argv[1]) + #res = transcoder.transcode(*sys.argv[1:3]) + + thumbnailer = AudioThumbnailer() + + thumbnailer.spectrogram(*sys.argv[1:], width=640) + + pdb.set_trace() diff --git a/mediagoblin/media_types/image/__init__.py b/mediagoblin/media_types/image/__init__.py index 98e0c32a..d4720fab 100644 --- a/mediagoblin/media_types/image/__init__.py +++ b/mediagoblin/media_types/image/__init__.py @@ -14,13 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from mediagoblin.media_types.image.processing import process_image +from mediagoblin.media_types.image.processing import process_image, \ + sniff_handler MEDIA_MANAGER = { "human_readable": "Image", "processor": process_image, # alternately a string, # 'mediagoblin.media_types.image.processing'? + "sniff_handler": sniff_handler, "display_template": "mediagoblin/media_displays/image.html", "default_thumb": "images/media_thumbs/image.jpg", "accepted_extensions": ["jpg", "jpeg", "png", "gif", "tiff"]} diff --git a/mediagoblin/media_types/image/models.py b/mediagoblin/media_types/image/models.py index 5eb20ed4..fc518daa 100644 --- a/mediagoblin/media_types/image/models.py +++ b/mediagoblin/media_types/image/models.py @@ -19,6 +19,7 @@ from mediagoblin.db.sql.base import Base from sqlalchemy import ( Column, Integer, Float, ForeignKey) +from sqlalchemy.orm import relationship, backref from mediagoblin.db.sql.extratypes import JSONEncoded @@ -28,6 +29,9 @@ class ImageData(Base): # The primary key *and* reference to the main media_entry media_entry = Column(Integer, ForeignKey('core__media_entries.id'), primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref("image__media_data", cascade="all, delete-orphan")) + width = Column(Integer) height = Column(Integer) exif_all = Column(JSONEncoded) diff --git a/mediagoblin/media_types/image/processing.py b/mediagoblin/media_types/image/processing.py index a3bf2c20..bbfcd32d 100644 --- a/mediagoblin/media_types/image/processing.py +++ b/mediagoblin/media_types/image/processing.py @@ -16,12 +16,69 @@ import Image import os +import logging from mediagoblin import mg_globals as mgg from mediagoblin.processing import BadMediaFail, \ - create_pub_filepath, THUMB_SIZE, MEDIUM_SIZE + create_pub_filepath, FilenameBuilder from mediagoblin.tools.exif import exif_fix_image_orientation, \ - extract_exif, clean_exif, get_gps_data, get_useful + extract_exif, clean_exif, get_gps_data, get_useful, \ + exif_image_needs_rotation + +_log = logging.getLogger(__name__) + + +def resize_image(entry, filename, new_path, exif_tags, workdir, new_size, + size_limits=(0, 0)): + """Store a resized version of an image and return its pathname. + + Arguments: + entry -- the entry for the image to resize + filename -- the filename of the original image being resized + new_path -- public file path for the new resized image + exif_tags -- EXIF data for the original image + workdir -- directory path for storing converted image files + new_size -- 2-tuple size for the resized image + """ + try: + resized = Image.open(filename) + except IOError: + raise BadMediaFail() + resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation + resized.thumbnail(new_size, Image.ANTIALIAS) + + # Copy the new file to the conversion subdir, then remotely. + tmp_resized_filename = os.path.join(workdir, new_path[-1]) + with file(tmp_resized_filename, 'w') as resized_file: + resized.save(resized_file) + mgg.public_store.copy_local_to_storage(tmp_resized_filename, new_path) + + +SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg'] + + +def sniff_handler(media_file, **kw): + if kw.get('media') is not None: # That's a double negative! + name, ext = os.path.splitext(kw['media'].filename) + clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase + + _log.debug('name: {0}\next: {1}\nlower_ext: {2}'.format( + name, + ext, + clean_ext)) + + if clean_ext in SUPPORTED_FILETYPES: + _log.info('Found file extension in supported filetypes') + return True + else: + _log.debug('Media present, extension not found in {0}'.format( + SUPPORTED_FILETYPES)) + else: + _log.warning('Need additional information (keyword argument \'media\')' + ' to be able to handle sniffing') + + return False + def process_image(entry): """ @@ -32,72 +89,48 @@ def process_image(entry): conversions_subdir = os.path.join( workbench.dir, 'conversions') os.mkdir(conversions_subdir) - queued_filepath = entry.queued_media_file queued_filename = workbench.localized_file( mgg.queue_store, queued_filepath, 'source') - - filename_bits = os.path.splitext(queued_filename) - basename = os.path.split(filename_bits[0])[1] - extension = filename_bits[1].lower() + name_builder = FilenameBuilder(queued_filename) # EXIF extraction exif_tags = extract_exif(queued_filename) gps_data = get_gps_data(exif_tags) - try: - thumb = Image.open(queued_filename) - except IOError: - raise BadMediaFail() - - thumb = exif_fix_image_orientation(thumb, exif_tags) - - thumb.thumbnail(THUMB_SIZE, Image.ANTIALIAS) - - # Copy the thumb to the conversion subdir, then remotely. - thumb_filename = 'thumbnail' + extension - thumb_filepath = create_pub_filepath(entry, thumb_filename) - - tmp_thumb_filename = os.path.join( - conversions_subdir, thumb_filename) - - with file(tmp_thumb_filename, 'w') as thumb_file: - thumb.save(thumb_file) - - mgg.public_store.copy_local_to_storage( - tmp_thumb_filename, thumb_filepath) + # Always create a small thumbnail + thumb_filepath = create_pub_filepath( + entry, name_builder.fill('{basename}.thumbnail{ext}')) + resize_image(entry, queued_filename, thumb_filepath, + exif_tags, conversions_subdir, + (mgg.global_config['media:thumb']['max_width'], + mgg.global_config['media:thumb']['max_height'])) # If the size of the original file exceeds the specified size of a `medium` - # file, a `medium.jpg` files is created and later associated with the media + # file, a `.medium.jpg` files is created and later associated with the media # entry. medium = Image.open(queued_filename) - - # Fix orientation - medium = exif_fix_image_orientation(medium, exif_tags) - - if medium.size[0] > MEDIUM_SIZE[0] or medium.size[1] > MEDIUM_SIZE[1]: - medium.thumbnail(MEDIUM_SIZE, Image.ANTIALIAS) - - medium_filename = 'medium' + extension - medium_filepath = create_pub_filepath(entry, medium_filename) - - tmp_medium_filename = os.path.join( - conversions_subdir, medium_filename) - - with file(tmp_medium_filename, 'w') as medium_file: - medium.save(medium_file) - - mgg.public_store.copy_local_to_storage( - tmp_medium_filename, medium_filepath) + if medium.size[0] > mgg.global_config['media:medium']['max_width'] \ + or medium.size[1] > mgg.global_config['media:medium']['max_height'] \ + or exif_image_needs_rotation(exif_tags): + medium_filepath = create_pub_filepath( + entry, name_builder.fill('{basename}.medium{ext}')) + resize_image( + entry, queued_filename, medium_filepath, + exif_tags, conversions_subdir, + (mgg.global_config['media:medium']['max_width'], + mgg.global_config['media:medium']['max_height'])) + else: + medium_filepath = None # we have to re-read because unlike PIL, not everything reads # things in string representation :) queued_file = file(queued_filename, 'rb') with queued_file: - #create_pub_filepath(entry, queued_filepath[-1]) - original_filepath = create_pub_filepath(entry, basename + extension) + original_filepath = create_pub_filepath( + entry, name_builder.fill('{basename}{ext}')) with mgg.public_store.get_file(original_filepath, 'wb') \ as original_file: @@ -111,7 +144,8 @@ def process_image(entry): media_files_dict = entry.setdefault('media_files', {}) media_files_dict['thumb'] = thumb_filepath media_files_dict['original'] = original_filepath - media_files_dict['medium'] = medium_filepath + if medium_filepath: + media_files_dict['medium'] = medium_filepath # Insert exif data into database exif_all = clean_exif(exif_tags) diff --git a/mediagoblin/media_types/video/__init__.py b/mediagoblin/media_types/video/__init__.py index 579fdc6a..3faa5b9f 100644 --- a/mediagoblin/media_types/video/__init__.py +++ b/mediagoblin/media_types/video/__init__.py @@ -14,16 +14,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from mediagoblin.media_types.video.processing import process_video +from mediagoblin.media_types.video.processing import process_video, \ + sniff_handler MEDIA_MANAGER = { "human_readable": "Video", - "processor": process_video, # alternately a string, - # 'mediagoblin.media_types.image.processing'? + "processor": process_video, # alternately a string, + # 'mediagoblin.media_types.image.processing'? + "sniff_handler": sniff_handler, "display_template": "mediagoblin/media_displays/video.html", "default_thumb": "images/media_thumbs/video.jpg", - # TODO: This list should be autogenerated based on gst plugins "accepted_extensions": [ - "mp4", "mov", "webm", "avi", "3gp", "3gpp", "mkv", "ogv", "ogg", - "m4v"]} + "mp4", "mov", "webm", "avi", "3gp", "3gpp", "mkv", "ogv", "m4v"]} diff --git a/mediagoblin/media_types/video/models.py b/mediagoblin/media_types/video/models.py index cf42b7c8..35ed92bf 100644 --- a/mediagoblin/media_types/video/models.py +++ b/mediagoblin/media_types/video/models.py @@ -19,6 +19,7 @@ from mediagoblin.db.sql.base import Base from sqlalchemy import ( Column, Integer, SmallInteger, ForeignKey) +from sqlalchemy.orm import relationship, backref class VideoData(Base): @@ -27,6 +28,9 @@ class VideoData(Base): # The primary key *and* reference to the main media_entry media_entry = Column(Integer, ForeignKey('core__media_entries.id'), primary_key=True) + get_media_entry = relationship("MediaEntry", + backref=backref("video__media_data", cascade="all, delete-orphan")) + width = Column(SmallInteger) height = Column(SmallInteger) diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index 3a479802..d4b4e983 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -20,7 +20,7 @@ import os from mediagoblin import mg_globals as mgg from mediagoblin.processing import mark_entry_failed, \ - THUMB_SIZE, MEDIUM_SIZE, create_pub_filepath + create_pub_filepath, FilenameBuilder from . import transcoders logging.basicConfig() @@ -29,17 +29,27 @@ _log = logging.getLogger(__name__) _log.setLevel(logging.DEBUG) -def process_video(entry): - """ - Code to process a video +def sniff_handler(media_file, **kw): + transcoder = transcoders.VideoTranscoder() + data = transcoder.discover(media_file.name) + + _log.debug('Discovered: {0}'.format(data)) + + if not data: + _log.error('Could not discover {0}'.format( + kw.get('media'))) + return False + + if data['is_video'] == True: + return True - Much of this code is derived from the arista-transcoder script in - the arista PyPI package and changed to match the needs of - MediaGoblin + return False - This function sets up the arista video encoder in some kind of new thread - and attaches callbacks to that child process, hopefully, the - entry-complete callback will be called when the video is done. + +def process_video(entry): + """ + Process a video entry, transcode the queued media files (originals) and + create a thumbnail for the entry. """ video_config = mgg.global_config['media_type:mediagoblin.media_types.video'] @@ -49,24 +59,21 @@ def process_video(entry): queued_filename = workbench.localized_file( mgg.queue_store, queued_filepath, 'source') + name_builder = FilenameBuilder(queued_filename) medium_filepath = create_pub_filepath( - entry, - '{original}-640p.webm'.format( - original=os.path.splitext( - queued_filepath[-1])[0] # Select the - )) + entry, name_builder.fill('{basename}-640p.webm')) thumbnail_filepath = create_pub_filepath( - entry, 'thumbnail.jpg') - + entry, name_builder.fill('{basename}.thumbnail.jpg')) # Create a temporary file for the video destination tmp_dst = tempfile.NamedTemporaryFile() with tmp_dst: # Transcode queued file to a VP8/vorbis file that fits in a 640x640 square - transcoder = transcoders.VideoTranscoder(queued_filename, tmp_dst.name) + transcoder = transcoders.VideoTranscoder() + transcoder.transcode(queued_filename, tmp_dst.name) # Push transcoded video to public storage _log.debug('Saving medium...') @@ -82,7 +89,7 @@ def process_video(entry): height=transcoder.dst_data.videoheight) # Create a temporary file for the video thumbnail - tmp_thumb = tempfile.NamedTemporaryFile() + tmp_thumb = tempfile.NamedTemporaryFile(suffix='.jpg') with tmp_thumb: # Create a thumbnail.jpg that fits in a 180x180 square diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py index 6137c3bf..74821877 100644 --- a/mediagoblin/media_types/video/transcoders.py +++ b/mediagoblin/media_types/video/transcoders.py @@ -17,47 +17,40 @@ from __future__ import division import os -os.putenv('GST_DEBUG_DUMP_DOT_DIR', '/tmp') - import sys import logging -import pdb import urllib +import multiprocessing +import gobject +import pygst +pygst.require('0.10') +import gst +import struct +import Image + +from gst.extend import discoverer _log = logging.getLogger(__name__) -logging.basicConfig() -_log.setLevel(logging.DEBUG) + +gobject.threads_init() CPU_COUNT = 2 -try: - import multiprocessing - try: - CPU_COUNT = multiprocessing.cpu_count() - except NotImplementedError: - _log.warning('multiprocessing.cpu_count not implemented') - pass -except ImportError: - _log.warning('Could not import multiprocessing, defaulting to 2 CPU cores') - pass try: - import gtk -except: - raise Exception('Could not find pygtk') + CPU_COUNT = multiprocessing.cpu_count() +except NotImplementedError: + _log.warning('multiprocessing.cpu_count not implemented') -try: - import gobject - gobject.threads_init() -except: - raise Exception('gobject could not be found') +os.putenv('GST_DEBUG_DUMP_DOT_DIR', '/tmp') -try: - import pygst - pygst.require('0.10') - import gst - from gst.extend import discoverer -except: - raise Exception('gst/pygst 0.10 could not be found') + +def pixbuf_to_pilbuf(buf): + data = list() + for i in range(0, len(buf), 3): + r, g, b = struct.unpack('BBB', buf[i:i + 3]) + data.append((r, g, b)) + + return data class VideoThumbnailer: @@ -232,21 +225,18 @@ class VideoThumbnailer: width = filters["width"] height = filters["height"] - pixbuf = gtk.gdk.pixbuf_new_from_data( - buff.data, gtk.gdk.COLORSPACE_RGB, False, 8, - width, height, width * 3) + im = Image.new('RGB', (width, height)) - # NOTE: 200x136 is sort of arbitrary. it's larger than what - # the ui uses at the time of this writing. - # new_width, new_height = scaled_size((width, height), (200, 136)) + data = pixbuf_to_pilbuf(buff.data) - #pixbuf = pixbuf.scale_simple( - #new_width, new_height, gtk.gdk.INTERP_BILINEAR) + im.putdata(data) + + im.save(self.dest_path) - pixbuf.save(self.dest_path, 'jpeg') _log.info('Saved thumbnail') - del pixbuf + self.shutdown() + except gst.QueryError: pass return False @@ -270,7 +260,7 @@ class VideoThumbnailer: return 0 try: - return pipeline.query_duration(gst.FORMAT_TIME)[0] + return pipeline.query_duration(gst.FORMAT_TIME)[0] except gst.QueryError: return self._get_duration(pipeline, retries + 1) @@ -320,12 +310,11 @@ class VideoThumbnailer: self.bus.disconnect(self.watch_id) self.bus = None - def __halt_final(self): _log.info('Done') if self.errors: _log.error(','.join(self.errors)) - + self.loop.quit() @@ -341,10 +330,15 @@ class VideoTranscoder: that it was refined afterwards and therefore is done more correctly. ''' - def __init__(self, src, dst, **kwargs): + def __init__(self): _log.info('Initializing VideoTranscoder...') self.loop = gobject.MainLoop() + + def transcode(self, src, dst, **kwargs): + ''' + Transcode a video file into a 'medium'-sized version. + ''' self.source_path = src self.destination_path = dst @@ -358,6 +352,34 @@ class VideoTranscoder: self._setup() self._run() + def discover(self, src): + ''' + Discover properties about a media file + ''' + _log.info('Discovering {0}'.format(src)) + + self.source_path = src + self._setup_discover(discovered_callback=self.__on_discovered) + + self.discoverer.discover() + + self.loop.run() + + if hasattr(self, '_discovered_data'): + return self._discovered_data.__dict__ + else: + return None + + def __on_discovered(self, data, is_media): + _log.debug('Discovered: {0}'.format(data)) + if not is_media: + self.__stop() + raise Exception('Could not discover {0}'.format(self.source_path)) + + self._discovered_data = data + + self.__stop_mainloop() + def _setup(self): self._setup_discover() self._setup_pipeline() @@ -370,12 +392,14 @@ class VideoTranscoder: _log.debug('Initializing MainLoop()') self.loop.run() - def _setup_discover(self): + def _setup_discover(self, **kw): _log.debug('Setting up discoverer') self.discoverer = discoverer.Discoverer(self.source_path) # Connect self.__discovered to the 'discovered' event - self.discoverer.connect('discovered', self.__discovered) + self.discoverer.connect( + 'discovered', + kw.get('discovered_callback', self.__discovered)) def __discovered(self, data, is_media): ''' @@ -422,7 +446,7 @@ class VideoTranscoder: self.ffmpegcolorspace = gst.element_factory_make( 'ffmpegcolorspace', 'ffmpegcolorspace') self.pipeline.add(self.ffmpegcolorspace) - + self.videoscale = gst.element_factory_make('ffvideoscale', 'videoscale') #self.videoscale.set_property('method', 2) # I'm not sure this works #self.videoscale.set_property('add-borders', 0) @@ -516,7 +540,6 @@ class VideoTranscoder: # Setup the message bus and connect _on_message to the pipeline self._setup_bus() - def _on_dynamic_pad(self, dbin, pad, islast): ''' Callback called when ``decodebin2`` has a pad that we can connect to @@ -561,11 +584,11 @@ class VideoTranscoder: t = message.type - if t == gst.MESSAGE_EOS: + if message.type == gst.MESSAGE_EOS: self._discover_dst_and_stop() _log.info('Done') - elif t == gst.MESSAGE_ELEMENT: + elif message.type == gst.MESSAGE_ELEMENT: if message.structure.get_name() == 'progress': data = dict(message.structure) @@ -587,7 +610,6 @@ class VideoTranscoder: self.dst_discoverer.discover() - def __dst_discovered(self, data, is_media): self.dst_data = data @@ -596,8 +618,9 @@ class VideoTranscoder: def __stop(self): _log.debug(self.loop) - # Stop executing the pipeline - self.pipeline.set_state(gst.STATE_NULL) + if hasattr(self, 'pipeline'): + # Stop executing the pipeline + self.pipeline.set_state(gst.STATE_NULL) # This kills the loop, mercifully gobject.idle_add(self.__stop_mainloop) @@ -615,14 +638,15 @@ class VideoTranscoder: if __name__ == '__main__': os.nice(19) + logging.basicConfig() from optparse import OptionParser parser = OptionParser( - usage='%prog [-v] -a [ video | thumbnail ] SRC DEST') + usage='%prog [-v] -a [ video | thumbnail | discover ] SRC [ DEST ]') parser.add_option('-a', '--action', dest='action', - help='One of "video" or "thumbnail"') + help='One of "video", "discover" or "thumbnail"') parser.add_option('-v', dest='verbose', @@ -646,13 +670,17 @@ if __name__ == '__main__': _log.debug(args) - if not len(args) == 2: + if not len(args) == 2 and not options.action == 'discover': parser.print_help() sys.exit() + transcoder = VideoTranscoder() + if options.action == 'thumbnail': VideoThumbnailer(*args) elif options.action == 'video': def cb(data): print('I\'m a callback!') - transcoder = VideoTranscoder(*args, progress_callback=cb) + transcoder.transcode(*args, progress_callback=cb) + elif options.action == 'discover': + print transcoder.discover(*args).__dict__ diff --git a/mediagoblin/processing/__init__.py b/mediagoblin/processing/__init__.py index ecdfa8a9..4a827af4 100644 --- a/mediagoblin/processing/__init__.py +++ b/mediagoblin/processing/__init__.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging +import os from mediagoblin.db.util import atomic_update from mediagoblin import mg_globals as mgg @@ -23,9 +24,6 @@ from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ _log = logging.getLogger(__name__) -THUMB_SIZE = 180, 180 -MEDIUM_SIZE = 640, 640 - def create_pub_filepath(entry, filename): return mgg.public_store.get_unique_filepath( @@ -33,6 +31,37 @@ def create_pub_filepath(entry, filename): unicode(entry._id), filename]) +class FilenameBuilder(object): + """Easily slice and dice filenames. + + Initialize this class with an original file path, then use the fill() + method to create new filenames based on the original. + + """ + MAX_FILENAME_LENGTH = 255 # VFAT's maximum filename length + + def __init__(self, path): + """Initialize a builder from an original file path.""" + self.dirpath, self.basename = os.path.split(path) + self.basename, self.ext = os.path.splitext(self.basename) + self.ext = self.ext.lower() + + def fill(self, fmtstr): + """Build a new filename based on the original. + + The fmtstr argument can include the following: + {basename} -- the original basename, with the extension removed + {ext} -- the original extension, always lowercase + + If necessary, {basename} will be truncated so the filename does not + exceed this class' MAX_FILENAME_LENGTH in length. + + """ + basename_len = (self.MAX_FILENAME_LENGTH - + len(fmtstr.format(basename='', ext=self.ext))) + return fmtstr.format(basename=self.basename[:basename_len], + ext=self.ext) + def mark_entry_failed(entry_id, exc): """ diff --git a/mediagoblin/static/css/audio.css b/mediagoblin/static/css/audio.css new file mode 100644 index 00000000..387278ec --- /dev/null +++ b/mediagoblin/static/css/audio.css @@ -0,0 +1,84 @@ +.audio-spectrogram { + position: relative; +} +.playhead { + position: absolute; + top: 0; + left: 0; + background: rgba(134, 212, 177, 0.3); + border-right: thin solid #ffaa00; + height: 100%; + -webkit-transition: width .1s ease-out; + -moz-transition: width .1s ease-out; + transition: width .1s ease-out; +} +.audio-control-play-pause { + position: absolute; + bottom: 0; + left: 5px; + cursor: pointer; + /* background: rgba(0, 0, 0, 0.7); */ + font-size: 40px; + width: 50px; + text-shadow: 0 0 10px black; +} + .audio-control-play-pause.playing { + color: #b71500; + letter-spacing: -17px; + margin-left: -7px; + } + .audio-control-play-pause.paused { + /* Warning: this means the the play button shows! */ + color: rgb(134, 212, 177); + } + +.buffered-indicators { + position: absolute; + bottom: 0; + left: 0; + height: 2px; +} + .buffered-indicators div { + position: absolute; + height: 2px; + left: 0; + background: rgba(134, 177, 212, 1); + + -webkit-transition: left 1s ease-out; + -moz-transition: left 1s ease-out; + transition: left 1s ease-out; + + -webkit-transition: width 1s ease-out; + -moz-transition: width 1s ease-out; + transition: width 1s ease-out; + + cursor: pointer; + } + +.seekbar { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.audio-currentTime { + position: absolute; + bottom: 0; + right: 0; + background: rgba(0, 0, 0, 0.7); +} + +.audio-volume { + position: absolute; + left: 50px; + bottom: 10px; + opacity: 0.3; + -moz-transition: opacity .1s ease-in-out; + -webkit-transition: opacity .1s ease-in-out; + transition: opacity .1s ease-in-out; +} + .audio-spectrogram:hover .audio-volume { + opacity: 1; + } diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 34be4f16..e4cd91ca 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -26,8 +26,7 @@ } body { - background-color: #111; - background-image: url("../images/background.png"); + background-color: #161616; color: #C3C3C3; padding: 0; margin: 0px; @@ -113,25 +112,71 @@ input, textarea { } header { - width: 98%; - padding: 6px 1% 0; - margin-bottom: 20px; - background-color: #222; + width: 100%; + padding: 0; + margin-bottom: 42px; + background-color: #303030; + border-bottom: 1px solid #252525; } .header_right { + margin: 8px; + display: inline-block; float: right; - margin: 8px 0px 8px 8px; +} + +.header_right ul { + display: none; + position: absolute; + top: 42px; + right: 0px; + background: #252525; + padding: 20px; +} + +.header_right li { + list-style: none; +} + +.dropdown { + display: inline-block; + color: #c3c3c3; + background-color: #424242; + border: 1px solid; + border-color: #464646 #2B2B2B #252525; + border-radius: 4px; + padding: 3px 8px; + font-size: 16px; + text-decoration: none; + font-style: normal; + font-weight: bold; + cursor: pointer; + position: relative; +} + +.dropdown_items { + position: absolute; + right: 0px; + top: 25px; + background-color: #424242; + padding: 10px; + width: 160px; + border-radius: 5px 0 5px 5px; + box-shadow: 0 2px 1px black; +} + +.dropdown_items a { + display: block; } a.logo { color: #fff; font-weight: bold; - margin: 8px 8px 8px 0; } .logo img { vertical-align: middle; + margin: 6px 8px; } .mediagoblin_content { @@ -241,7 +286,7 @@ text-align: center; } .media_sidebar p { - padding-left: 8px; + margin-left: 8px; } /* forms */ @@ -310,18 +355,23 @@ textarea#description, textarea#bio { /* comments */ +.comment_wrapper { + margin-top: 20px; + margin-bottom: 20px; +} + +.comment_wrapper p { + margin-bottom: 2px; +} + .comment_author { - margin-bottom: 40px; padding-top: 4px; font-size: 0.9em; } .comment_content { - margin-bottom: 30px; -} - -.comment_content p { - margin-bottom: 0px; + margin-left: 8px; + margin-top: 8px; } textarea#comment_content { @@ -563,6 +613,15 @@ table.media_panel th { header { text-align: center; } + + .header_right { + margin-right: 2%; + float: none; + } + + a.logo { + margin-left: 2%; + } } @media screen and (max-width: 570px) { diff --git a/mediagoblin/static/js/audio.js b/mediagoblin/static/js/audio.js new file mode 100644 index 00000000..f50908a1 --- /dev/null +++ b/mediagoblin/static/js/audio.js @@ -0,0 +1,229 @@ +/** + * GNU MediaGoblin -- federated, autonomous media hosting + * Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +var audioPlayer = new Object(); + +(function (audioPlayer) { + audioPlayer.init = function (audioElement) { + audioPlayer.audioElement = audioElement; + + console.log(audioElement); + + attachEvents(); + + $(audioElement).hide(); + }; + + function attachEvents () { + audioPlayer.audioElement.addEventListener( + 'durationchange', audioPlayer.durationChange, true); + audioPlayer.audioElement.addEventListener( + 'timeupdate', audioPlayer.timeUpdate, true); + audioPlayer.audioElement.addEventListener( + 'progress', audioPlayer.onProgress, true); + audioPlayer.audioElement.addEventListener( + 'ended', audioPlayer.onEnded, true); + + $(document).ready( function () { + $('.audio-spectrogram').delegate( + '.seekbar', 'click', audioPlayer.onSeek); + $('.audio-spectrogram').delegate( + '.audio-control-play-pause', 'click', audioPlayer.playPause); + $('.audio-spectrogram').delegate( + '.audio-volume', 'change', audioPlayer.onVolumeChange); + $('.audio-media').delegate( + '.audio-spectrogram', 'attachedControls', + audioPlayer.onControlsAttached); + }); + } + + audioPlayer.onVolumeChange = function(e) { + console.log('volume change', e); + audioPlayer.audioElement.volume = e.target.value; + } + + audioPlayer.onControlsAttached = function(e) { + console.log('Controls attached', e); + $('.audio-spectrogram .audio-volume').val( + Math.round(audioPlayer.audioElement.volume, 2)); + } + + audioPlayer.onProgress = function(e) { + /** + * Handler for file download progress + */ + console.log(e); + + var buffered = audioPlayer.audioElement.buffered; + + ranges = new Array(); + + var indicators = $('.audio-spectrogram .buffered-indicators div'); + + for (var i = 0; i < buffered.length; i++) { + if (!(i in indicators)) { + $('<div style="display: none;"></div>') + .appendTo($('.audio-spectrogram .buffered-indicators')) + .fadeIn(500); + indicators = $('.audio-spectrogram .buffered-indicators div'); + } + var posStart = ((buffered.start(i) / audioPlayer.audioElement.duration) + * audioPlayer.imageElement.width()); + var posStop = ((buffered.end(i) / audioPlayer.audioElement.duration) + * audioPlayer.imageElement.width()); + console.log('indicators', indicators); + + var indicator = $(indicators[i]); + + indicator.css('left', posStart); + indicator.css('width', posStop - posStart); + } + + /* + * Clean up unused indicators + */ + if (indicators.length > buffered.length) { + for (var i = buffered.length; i < indicators.length; i++) { + $(indicators[i]).fadeOut(500, function () { + this.remove(); + }); + } + } + }; + + audioPlayer.onSeek = function (e) { + /** + * Callback handler for seek event, which is a .click() event on the + * .seekbar element + */ + console.log('onSeek', e); + + var im = audioPlayer.imageElement; + var pos = (e.offsetX || e.originalEvent.layerX) / im.width(); + + audioPlayer.audioElement.currentTime = pos * audioPlayer.audioElement.duration; + audioPlayer.audioElement.play(); + audioPlayer.setState(audioPlayer.PLAYING); + }; + + audioPlayer.onEnded = function (e) { + audioPlayer.setState(audioPlayer.PAUSED); + } + + audioPlayer.playPause = function (e) { + console.log('playPause', e); + if (audioPlayer.audioElement.paused) { + audioPlayer.audioElement.play(); + audioPlayer.setState(audioPlayer.PLAYING); + } else { + audioPlayer.audioElement.pause(); + audioPlayer.setState(audioPlayer.PAUSED); + } + }; + + audioPlayer.NULL = null; + audioPlayer.PLAYING = 2; + audioPlayer.PAUSED = 4; + + audioPlayer.state = audioPlayer.NULL; + + audioPlayer.setState = function (state) { + if (state == audioPlayer.state) { + return; + } else { + audioPlayer.state = state; + } + + switch (state) { + case audioPlayer.PLAYING: + $('.audio-spectrogram .audio-control-play-pause') + .removeClass('paused').addClass('playing') + .text('▮▮'); + break; + case audioPlayer.PAUSED: + $('.audio-spectrogram .audio-control-play-pause') + .removeClass('playing').addClass('paused') + .text('▶'); + break; + } + }; + + audioPlayer.durationChange = function () { + // ??? + }; + + audioPlayer.timeUpdate = function () { + /** + * Callback handler for the timeupdate event, responsible for + * updating the playhead + */ + var currentTime = audioPlayer.audioElement.currentTime; + var playhead = audioPlayer.imageElement.parent().find('.playhead'); + playhead.css('width', (currentTime / audioPlayer.audioElement.duration) + * audioPlayer.imageElement.width()); + var time = formatTime(currentTime); + var duration = formatTime(audioPlayer.audioElement.duration); + audioPlayer.imageElement.parent() + .find('.audio-currentTime') + .text(time + '/' + duration); + }; + + function formatTime(seconds) { + /** + * Format a time duration in (hh:)?mm:ss manner + */ + var h = Math.floor(seconds / (60 * 60)); + var m = Math.floor((seconds - h * 60 * 60) / 60); + var s = Math.round(seconds - h * 60 * 60 - m * 60); + return '' + (h ? (h < 10 ? '0' + h : h) + ':' : '') + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s); + } + + audioPlayer.formatTime = formatTime; + + audioPlayer.attachToImage = function (imageElement) { + /** + * Attach the player to an image element + */ + console.log(imageElement); + + var im = $(imageElement); + + audioPlayer.imageElement = im; + + $('<div class="playhead"></div>').appendTo(im.parent()); + $('<div class="buffered-indicators"></div>').appendTo(im.parent()); + $('<div class="seekbar"></div>').appendTo(im.parent()); + $('<div class="audio-control-play-pause paused">▶</div>').appendTo(im.parent()); + $('<div class="audio-currentTime">00:00</div>').appendTo(im.parent()); + $('<input placeholder="Range input not supported" class="audio-volume"' + +'type="range" min="0" max="1" step="0.01" />').appendTo(im.parent()); + $('.audio-spectrogram').trigger('attachedControls'); + }; +})(audioPlayer); + +$(document).ready(function () { + if (!$('.audio-media').length) { + return; + } + + console.log('Initializing audio player'); + + audioElements = $('.audio-media .audio-player'); + audioPlayer.init(audioElements[0]); + audioPlayer.attachToImage($('.audio-spectrogram img')[0]); +}); diff --git a/mediagoblin/static/js/geolocation-map.js b/mediagoblin/static/js/geolocation-map.js index a2c62045..de49a37d 100644 --- a/mediagoblin/static/js/geolocation-map.js +++ b/mediagoblin/static/js/geolocation-map.js @@ -17,6 +17,11 @@ */ $(document).ready(function () { + if (!$('#tile-map').length) { + return; + } + console.log('Initializing map'); + var longitude = Number( $('#tile-map #gps-longitude').val()); var latitude = Number( diff --git a/mediagoblin/static/js/header_dropdown.js b/mediagoblin/static/js/header_dropdown.js new file mode 100644 index 00000000..643bafa4 --- /dev/null +++ b/mediagoblin/static/js/header_dropdown.js @@ -0,0 +1,30 @@ +/** + * GNU MediaGoblin -- federated, autonomous media hosting + * Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +$(document).ready(function() { + $(".dropdown_items").hide(); + $(document).mouseup(function(e) { + if($(e.target).is(".dropdown")) { + $(".dropdown_items").toggle(); + } else if($(e.target).is(".dropdown_items")) { + return; + } else { + $(".dropdown_items").hide(); + } + }); +}); diff --git a/mediagoblin/static/js/show_password.js b/mediagoblin/static/js/show_password.js index e42d44ea..b3fbc862 100644 --- a/mediagoblin/static/js/show_password.js +++ b/mediagoblin/static/js/show_password.js @@ -17,6 +17,7 @@ */ $(document).ready(function(){ + //Create a duplicate password field. We could change the input type dynamically, but this angers the IE gods (not just IE6). $("#password").after('<input type="text" value="" name="password_clear" id="password_clear" /><label><input type="checkbox" id="password_boolean" />Show password</label>'); $('#password_clear').hide(); $('#password_boolean').click(function(){ diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index 1df676ab..517fb646 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -20,7 +20,8 @@ from os.path import splitext from cgi import FieldStorage from celery import registry -import urllib,urllib2 +import urllib +import urllib2 import logging _log = logging.getLogger(__name__) @@ -36,7 +37,7 @@ from mediagoblin.submit import forms as submit_forms from mediagoblin.processing import mark_entry_failed from mediagoblin.processing.task import ProcessMedia from mediagoblin.messages import add_message, SUCCESS -from mediagoblin.media_types import get_media_type_and_manager, \ +from mediagoblin.media_types import sniff_media, \ InvalidFileType, FileTypeNotSupported @@ -56,7 +57,11 @@ def submit_start(request): else: try: filename = request.POST['file'].filename - media_type, media_manager = get_media_type_and_manager(filename) + + # Sniff the submitted media to determine which + # media plugin should handle processing + media_type, media_manager = sniff_media( + request.POST['file']) # create entry and save in database entry = request.db.MediaEntry() @@ -131,9 +136,10 @@ def submit_start(request): raise if mg_globals.app_config["push_urls"]: - feed_url=request.urlgen( + feed_url = request.urlgen( 'mediagoblin.user_pages.atom_feed', - qualified=True,user=request.user.username) + qualified=True, + user=request.user.username) hubparameters = { 'hub.mode': 'publish', 'hub.url': feed_url} @@ -160,10 +166,9 @@ def submit_start(request): user=request.user.username) except Exception as e: ''' - This section is intended to catch exceptions raised in + This section is intended to catch exceptions raised in mediagobling.media_types ''' - if isinstance(e, InvalidFileType) or \ isinstance(e, FileTypeNotSupported): submit_form.file.errors.append( diff --git a/mediagoblin/templates/mediagoblin/base.html b/mediagoblin/templates/mediagoblin/base.html index c2d5457d..16882a98 100644 --- a/mediagoblin/templates/mediagoblin/base.html +++ b/mediagoblin/templates/mediagoblin/base.html @@ -28,6 +28,8 @@ <link rel="shortcut icon" href="{{ request.staticdirect('/images/goblin.ico') }}" /> <script src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script> + <script type="text/javascript" + src="{{ request.staticdirect('/js/header_dropdown.js') }}"></script> <!--[if lt IE 9]> <script src="{{ request.staticdirect('/js/extlib/html5shiv.js') }}"></script> <![endif]--> @@ -44,12 +46,6 @@ ><img src="{{ request.staticdirect('/images/logo.png') }}" alt="{% trans %}MediaGoblin logo{% endtrans %}" /></a> {% endblock mediagoblin_logo %} - {% if request.user and request.user.status == 'active' %} - <a class="button_action" - href="{{ request.urlgen('mediagoblin.submit.start') }}"> - {% trans %}Add media{% endtrans %} - </a> - {% endif %} {% block mediagoblin_header_title %}{% endblock %} <div class="header_right"> {% if request.user %} @@ -60,15 +56,22 @@ class="button_action_highlight"> {% trans %}Verify your email!{% endtrans %}</a> {% endif %} - <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', - user= request.user.username) }}"> - {{ request.user.username }}</a> - (<a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a>) + <div class="dropdown"> + {{ request.user.username }} ▾ + <div class="dropdown_items"> + {% if request.user and request.user.status == 'active' %} + <a href="{{ request.urlgen('mediagoblin.submit.start') }}">+ Add media</a> + {% endif %} + <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', user= request.user.username) }}">View your profile</a> + <a class="button_action" href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}Log out{% endtrans %}</a> + </div> + </div> {% else %} <a href="{{ request.urlgen('mediagoblin.auth.login') }}"> {% trans %}Log in{% endtrans %}</a> {% endif %} </div> + <div class="clear"></div> </header> {% endblock %} <div class="container"> diff --git a/mediagoblin/templates/mediagoblin/media_displays/audio.html b/mediagoblin/templates/mediagoblin/media_displays/audio.html new file mode 100644 index 00000000..36bd9d1d --- /dev/null +++ b/mediagoblin/templates/mediagoblin/media_displays/audio.html @@ -0,0 +1,61 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#} + +{% extends 'mediagoblin/user_pages/media.html' %} + +{% block mediagoblin_head %} + {{ super() }} + <link rel="stylesheet" type="text/css" href="{{ request.staticdirect('/css/audio.css') }}" /> + <script type="text/javascript" src="{{ request.staticdirect( + '/js/audio.js') }}"></script> +{% endblock %} + +{% block mediagoblin_media %} + <div class="audio-media"> + {% if 'spectrogram' in media.media_files %} + <div class="audio-spectrogram"> + <img src="{{ request.app.public_store.file_url( + media.media_files.spectrogram) }}" + alt="Spectrogram" /> + </div> + {% endif %} + <audio class="audio-player" controls="controls" + preload="metadata"> + <source src="{{ request.app.public_store.file_url( + media.media_files.ogg) }}" type="video/webm; encoding="vorbis"" /> + <div class="no_html5"> + {%- trans -%}Sorry, this audio will not work because + your web browser does not support HTML5 + audio.{%- endtrans -%}<br/> + {%- trans -%}You can get a modern web browser that + can play the audio at <a href="http://getfirefox.com"> + http://getfirefox.com</a>!{%- endtrans -%} + </div> + </audio> + </div> + {% if 'original' in media.media_files %} + <p> + <a href="{{ request.app.public_store.file_url( + media.media_files['original']) }}"> + {%- trans -%} + Original + {%- endtrans -%} + </a> + </p> + {% endif %} +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index 70a367f9..eadf712e 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -114,8 +114,6 @@ </p> {% endif %} {% if comments %} - <h3> - <div class="right_align"> <a {% if not request.user %} href="{{ request.urlgen('mediagoblin.auth.login') }}" @@ -123,8 +121,6 @@ class="button_action" id="button_addcomment" title="Add a comment"> {% trans %}Add a comment{% endtrans %} </a> - </div> - </h3> {% if request.user %} <form action="{{ request.urlgen('mediagoblin.user_pages.media_post_comment', user= media.get_uploader.username, @@ -147,10 +143,7 @@ {% else %} <div class="comment_wrapper" id="comment-{{ comment._id }}"> {% endif %} - <div class="comment_content"> - {% autoescape False %} - {{ comment.content_html }} - {% endautoescape %} + <div class="comment_author"> <img src="{{ request.staticdirect('/images/icon_comment.png') }}" /> <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', user = comment_author.username) }}"> @@ -162,7 +155,12 @@ user = media.get_uploader.username, media = media.slug_or_id) }}#comment"> {{ comment.created.strftime("%I:%M%p %Y-%m-%d") }} - </a> + </a>: + </div> + <div class="comment_content"> + {% autoescape False %} + {{ comment.content_html }} + {% endautoescape %} </div> </div> {% endfor %} diff --git a/mediagoblin/tests/test_processing.py b/mediagoblin/tests/test_processing.py new file mode 100644 index 00000000..417f91f3 --- /dev/null +++ b/mediagoblin/tests/test_processing.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python + +from nose.tools import assert_equal, assert_true, assert_false + +from mediagoblin import processing + +class TestProcessing(object): + def run_fill(self, input, format, output=None): + builder = processing.FilenameBuilder(input) + result = builder.fill(format) + if output is None: + return result + assert_equal(output, result) + + def test_easy_filename_fill(self): + self.run_fill('/home/user/foo.TXT', '{basename}bar{ext}', 'foobar.txt') + + def test_long_filename_fill(self): + self.run_fill('{0}.png'.format('A' * 300), 'image-{basename}{ext}', + 'image-{0}.png'.format('A' * 245)) diff --git a/mediagoblin/tests/test_submission.py b/mediagoblin/tests/test_submission.py index 9b503f4f..8d1b3745 100644 --- a/mediagoblin/tests/test_submission.py +++ b/mediagoblin/tests/test_submission.py @@ -15,30 +15,35 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import urlparse -import pkg_resources +import os import re from nose.tools import assert_equal, assert_true, assert_false +from pkg_resources import resource_filename -from mediagoblin.tests.tools import setup_fresh_app, get_test_app, \ +from mediagoblin.tests.tools import get_test_app, \ fixture_add_user from mediagoblin import mg_globals -from mediagoblin.tools import template, common - -GOOD_JPG = pkg_resources.resource_filename( - 'mediagoblin.tests', 'test_submission/good.jpg') -GOOD_PNG = pkg_resources.resource_filename( - 'mediagoblin.tests', 'test_submission/good.png') -EVIL_FILE = pkg_resources.resource_filename( - 'mediagoblin.tests', 'test_submission/evil') -EVIL_JPG = pkg_resources.resource_filename( - 'mediagoblin.tests', 'test_submission/evil.jpg') -EVIL_PNG = pkg_resources.resource_filename( - 'mediagoblin.tests', 'test_submission/evil.png') +from mediagoblin.tools import template + + +def resource(filename): + return resource_filename('mediagoblin.tests', 'test_submission/' + filename) + + +GOOD_JPG = resource('good.jpg') +GOOD_PNG = resource('good.png') +EVIL_FILE = resource('evil') +EVIL_JPG = resource('evil.jpg') +EVIL_PNG = resource('evil.png') +BIG_BLUE = resource('bigblue.png') GOOD_TAG_STRING = 'yin,yang' BAD_TAG_STRING = 'rage,' + 'f' * 26 + 'u' * 26 +FORM_CONTEXT = ['mediagoblin/submit/start.html', 'submit_form'] +REQUEST_CONTEXT = ['mediagoblin/user_pages/user.html', 'request'] + class TestSubmission: def setUp(self): @@ -61,85 +66,74 @@ class TestSubmission: def logout(self): self.test_app.get('/auth/logout/') + def do_post(self, data, *context_keys, **kwargs): + url = kwargs.pop('url', '/submit/') + do_follow = kwargs.pop('do_follow', False) + template.clear_test_template_context() + response = self.test_app.post(url, data, **kwargs) + if do_follow: + response.follow() + context_data = template.TEMPLATE_TEST_CONTEXT + for key in context_keys: + context_data = context_data[key] + return response, context_data + + def upload_data(self, filename): + return {'upload_files': [('file', filename)]} + + def check_comments(self, request, media_id, count): + comments = request.db.MediaComment.find({'media_entry': media_id}) + assert_equal(count, len(list(comments))) + def test_missing_fields(self): # Test blank form # --------------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', {}) - context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html'] - form = context['submit_form'] - assert form.file.errors == [u'You must provide a file.'] + response, form = self.do_post({}, *FORM_CONTEXT) + assert_equal(form.file.errors, [u'You must provide a file.']) # Test blank file # --------------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'test title'}) - context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html'] - form = context['submit_form'] - assert form.file.errors == [u'You must provide a file.'] - + response, form = self.do_post({'title': 'test title'}, *FORM_CONTEXT) + assert_equal(form.file.errors, [u'You must provide a file.']) - def test_normal_uploads(self): - # Test JPG - # -------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Normal upload 1' - }, upload_files=[( - 'file', GOOD_JPG)]) - - # User should be redirected - response.follow() - assert_equal( - urlparse.urlsplit(response.location)[2], - '/u/chris/') - assert template.TEMPLATE_TEST_CONTEXT.has_key( - 'mediagoblin/user_pages/user.html') + def check_url(self, response, path): + assert_equal(urlparse.urlsplit(response.location)[2], path) + def check_normal_upload(self, title, filename): + response, context = self.do_post({'title': title}, do_follow=True, + **self.upload_data(filename)) + self.check_url(response, '/u/{0}/'.format(self.test_user.username)) + assert_true('mediagoblin/user_pages/user.html' in context) # Make sure the media view is at least reachable, logged in... - self.test_app.get('/u/chris/m/normal-upload-1/') + url = '/u/{0}/m/{1}/'.format(self.test_user.username, + title.lower().replace(' ', '-')) + self.test_app.get(url) # ... and logged out too. self.logout() - self.test_app.get('/u/chris/m/normal-upload-1/') - # Log back in for the remaining tests. - self.login() + self.test_app.get(url) - # Test PNG - # -------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Normal upload 2' - }, upload_files=[( - 'file', GOOD_PNG)]) + def test_normal_jpg(self): + self.check_normal_upload('Normal upload 1', GOOD_JPG) - response.follow() - assert_equal( - urlparse.urlsplit(response.location)[2], - '/u/chris/') - assert template.TEMPLATE_TEST_CONTEXT.has_key( - 'mediagoblin/user_pages/user.html') + def test_normal_png(self): + self.check_normal_upload('Normal upload 2', GOOD_PNG) + + def check_media(self, request, find_data, count=None): + media = request.db.MediaEntry.find(find_data) + if count is not None: + assert_equal(media.count(), count) + if count == 0: + return + return media[0] def test_tags(self): # Good tag string # -------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Balanced Goblin', - 'tags': GOOD_TAG_STRING - }, upload_files=[( - 'file', GOOD_JPG)]) - - # New media entry with correct tags should be created - response.follow() - context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/user_pages/user.html'] - request = context['request'] - media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0] + response, request = self.do_post({'title': 'Balanced Goblin', + 'tags': GOOD_TAG_STRING}, + *REQUEST_CONTEXT, do_follow=True, + **self.upload_data(GOOD_JPG)) + media = self.check_media(request, {'title': 'Balanced Goblin'}, 1) assert media.tags[0]['name'] == u'yin' assert media.tags[0]['slug'] == u'yin' @@ -148,150 +142,117 @@ class TestSubmission: # Test tags that are too long # --------------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Balanced Goblin', - 'tags': BAD_TAG_STRING - }, upload_files=[( - 'file', GOOD_JPG)]) - - # Too long error should be raised - context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html'] - form = context['submit_form'] - assert form.tags.errors == [ - u'Tags must be shorter than 50 characters. Tags that are too long'\ - ': ffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuu'] + response, form = self.do_post({'title': 'Balanced Goblin', + 'tags': BAD_TAG_STRING}, + *FORM_CONTEXT, + **self.upload_data(GOOD_JPG)) + assert_equal(form.tags.errors, [ + u'Tags must be shorter than 50 characters. ' \ + 'Tags that are too long: ' \ + 'ffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuu']) def test_delete(self): - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Balanced Goblin', - }, upload_files=[( - 'file', GOOD_JPG)]) - - # Post image - response.follow() - - request = template.TEMPLATE_TEST_CONTEXT[ - 'mediagoblin/user_pages/user.html']['request'] - - media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0] - - # Does media entry exist? - assert_true(media) + response, request = self.do_post({'title': 'Balanced Goblin'}, + *REQUEST_CONTEXT, do_follow=True, + **self.upload_data(GOOD_JPG)) + media = self.check_media(request, {'title': 'Balanced Goblin'}, 1) + media_id = media.id # Add a comment, so we can test for its deletion later. - media_id = media.id - get_comments = lambda: list( - request.db.MediaComment.find({'media_entry': media_id})) - assert_false(get_comments()) - response = self.test_app.post( - request.urlgen('mediagoblin.user_pages.media_post_comment', - user=self.test_user.username, - media=media._id), - {'comment_content': 'i love this test'}) - response.follow() - assert_true(get_comments()) + self.check_comments(request, media_id, 0) + comment_url = request.urlgen( + 'mediagoblin.user_pages.media_post_comment', + user=self.test_user.username, media=media_id) + response = self.do_post({'comment_content': 'i love this test'}, + url=comment_url, do_follow=True)[0] + self.check_comments(request, media_id, 1) # Do not confirm deletion # --------------------------------------------------- - response = self.test_app.post( - request.urlgen('mediagoblin.user_pages.media_confirm_delete', - # No work: user=media.uploader().username, - user=self.test_user.username, - media=media_id), - # no value means no confirm - {}) - - response.follow() - - request = template.TEMPLATE_TEST_CONTEXT[ - 'mediagoblin/user_pages/user.html']['request'] - - media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0] - - # Does media entry still exist? - assert_true(media) + delete_url = request.urlgen( + 'mediagoblin.user_pages.media_confirm_delete', + user=self.test_user.username, media=media_id) + # Empty data means don't confirm + response = self.do_post({}, do_follow=True, url=delete_url)[0] + media = self.check_media(request, {'title': 'Balanced Goblin'}, 1) + media_id = media.id # Confirm deletion # --------------------------------------------------- - response = self.test_app.post( - request.urlgen('mediagoblin.user_pages.media_confirm_delete', - # No work: user=media.uploader().username, - user=self.test_user.username, - media=media._id), - {'confirm': 'y'}) - - response.follow() - - request = template.TEMPLATE_TEST_CONTEXT[ - 'mediagoblin/user_pages/user.html']['request'] - - # Does media entry still exist? - assert_false( - request.db.MediaEntry.find( - {'_id': media._id}).count()) + response, request = self.do_post({'confirm': 'y'}, *REQUEST_CONTEXT, + do_follow=True, url=delete_url) + self.check_media(request, {'_id': media_id}, 0) + self.check_comments(request, media_id, 0) - # How about the comment? - assert_false(get_comments()) - - def test_malicious_uploads(self): + def test_evil_file(self): # Test non-suppoerted file with non-supported extension # ----------------------------------------------------- + response, form = self.do_post({'title': 'Malicious Upload 1'}, + *FORM_CONTEXT, + **self.upload_data(EVIL_FILE)) + assert_equal(len(form.file.errors), 1) + assert 'Sorry, I don\'t support that file type :(' == \ + str(form.file.errors[0]) + + def test_sniffing(self): + ''' + Test sniffing mechanism to assert that regular uploads work as intended + ''' template.clear_test_template_context() response = self.test_app.post( '/submit/', { - 'title': 'Malicious Upload 1' + 'title': 'UNIQUE_TITLE_PLS_DONT_CREATE_OTHER_MEDIA_WITH_THIS_TITLE' }, upload_files=[( - 'file', EVIL_FILE)]) + 'file', GOOD_JPG)]) + + response.follow() + + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/user_pages/user.html'] + + request = context['request'] + + media = request.db.MediaEntry.find_one({ + u'title': u'UNIQUE_TITLE_PLS_DONT_CREATE_OTHER_MEDIA_WITH_THIS_TITLE'}) - context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html'] - form = context['submit_form'] - assert re.match(r'^Could not extract any file extension from ".*?"$', str(form.file.errors[0])) - assert len(form.file.errors) == 1 + assert media.media_type == 'mediagoblin.media_types.image' + def check_false_image(self, title, filename): # NOTE: The following 2 tests will ultimately fail, but they # *will* pass the initial form submission step. Instead, # they'll be caught as failures during the processing step. + response, context = self.do_post({'title': title}, do_follow=True, + **self.upload_data(filename)) + self.check_url(response, '/u/{0}/'.format(self.test_user.username)) + entry = mg_globals.database.MediaEntry.find_one({'title': title}) + assert_equal(entry.state, 'failed') + assert_equal(entry.fail_error, u'mediagoblin.processing:BadMediaFail') + def test_evil_jpg(self): # Test non-supported file with .jpg extension # ------------------------------------------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Malicious Upload 2' - }, upload_files=[( - 'file', EVIL_JPG)]) - response.follow() - assert_equal( - urlparse.urlsplit(response.location)[2], - '/u/chris/') - - entry = mg_globals.database.MediaEntry.find_one( - {'title': 'Malicious Upload 2'}) - assert_equal(entry.state, 'failed') - assert_equal( - entry.fail_error, - u'mediagoblin.processing:BadMediaFail') + self.check_false_image('Malicious Upload 2', EVIL_JPG) + def test_evil_png(self): # Test non-supported file with .png extension # ------------------------------------------- - template.clear_test_template_context() - response = self.test_app.post( - '/submit/', { - 'title': 'Malicious Upload 3' - }, upload_files=[( - 'file', EVIL_PNG)]) - response.follow() - assert_equal( - urlparse.urlsplit(response.location)[2], - '/u/chris/') - - entry = mg_globals.database.MediaEntry.find_one( - {'title': 'Malicious Upload 3'}) - assert_equal(entry.state, 'failed') - assert_equal( - entry.fail_error, - u'mediagoblin.processing:BadMediaFail') + self.check_false_image('Malicious Upload 3', EVIL_PNG) + + def test_processing(self): + data = {'title': 'Big Blue'} + response, request = self.do_post(data, *REQUEST_CONTEXT, do_follow=True, + **self.upload_data(BIG_BLUE)) + media = self.check_media(request, data, 1) + last_size = 1024 ** 3 # Needs to be larger than bigblue.png + for key, basename in (('original', 'bigblue.png'), + ('medium', 'bigblue.medium.png'), + ('thumb', 'bigblue.thumbnail.png')): + # Does the processed image have a good filename? + filename = resource_filename( + 'mediagoblin.tests', + os.path.join('test_user_dev/media/public', + *media.media_files.get(key, []))) + assert_true(filename.endswith('_' + basename)) + # Is it smaller than the last processed image we looked at? + size = os.stat(filename).st_size + assert_true(last_size > size) + last_size = size diff --git a/mediagoblin/tests/test_submission/bigblue.png b/mediagoblin/tests/test_submission/bigblue.png Binary files differnew file mode 100644 index 00000000..2b2c2a44 --- /dev/null +++ b/mediagoblin/tests/test_submission/bigblue.png diff --git a/mediagoblin/tools/exif.py b/mediagoblin/tools/exif.py index de6dd128..448a342e 100644 --- a/mediagoblin/tools/exif.py +++ b/mediagoblin/tools/exif.py @@ -32,6 +32,13 @@ USEFUL_TAGS = [ 'EXIF UserComment', ] +def exif_image_needs_rotation(exif_tags): + """ + Returns True if EXIF orientation requires rotation + """ + return 'Image Orientation' in exif_tags \ + and exif_tags['Image Orientation'].values[0] != 1 + def exif_fix_image_orientation(im, exif_tags): """ Translate any EXIF orientation to raw orientation |