Last active
October 31, 2023 20:12
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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