Skip to content

Instantly share code, notes, and snippets.

@ninapavlich
Last active October 31, 2023 20:12
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ninapavlich/48ff55a50a17ca5780fc8ae83d0c393a to your computer and use it in GitHub Desktop.
Save ninapavlich/48ff55a50a17ca5780fc8ae83d0c393a to your computer and use it in GitHub Desktop.
Custom Django File field that provides meta data (md5, mime type, content type, size) to sibling fields, creates thumbnails in sibling fields, can add watermarks to thumbnails, and can enforce size and various validations.
import hashlib
import io
import logging
import mimetypes
import os
import re
import tempfile
from xml.dom import minidom
from PIL import Image
import cv2
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.base import File
from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import SimpleUploadedFile, InMemoryUploadedFile
from django.db import models
from django.db.models.fields.files import FileDescriptor, FieldFile, ImageFileDescriptor
from django.template.defaultfilters import filesizeformat
logger = logging.getLogger('django')
"""
This file field has some extra built in features for copying meta data into other sibling fields, and apply file size and mime-type restrictions.
SuperFieldFile:
Settings:
max_upload_size: max file size in bytes (e.g. max_upload_size=10000)
min_upload_size: minimum file size in bytes (e.g. min_upload_size=100)
limit_mime_types: list of acceptable mime types ['image/*', 'video/mpeg4']
min_width: Minimum width of an image or video (e.g. min_width=600)
max_width: Maximum width of an image or video (e.g. max_width=1200)
min_height: Minimum height of an image or video (e.g. min_height=600)
max_height: Maximum height of an image or video (e.g. max_height=1200)
min_aspect_ratio: Minimum aspect ratio of an image or video. (e.g. min_aspect_ratio=0.5)
max_aspect_ratio: Maximum aspect ratio of an image or video. (e.g. min_aspect_ratio=2)
NOTE: A tall image has an aspect ratio < 1, a wide image has an aspect ratio of > 1
a square image has an aspect ratio of 1. Set both min and max to 1 to require a square image.
min_duration: Minimum duration in seconds of a video. (e.g. min_duration=10)
max_duration: Maximum duration in seconds of a video. (e.g. max_duration=120)
NOTE: Min and max durations are not yet implemented for audio files.
Sibling Fields:
md5_field: MD5 hash of file [models.CharField]
mime_type_field: Full mime of a file (e.g. audio/mpeg3) [models.CharField]
content_type_field: The umbrella mime type of a file (e.g. "audio" from audio/mpeg3) [models.CharField]
file_size_field: The size of a file in bytes, such as 1362492 [BigIntegerField]
display_file_size_field: The friendly display of a file size in bytes, such as "1.3 MB" [models.CharField]
width_field: Width of an image or video in pixels [models.IntegerField]
height_field: Height of an image or video in pixels [models.IntegerField]
duration_field: Length of a video in seconds, rounded to 2 decimal places [models.FloatField or models.CharField]
thumbnail_fields: An array of field names where thumbnails of the file will be stored [ThumbnailField]
ThumbnailField:
Settings:
max_width: Max width of the thumbnail in pixels, defaults to 150 (e.g. max_width=150)
max_height: Max width of the thumbnail in pixels, defaults to 100 (e.g. max_height=100)
quality: Thumbnail quality, defaults to 90. 95 or below is recommended (e.g. quality=95)
optimize: Decrease file size if possible, defaults to True (e.g. optimize=False)
progressive: If saving a JPEG, it will attempt to save as progressive JPG, defaults to True (e.g. progressive=False)
override_format: Force thumbnail to use a different format that source image, defaults to use original image format (e.g. override_format='png')
crop: If the thumbnail should be cropped to match the max_width and max_height aspect ratio.
If false, then the thumbnail will be contained inside the max_width and max_height,
but may have a different aspect ratio. If you want a square thumbnail for example,
set this to True. Defaults to False.
crop_focus_x: If cropping to create the video, what is the horizontal position we should center around, from 0-1.
0 would mean crop off the right side, 1 would mean crop off the left size,
and 0.5 means crop an equal amount off both sides. Defaults to 0.5
crop_focus_y: If cropping to create the video, what is the vertical position we should center around, from 0-1.
0 would mean crop off the bottom, 1 would mean crop off the top,
and 0.5 means crop an equal amount off top and bottom. Defaults to 0.5
frame_position: Which frame from the video to select the thumbnail from, as a portion from 0-1.
0 would mean the first frame, 1 would mean the last frame,
0.5 would mean the middle of the video. Defaults to 0.5
auto_generate: Whether to automatically generate this thumbnail when the parent field is updated.
If you allow thumbnails to be manually set, then you may want to set this to False.
Defaults to True.
watermark_file: Image to automatically apply to thumbnail, assumed to be in the /graphics/ directory
watermark_scale_x: How wide the watermark image should be in relation to the thumbnail image, from 0-1.
Enter 0.25 to scale the watermark to 25% of the thumbnail's width.
watermark_scale_y: How tall the watermark image should be in relation to the thumbnail image, from 0-1.
Enter 0.25 to scale the watermark to 25% of the thumbnail's height.
NOTE: Only provide watermark_scale_x OR watermark_scale_y. If you provide watermark_scale_y,
then also set watermark_scale_x to None.
watermark_inset: Whether the watermark should be positioned inside the thumbnail,
or should hang off the edges. Watermark is inset True by default.
watermark_position_x: Horizontal positioning of the watermark in relation to the thumbnail.
Enter 0 for flush left, 1 for flush right when inset is True
Enter -1 for hanging off left edge, 1 for hanging off right edge when inset is False
watermark_position_y: Vertical positioning of the watermark in relation to the thumbnail.
Enter 0 for flush top, 1 for flush bottom when inset is True
Enter -1 for hanging off top edge, 1 for hanging off bottom edge when inset is False
Example Usage:
--------------
def asset_upload_path(instance, filename):
return os.path.join(settings.MEDIA_ASSET_UPLOAD_FOLDER, instance.txtid, 'asset', filename)
def asset_thumbnail_upload_path(instance, filename):
return os.path.join(settings.MEDIA_ASSET_UPLOAD_FOLDER, instance.txtid, 'asset_thumbnail', filename)
class Post(models.Model):
asset = SuperFileField(upload_to=asset_upload_path, max_length=255, blank=True, null=True, md5_field='asset_md5', content_type_field='asset_file_type',
storage=OverwriteStorage(), thumbnail_fields=['assetEn_thumbnail'], max_upload_size=10000, limit_mime_types=['image/*', 'video/mpeg4'])
asset_thumbnail = ThumbnailField(upload_to=asset_thumbnail_upload_path, max_length=255,
blank=True, null=True, storage=OverwriteStorage(), max_width=400, max_height=400)
asset_md5 = models.CharField(max_length=255, null=True, blank=True)
asset_file_type = models.CharField(max_length=255, null=True, blank=True)
Custom Icons for other file types:
----------------------------------
To add custom icons to additional media types, place an /graphics/ directory in the same folder as this script,
and in it place files with the parent mime type (audio or application):
- /graphics/application.png
- /graphics/audio.png
- /graphics/text.png
- /graphics/video.png
You can also add more specific sub mime types like so:
- /graphics/application/zip.png
It will first try to find the more specific icon, and if that doesn't exist, fall back to the parent super icon,
and if that doesn't exist then it will just use a plain grey rectangle.
"""
def image_mode_for_file(thumbnail_content_type):
if thumbnail_content_type.upper() == "PNG":
return "RGBA"
if thumbnail_content_type.upper() == "GIF":
return "RGBA"
return "RGB"
def normalize_extension(extension):
# Replace any odd extention variants
return extension.replace("jpg", "jpeg").replace("jpe", "jpeg").replace("jpegg", "jpeg")
def is_svg_vector_image(file_mime_type):
if file_mime_type == "image/svg+xml":
return True
return False
def unit_to_num(raw_unit):
raw_unit = raw_unit.replace("em", "")
raw_unit = raw_unit.replace("ex", "")
raw_unit = raw_unit.replace("px", "")
raw_unit = raw_unit.replace("pt", "")
raw_unit = raw_unit.replace("pc", "")
raw_unit = raw_unit.replace("cm", "")
raw_unit = raw_unit.replace("mm", "")
raw_unit = raw_unit.replace("in", "")
raw_unit = raw_unit.replace("%", "")
return float(raw_unit)
def get_dimensions_from_svg(filename):
# Example:
# <svg width="800" height="600" viewbox="0 0 800 600">
# <!-- SVG content drawn onto the SVG canvas -->
# </svg>
doc = minidom.parse(filename)
svg = doc.getElementsByTagName("svg")
viewBox_width = None
viewBox_height = None
for element in svg:
width = unit_to_num(element.getAttribute('width'))
height = unit_to_num(element.getAttribute('height'))
viewBox = element.getAttribute('viewBox')
pieces = None if not viewBox else viewBox.split(" ")
viewBox_width = width if not viewBox else unit_to_num(pieces[2])
viewBox_height = width if not viewBox else unit_to_num(pieces[3])
return (viewBox_width, viewBox_height)
class OverwriteStorage(FileSystemStorage):
def get_available_name(self, name, max_length=None):
# If the filename already exists, remove it as if it was a true file
# system
if self.exists(name):
os.remove(os.path.join(settings.MEDIA_ROOT, name))
return name
class SuperFieldFile(FieldFile):
_content_type = None
_mime_type = None
_md5 = None
_width = None
_height = None
_duration = None
_local_temp_file = None
# Base thumbnail settings
_default_icon_background_color = (230, 230, 230)
_default_graphic_directory = './graphics/'
_default_thumbnail_image_format = 'png'
_default_thumbnail_icon_padding = 50
def __init__(self, *args, **kwargs):
file_field = args[1]
self.md5_field = file_field.md5_field
self.content_type_field = file_field.content_type_field
self.mime_type_field = file_field.mime_type_field
self.file_size_field = file_field.file_size_field
self.display_file_size_field = file_field.display_file_size_field
self.width_field = file_field.width_field
self.height_field = file_field.height_field
self.duration_field = file_field.duration_field
self.thumbnail_fields = file_field.thumbnail_fields
super(SuperFieldFile,
self).__init__(*args, **kwargs)
@property
def content_type(self):
self._require_file()
return self._content_type
@property
def mime_type(self):
self._require_file()
return self._mime_type
@property
def md5(self):
self._require_file()
return self._md5
@property
def width(self):
self._require_file()
return self._width
@property
def height(self):
self._require_file()
return self._height
@property
def duration(self):
self._require_file()
return self._duration
def _require_metadata(self, content):
self._require_local_temp_file(content)
if not self._md5:
self.deteremin_md5(content)
if not self._mime_type:
self.determine_mime(content)
if not self._width:
self.determine_measurements(content)
def determine_mime(self, content):
filetype = None
if hasattr(content, "content_type"):
# It's a django.core.files.uploadedfile.InMemoryUploadedFile
filetype = content.content_type
elif hasattr(content, "key"):
# It's a storages.backends.s3boto.S3BotoStorageFile
filetype = content.key.content_type
else:
# It's a django.core.files.base.File
filetype, encoding = mimetypes.guess_type(str(content))
if filetype:
self._mime_type = filetype.lower()
self._content_type = filetype.lower().split('/')[0]
def is_type_of(self, mime_types):
self._require_file()
found_match = False
for mime_type in mime_types:
if re.compile(mime_type).search(self._content_type):
found_match = True
return found_match
def deteremin_md5(self, content):
if self._md5:
return self._md5
hash_md5 = hashlib.md5()
for chunk in content.chunks():
hash_md5.update(chunk)
self._md5 = hash_md5.hexdigest()
def determine_measurements(self, content):
if self.content_type != 'image' and self.content_type != 'video':
return
try:
if not self._local_temp_file:
return
if self.content_type == 'image':
if is_svg_vector_image(self.mime_type):
width, height = get_dimensions_from_svg(
self._local_temp_file.name)
if width:
self._width = width
if height:
self._height = height
else:
img = cv2.imread(self._local_temp_file.name)
height, width, channels = img.shape
self._width = width
self._height = height
elif self.content_type == 'video':
cap = cv2.VideoCapture(self._local_temp_file.name)
if cap.isOpened():
num_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
fps = cap.get(cv2.CAP_PROP_FPS)
self._duration = round(float(num_frames) / float(fps), 2)
self._width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self._height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
except Exception as ex:
template = u"Error determining measurements for file: {0}({1}): {2}: {3!r}"
message = template.format(
self._local_temp_file,
self.content_type,
type(ex).__name__,
ex.args)
logger.warn(message)
def _require_local_temp_file(self, content):
# Required for some processing involving cv2
if self._local_temp_file:
return self._local_temp_file
if not content:
return None
# temp_filename = os.path.join(tempfile.gettempdir(), content.name)
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_filename = temp_file.name
self._local_temp_file = open(temp_filename, "wb+")
content.seek(0)
self._local_temp_file.write(content.read())
self._local_temp_file.close()
def _clean_local_temp_file(self):
if self._local_temp_file:
if os.path.exists(self._local_temp_file.name):
os.remove(self._local_temp_file.name)
self._local_temp_file = None
def save(self, name, content, save=True):
self._require_metadata(content)
self._generate_thumbnails(content)
super(SuperFieldFile,
self).save(name, content, save)
self._generate_metadata()
self._clean_local_temp_file()
def generate_metadata(self):
"""
Allows you to update thumbnails if definitions change:
>>> poster = Poster.objects.get(txtid='test')
>>> poster.image.generate_thumbnails()
"""
has_file = False
try:
if self.file:
has_file = True
except:
has_file = False
if has_file:
self._generate_metadata()
def _generate_metadata(self):
if self.md5_field:
setattr(self.instance, self.md5_field, self.md5)
if self.content_type_field:
setattr(self.instance, self.content_type_field, self.content_type)
if self.mime_type_field:
setattr(self.instance, self.mime_type_field, self.mime_type)
if self.file_size_field:
setattr(self.instance, self.file_size_field, self.size)
if self.display_file_size_field:
setattr(self.instance, self.display_file_size_field,
filesizeformat(self.size))
if self.width_field:
setattr(self.instance, self.width_field, self.width)
if self.height_field:
setattr(self.instance, self.height_field, self.height)
if self.duration_field:
setattr(self.instance, self.duration_field, self.duration)
self._clean_local_temp_file()
def generate_thumbnails(self, thumbnail_fields_to_generate=None):
"""
Allows you to update thumbnails if definitions change:
>>> poster = Poster.objects.get(txtid='test')
>>> poster.image.generate_thumbnails()
"""
has_file = False
try:
if self.file:
has_file = True
except:
has_file = False
if has_file:
self._generate_thumbnails(
self.file, thumbnail_fields_to_generate, True, True)
def _generate_thumbnails(self, content, thumbnail_fields_to_generate=None, force=False, save=False):
if not content:
return
self._require_metadata(content)
if not thumbnail_fields_to_generate:
thumbnail_fields_to_generate = self.thumbnail_fields
if not thumbnail_fields_to_generate:
return
filename = content.name.split('/')[-1]
for thumbnail_field_name in thumbnail_fields_to_generate:
self._generate_thumbnail_for_field(
content, thumbnail_field_name, filename, force, save)
def _generate_thumbnail_for_field(self, content, thumbnail_field_name, filename, force=False, save=False):
if not content:
return
thumbnail_field_instance = getattr(self.instance, thumbnail_field_name)
thumbnail_model_field = self.instance._meta.get_field(
thumbnail_field_name)
if force or thumbnail_model_field.auto_generate:
logger.log(logging.DEBUG,
"Generating thumbnail for field %s: max_width:%s max_height:%s crop:%s crop_focus_x:%s crop_focus_y:%s frame_position:%s" % (thumbnail_field_name, thumbnail_model_field.max_width, thumbnail_model_field.max_height,
thumbnail_model_field.crop, thumbnail_model_field.crop_focus_x, thumbnail_model_field.crop_focus_y, thumbnail_model_field.frame_position))
thumbnail = self.get_thumbnail_from_file(content, self.mime_type, thumbnail_model_field.max_width, thumbnail_model_field.max_height,
thumbnail_model_field.crop, thumbnail_model_field.crop_focus_x, thumbnail_model_field.crop_focus_y, thumbnail_model_field.frame_position)
if thumbnail:
thumbnail_filename, thumbnail_content_type = self.get_thumbnail_filename_type(
filename, self.mime_type, thumbnail_model_field.override_format)
thumbnail_image_mode = image_mode_for_file(thumbnail_content_type)
if thumbnail_model_field.watermark_file:
thumbnail = self.watermark_thumbnail(thumbnail, thumbnail_image_mode, thumbnail_model_field.watermark_file, thumbnail_model_field.watermark_position_x,
thumbnail_model_field.watermark_position_y, thumbnail_model_field.watermark_scale_x, thumbnail_model_field.watermark_scale_y, thumbnail_model_field.watermark_inset)
temp_handle = io.BytesIO()
if thumbnail.mode != thumbnail_image_mode:
# Convert to output format's image mode
thumbnail = thumbnail.convert(thumbnail_image_mode)
thumbnail.save(
temp_handle, thumbnail_content_type, quality=thumbnail_model_field.quality, optimize=thumbnail_model_field.optimize, progressive=thumbnail_model_field.progressive)
temp_handle.seek(0)
suf = SimpleUploadedFile(
thumbnail_filename, temp_handle.read(), content_type=thumbnail_content_type)
thumbnail_file = thumbnail_field_instance.save(
thumbnail_filename, suf, save=save)
else:
logger.warn("No thumbnail created for %s: %s" %
(self.instance, thumbnail_field_name))
def get_thumbnail_filename_type(self, filename, mime_type, override_format):
filename_base, original_file_extension = os.path.splitext(filename)
if override_format:
guessed_extension = u".%s" % (override_format)
file_type = override_format
else:
# Thumbnail can stay in the same format as its parent if the parent is
# one of these formats:
supported_thumbnail_formats = [
'image/gif', 'image/png', 'image/jpeg', 'image/bmp', 'image/webp']
super_mime_type = (mime_type.split('/')[0]).lower()
if mime_type.lower() in supported_thumbnail_formats:
# If original file is an image, then use the same filetype for the
# thumbnails
file_type = (mime_type.split('/')[1]).lower()
else:
# Otherwise default to PNG since this will probably be a
# 'generated' vectory image
file_type = self._default_thumbnail_image_format
thumbnail_mime_type = 'image/%s' % (file_type)
guessed_extension = mimetypes.guess_extension(thumbnail_mime_type)
# Fallback if guess_extension doesnt work, which it doesn't in some
# cases like bmps
if not guessed_extension:
guessed_extension = (mime_type.split('/')[1]).lower()
guessed_extension = normalize_extension(guessed_extension)
file_type = normalize_extension(file_type)
filename = u'%s%s' % (
filename_base, guessed_extension)
return (filename, file_type)
def get_thumbnail_from_file(self, source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position):
mime_type = file_mime.split('/')[0]
if mime_type == 'image':
if is_svg_vector_image(file_mime.lower()):
return self.get_thumbnail_from_svg_image(source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
else:
return self.get_thumbnail_from_image(source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
elif mime_type == 'video':
return self.get_thumbnail_from_video(source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
else:
return self.get_thumbnail_from_unknown(source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
def get_thumbnail_from_unknown(self, source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position):
image = Image.new("RGBA", (max_width, max_height),
self._default_icon_background_color)
icon = None
try:
# Try to add a very specific icon file type, e.g.
# /icons/video/mp4.png
path = os.path.join(os.path.realpath(__file__),
os.pardir, '%s%s.png' % (self._default_graphic_directory, file_mime))
icon_path = os.path.abspath(path)
icon = Image.open(icon_path).convert("RGBA")
except (IOError):
try:
# Try to add a more geeric icon file type, e.g.
# /icons/video.png
super_mime_type = (file_mime.split('/')[0]).lower()
path = os.path.join(os.path.realpath(__file__),
os.pardir, '%s%s.png' % (self._default_graphic_directory, super_mime_type))
icon_path = os.path.abspath(path)
icon = Image.open(icon_path).convert("RGBA")
except (IOError):
# Oh well, guess it doesnt get any fancy icon
logger.warn("No icon was found for file type %s, tried %s" % (
file_mime, icon_path))
if icon:
# Scale thumbnail down to fit insize boundaries with a little
# padding
icon_max_width = max(
20, max_width - self._default_thumbnail_icon_padding - self._default_thumbnail_icon_padding)
icon_max_height = max(
20, max_height - self._default_thumbnail_icon_padding - self._default_thumbnail_icon_padding)
thumbnail_size = (icon_max_width, icon_max_height)
icon.thumbnail(thumbnail_size)
icon_w, icon_h = icon.size
bg_w, bg_h = image.size
offset = ((bg_w - icon_w) // 2, (bg_h - icon_h) // 2)
image.paste(icon, offset, mask=icon)
return image
def get_thumbnail_from_video(self, source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position):
try:
cap = cv2.VideoCapture(self._local_temp_file.name)
filesize = os.path.getsize(self._local_temp_file.name)
if cap.isOpened():
video_length = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) - 1
if video_length > 0:
# NOTE -- CV_CAP_PROP_POS_FRAMES not found for some reason
# cap.set(cv2.CV_CAP_PROP_POS_FRAMES, frame_position)
cap.set(1, frame_position)
success, image_array = cap.read()
# HACK -- for some reason this image Array comes through with Blue
# and Red channels swapped -- so we need to rotate the
# chancnels
image_array = cv2.cvtColor(image_array, cv2.COLOR_BGR2RGB)
image = Image.fromarray(image_array)
# Hand off to image thumbnailer to apply cropping settings
return self.get_thumbnail_from_image(image, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
else:
logger.warn("Video had no length %s (Length:%s Filesize: %s)" % (
self._local_temp_file, video_length, filesize))
else:
logger.warn("Video was not opened from %s (Filesize: %s)" % (
self._local_temp_file, filesize))
except Exception as ex:
template = u"Error generating video frame from temp file:{0}: {1}: {2!r}"
message = template.format(
self._local_temp_file,
type(ex).__name__,
ex.args)
logger.warn(message)
# If video processing was unsuccessful, try using a fallback image
return self.get_thumbnail_from_unknown(source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
def get_thumbnail_from_image(self, source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position):
if isinstance(source_file, Image.Image):
source_image = source_file
else:
try:
source_image = Image.open(source_file)
except (OSError):
logger.warn("Could not load file %s" % (source_file))
return
try:
thumbnail = source_image.copy()
original_size = thumbnail.size
thumbnail_size = (max_width, max_height)
logger.log(logging.DEBUG,
"-- %s -> %s (crop:%s)" % (original_size, thumbnail_size, crop))
if crop == True:
original_aspect_ratio = thumbnail.size[0] / thumbnail.size[1]
thumbnail_aspect_ratio = max_width / max_height
logger.log(logging.DEBUG,
"-- Cropping image from original aspect ratio %s to %s" % (original_aspect_ratio, thumbnail_aspect_ratio))
if thumbnail_aspect_ratio > original_aspect_ratio:
# thumbnail is wider than source, so we'll be chopping of the
# top and bottom of the original
target_height = (original_size[0] / thumbnail_aspect_ratio)
dy = (original_size[1] - target_height)
left = 0
top = crop_focus_y * dy
right = original_size[0]
bottom = original_size[1] - ((1 - crop_focus_y) * dy)
logger.log(logging.DEBUG,
"-- Thumbnail is wider than the original, so we're going to chop %s (to get %s) off the top and bottom: %s %s %s %s" % (dy, target_height, left, top, right, bottom))
thumbnail = thumbnail.crop((left, top, right, bottom))
elif thumbnail_aspect_ratio < original_aspect_ratio:
# thumbnail is taller than source, so we'll be chopping of left
# and right sides of the original
target_width = (original_size[1] * thumbnail_aspect_ratio)
dx = (original_size[0] - target_width)
left = crop_focus_x * dx
top = 0
right = original_size[0] - ((1 - crop_focus_x) * dx)
bottom = original_size[1]
logger.log(logging.DEBUG,
"-- Thumbnail is taller than the original, so we're going to chop %s (to get %s) off the left and right: %s %s %s %s" % (dx, target_width, left, top, right, bottom))
thumbnail = thumbnail.crop((left, top, right, bottom))
thumbnail.thumbnail(thumbnail_size)
return thumbnail
except (IOError):
# If image library is not found for the given format, just use a
# fallback
return self.get_thumbnail_from_unknown(source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
def get_thumbnail_from_svg_image(self, source_file, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position):
try:
import cairosvg
svg_scale = max(1, float(max_width / self._width),
float(max_height / self._height))
temp_svg_converted_name = u'%s.png' % (self._local_temp_file.name)
source_file.seek(0)
cairosvg.svg2png(bytestring=source_file.read(),
write_to=temp_svg_converted_name, scale=svg_scale)
thumbnail_size = (max_width, max_height)
png_image = Image.open(temp_svg_converted_name).convert("RGBA")
thumbnail = self.get_thumbnail_from_image(
png_image, file_mime, max_width, max_height, crop, crop_focus_x, crop_focus_y, frame_position)
if os.path.exists(temp_svg_converted_name):
os.remove(temp_svg_converted_name)
return thumbnail
except Exception as ex:
template = u"Error generating SVG thumbnail for file:{0}: {1}: {2!r}"
message = template.format(
self._local_temp_file,
type(ex).__name__,
ex.args)
logger.warn(message)
def watermark_thumbnail(self, image, image_mode, watermark_file, watermark_position_x, watermark_position_y, watermark_scale_x, watermark_scale_y, watermark_inset):
icon = None
icon_width = None
icon_height = None
image_width, image_height = image.size
try:
path = os.path.join(os.path.realpath(
__file__), os.pardir, self._default_graphic_directory, watermark_file)
icon_path = os.path.abspath(path)
icon = Image.open(icon_path).convert("RGBA")
icon_width, icon_height = icon.size
except (IOError):
logger.warn("Could not find watermark file %s in %s" % (
watermark_file, self._default_graphic_directory))
if icon:
icon_w, icon_h = icon.size
bg_w, bg_h = image.size
# Determine scale of watermark
if watermark_scale_x:
icon_paste_width = watermark_scale_x * image_width
icon_paste_portion = icon_paste_width / icon_w
icon_paste_height = icon_h * icon_paste_portion
elif watermark_scale_y:
icon_paste_height = watermark_scale_y * image_height
icon_paste_portion = icon_paste_height / icon_h
icon_paste_width = icon_w * icon_paste_portion
else:
icon_paste_width = icon_w
icon_paste_height = icon_h
thumbnail_size = (int(icon_paste_width), int(icon_paste_height))
icon = icon.resize(thumbnail_size, Image.ANTIALIAS)
# Determine position of watermark
icon_w, icon_h = icon.size
offset_x_inset = int((bg_w - icon_w) * watermark_position_x)
offset_y_inset = int((bg_h - icon_h) * watermark_position_y)
offset_x_outset = int(max(0, watermark_position_x) * bg_w)
offset_y_outset = int(max(0, watermark_position_y) * bg_h)
offset_x = offset_x_inset if watermark_inset else offset_x_outset
offset_y = offset_y_inset if watermark_inset else offset_y_outset
offset = (offset_x, offset_y)
if not watermark_inset:
# If watermark can go outside image boundaries, resize base
# image:
new_width = int(
bg_w + (abs(watermark_position_x) * icon_w))
new_height = int(
bg_h + (abs(watermark_position_y) * icon_h))
placed_x = int(max(0, -1 * watermark_position_x) * icon_w)
placed_y = int(max(0, -1 * watermark_position_y) * icon_h)
image_expanded = Image.new(image_mode, (new_width, new_height))
image_expanded.paste(
image, (placed_x, placed_y))
image = image_expanded
image.paste(icon, offset, mask=icon)
return image
class SuperFileField(models.FileField):
attr_class = SuperFieldFile
descriptor_class = FileDescriptor
description = "MetaFileMixin"
def __init__(self, *args, **kwargs):
self.thumbnail_fields = kwargs.pop("thumbnail_fields", None)
# VALIDATION FIELDS
self.max_upload_size = kwargs.pop("max_upload_size", None)
self.min_upload_size = kwargs.pop("min_upload_size", None)
self.limit_mime_types = kwargs.pop("limit_mime_types", None)
self.min_width = kwargs.pop("min_width", None)
self.max_width = kwargs.pop("max_width", None)
self.min_height = kwargs.pop("min_height", None)
self.max_height = kwargs.pop("max_height", None)
self.min_aspect_ratio = kwargs.pop("min_aspect_ratio", None)
self.max_aspect_ratio = kwargs.pop("max_aspect_ratio", None)
self.min_duration = kwargs.pop("min_duration", None)
self.max_duration = kwargs.pop("max_duration", None)
# META DATA FIELDS
self.md5_field = kwargs.pop("md5_field", None)
self.content_type_field = kwargs.pop("content_type_field", None)
self.mime_type_field = kwargs.pop("mime_type_field", None)
self.file_size_field = kwargs.pop("file_size_field", None)
self.display_file_size_field = kwargs.pop(
"display_file_size_field", None)
self.width_field = kwargs.pop("width_field", None)
self.height_field = kwargs.pop("height_field", None)
self.duration_field = kwargs.pop("duration_field", None)
required = kwargs['null'] and kwargs['blank']
super(SuperFileField, self).__init__(*args, **kwargs)
def clean(self, *args, **kwargs):
data = super(SuperFileField, self).clean(*args, **kwargs)
# Populate metadata for new upload:
try:
file = data.file
except OSError as e:
logger.error("File missing: %s" % (e))
return
# No need to re-apply validation if file has already been uploaded
already_saved = not hasattr(
file, "content_type") and not hasattr(file, "key")
if not already_saved:
data._require_metadata(file)
# type(data): <class'cms.utils.base_asset.models.SuperFieldFile'>
# type(file): If already saved: <class 'django.core.files.base.File'>
# type(file): If a new upload: <class
# 'django.core.files.uploadedfile.InMemoryUploadedFile'>
try:
if self.limit_mime_types and len(self.limit_mime_types) > 0:
found_match = data.is_type_of(self.limit_mime_types)
if not found_match:
format_valid_types = u', '.join(
[item.capitalize() for item in self.limit_mime_types])
raise ValidationError(('Please upload a file of the following type(s): %s. Current file type is %s.') % (
format_valid_types, data._content_type.capitalize()))
except AttributeError:
pass
try:
if self.max_upload_size and data.size > self.max_upload_size:
raise ValidationError(('Please keep filesize under %s. Current filesize %s') % (
filesizeformat(self.max_upload_size), filesizeformat(data.size)))
if self.min_upload_size and data.size < self.min_upload_size:
raise ValidationError(('Please keep filesize above %s. Current filesize %s') % (
filesizeformat(self.min_upload_size), filesizeformat(data.size)))
except AttributeError:
pass
try:
if self.min_width and data._width and data._width < self.min_width:
raise ValidationError(('Please provide a file with a width greater than or equal to %spx. Current width is %spx.') % (
self.min_width, data._width))
elif self.min_width and not data._width:
logger.warn("Field requires a minimum width of %s but width was not able to be determined for file" % (
self.min_width))
if self.max_width and data._width and data._width > self.max_width:
raise ValidationError(('Please provide a file with a width less than or equal to %spx. Current width is %spx.') % (
self.max_width, data._width))
elif self.max_width and not data._width:
logger.warn("Field requires a maximum width of %s but width was not able to be determined for file" % (
self.max_width))
if self.min_height and data._height and data._height < self.min_height:
raise ValidationError(('Please provide a file with a height greater than or equal to %spx. Current height is %spx.') % (
self.min_height, data._height))
elif self.min_height and not data._height:
logger.warn("Field requires a minimum height of %s but height was not able to be determined for file" % (
self.min_height))
if self.max_height and data._height and data._height > self.max_height:
raise ValidationError(('Please provide a file with a height less than or equal to %spx. Current height is %spx.') % (
self.max_height, data._height))
elif self.max_height and not data._height:
logger.warn("Field requires a maximum height of %s but height was not able to be determined for file" % (
self.max_height))
if self.min_aspect_ratio or self.max_aspect_ratio:
if data._width and data._height:
aspect_ratio = data._width / data._height
if self.min_aspect_ratio and aspect_ratio < self.min_aspect_ratio:
raise ValidationError(('Please provide a file with an aspect ratio greater than or equal to %s. Current aspect ratio is %s.') % (
self.min_aspect_ratio, aspect_ratio))
if self.max_aspect_ratio and aspect_ratio > self.max_aspect_ratio:
raise ValidationError(('Please provide a file with an aspect ratio less than or equal to %s. Current aspect ratio is %s.') % (
self.max_aspect_ratio, aspect_ratio))
elif self.min_aspect_ratio:
logger.warn("Field requires a minimum aspect ratio of %s but aspect ratio was not able to be determined for file" % (
self.min_aspect_ratio))
elif self.max_aspect_ratio:
logger.warn("Field requires a maximum aspect ratio of %s but aspect ratio was not able to be determined for file" % (
self.max_aspect_ratio))
if self.min_duration or self.max_duration:
if data._duration:
if self.min_duration and data._duration < self.min_duration:
raise ValidationError(('Please provide a file with a duration greater than or equal to %ss. Current duration is %ss.') % (
self.min_duration, data._duration))
if self.max_duration and data._duration > self.max_duration:
raise ValidationError(('Please provide a file with a duration less than or equal to %ss. Current duration is %ss.') % (
self.max_duration, data._duration))
elif self.min_duration:
logger.warn("Field requires a minimum duration of %s but duration was not able to be determined for file" % (
self.min_duration))
elif self.max_duration:
logger.warn("Field requires a maximum duration of %s but duration was not able to be determined for file" % (
self.max_duration))
except AttributeError:
pass
return data
class ThumbnailField(models.ImageField):
"""
This field is made to work with SuperFileField
"""
descriptor_class = ImageFileDescriptor
description = "ThumbnailField"
def __init__(self, *args, **kwargs):
self.max_width = kwargs.pop("max_width", 150)
self.max_height = kwargs.pop("max_height", 100)
self.quality = kwargs.pop("quality", 90)
self.optimize = kwargs.pop("optimize", True)
self.progressive = kwargs.pop("progressive", True)
self.override_format = kwargs.pop("override_format", None)
self.crop = kwargs.pop(
"crop", False)
self.crop_focus_x = kwargs.pop("crop_focus_x", 0.5)
self.crop_focus_y = kwargs.pop("crop_focus_y", 0.5)
self.frame_position = kwargs.pop("frame_position", 0.5)
self.auto_generate = kwargs.pop("auto_generate", True)
self.watermark_file = kwargs.pop("watermark_file", None)
self.watermark_position_x = kwargs.pop("watermark_position_x", 0.5)
self.watermark_position_y = kwargs.pop("watermark_position_y", 0.5)
self.watermark_scale_x = kwargs.pop("watermark_scale_x", 0.25)
self.watermark_scale_y = kwargs.pop("watermark_scale_y", None)
self.watermark_inset = kwargs.pop("watermark_inset", True)
required = kwargs['null'] and kwargs['blank']
super(ThumbnailField, self).__init__(*args, **kwargs)
def clean(self, *args, **kwargs):
data = super(ThumbnailField, self).clean(*args, **kwargs)
file = data.file
return data
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment