httpupload: thumbnailing improvements

- Move thumbnailing code into separate module
 - Port GdkPixbuf thumbnailer to gtk3
 - Make GdkPixbuf thumbnailer more efficient
 - Better exception logging
 - Some code reformatting
This commit is contained in:
Linus Heckemann
2016-08-20 00:09:14 +02:00
parent 17dfa9de94
commit db68897e78
2 changed files with 112 additions and 103 deletions

View File

@@ -4,20 +4,11 @@
from gi.repository import GObject, Gtk from gi.repository import GObject, Gtk
import os import os
import time import time
import base64
import tempfile
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.parse import quote as urlquote
import mimetypes # better use the magic packet, but that's not a standard lib import mimetypes # better use the magic packet, but that's not a standard lib
import gtkgui_helpers import gtkgui_helpers
import logging
from queue import Queue from queue import Queue
try:
from PIL import Image
pil_available = True
except:
pil_available = False
from io import BytesIO
import base64
import binascii import binascii
from common import gajim from common import gajim
@@ -25,10 +16,11 @@ from common import ged
import chat_control import chat_control
from plugins import GajimPlugin from plugins import GajimPlugin
from plugins.helpers import log_calls from plugins.helpers import log_calls
import logging
from dialogs import FileChooserDialog, ImageChooserDialog, ErrorDialog from dialogs import FileChooserDialog, ImageChooserDialog, ErrorDialog
import nbxmpp import nbxmpp
from .thumbnail import thumbnail
log = logging.getLogger('gajim.plugin_system.httpupload') log = logging.getLogger('gajim.plugin_system.httpupload')
if os.name != 'nt': if os.name != 'nt':
@@ -52,8 +44,6 @@ TAGSIZE = 16
jid_to_servers = {} jid_to_servers = {}
iq_ids_to_callbacks = {} iq_ids_to_callbacks = {}
last_info_query = {} last_info_query = {}
max_thumbnail_size = 2048
max_thumbnail_dimension = 160
class HttpuploadPlugin(GajimPlugin): class HttpuploadPlugin(GajimPlugin):
@@ -125,10 +115,11 @@ class HttpuploadPlugin(GajimPlugin):
#pass #pass
# query info at most every 60 seconds in case something goes wrong # query info at most every 60 seconds in case something goes wrong
if (not chat_control.account in last_info_query or \ if ((not chat_control.account in last_info_query or
last_info_query[chat_control.account] + 60 < time.time()) and \ last_info_query[chat_control.account] + 60 < time.time())
not gajim.get_jid_from_account(chat_control.account) in jid_to_servers and \ and not gajim.get_jid_from_account(chat_control.account) in jid_to_servers
gajim.account_is_connected(chat_control.account): and gajim.account_is_connected(chat_control.account)
):
log.info("Account %s: Using dicovery to find jid of httpupload component" % chat_control.account) log.info("Account %s: Using dicovery to find jid of httpupload component" % chat_control.account)
id_ = gajim.get_an_id() id_ = gajim.get_an_id()
iq = nbxmpp.Iq( iq = nbxmpp.Iq(
@@ -289,11 +280,11 @@ class Base(object):
progress_window.close_dialog() progress_window.close_dialog()
error = stanza.getTag("error") error = stanza.getTag("error")
if error and error.getTag("text"): if error and error.getTag("text"):
ErrorDialog(_('Could not request upload slot'), ErrorDialog(_('Could not request upload slot'),
_('Got unexpected response from server: %s') % str(error.getTagData("text")), _('Got unexpected response from server: %s') % str(error.getTagData("text")),
transient_for=self.chat_control.parent_win.window) transient_for=self.chat_control.parent_win.window)
else: else:
ErrorDialog(_('Could not request upload slot'), ErrorDialog(_('Could not request upload slot'),
_('Got unexpected response from server (protocol mismatch??)'), _('Got unexpected response from server (protocol mismatch??)'),
transient_for=self.chat_control.parent_win.window) transient_for=self.chat_control.parent_win.window)
return return
@@ -313,7 +304,7 @@ class Base(object):
except: except:
log.error("Could not open file") log.error("Could not open file")
progress_window.close_dialog() progress_window.close_dialog()
ErrorDialog(_('Could not open file'), ErrorDialog(_('Could not open file'),
_('Exception raised while opening file (see error log for more information)'), _('Exception raised while opening file (see error log for more information)'),
transient_for=self.chat_control.parent_win.window) transient_for=self.chat_control.parent_win.window)
raise # fill error log with useful information raise # fill error log with useful information
@@ -323,7 +314,7 @@ class Base(object):
if not put or not get: if not put or not get:
log.error("got unexpected stanza: " + str(stanza)) log.error("got unexpected stanza: " + str(stanza))
progress_window.close_dialog() progress_window.close_dialog()
ErrorDialog(_('Could not request upload slot'), ErrorDialog(_('Could not request upload slot'),
_('Got unexpected response from server (protocol mismatch??)'), _('Got unexpected response from server (protocol mismatch??)'),
transient_for=self.chat_control.parent_win.window) transient_for=self.chat_control.parent_win.window)
return return
@@ -335,69 +326,17 @@ class Base(object):
log.info("Upload completed successfully") log.info("Upload completed successfully")
xhtml = None xhtml = None
is_image = mime_type.split('/', 1)[0] == 'image' is_image = mime_type.split('/', 1)[0] == 'image'
if (not isinstance(self.chat_control, chat_control.ChatControl) or not self.chat_control.gpg_is_active) and \ if ((not isinstance(self.chat_control, chat_control.ChatControl)
self.dialog_type == 'image' and is_image and not self.encrypted_upload: or not self.chat_control.gpg_is_active)
and self.dialog_type == 'image'
and is_image
and not self.encrypted_upload
):
progress_messages.put(_('Calculating (possible) image thumbnail...')) progress_messages.put(_('Calculating (possible) image thumbnail...'))
thumb = None thumb = thumbnail(path_to_file)
quality_steps = (100, 80, 60, 50, 40, 35, 30, 25, 23, 20, 18, 15, 13, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
with open(path_to_file, 'rb') as content_file:
thumb = urlquote(base64.standard_b64encode(content_file.read()), '')
if thumb and len(thumb) < max_thumbnail_size:
quality = 100
log.info("Image small enough (%d bytes), not resampling" % len(thumb))
elif pil_available:
log.info("PIL available, using it for image downsampling")
try:
for quality in quality_steps:
thumb = Image.open(path_to_file)
thumb.thumbnail((max_thumbnail_dimension, max_thumbnail_dimension), Image.ANTIALIAS)
output = BytesIO()
thumb.save(output, format='JPEG', quality=quality, optimize=True)
thumb = output.getvalue()
output.close()
thumb = urlquote(base64.standard_b64encode(thumb), '')
log.debug("pil thumbnail jpeg quality %d produces an image of size %d..." % (quality, len(thumb)))
if len(thumb) < max_thumbnail_size:
break
except:
thumb = None
else:
thumb = None
if not thumb:
log.info("PIL not available, using GTK for image downsampling")
temp_file = None
try:
with open(path_to_file, 'rb') as content_file:
thumb = content_file.read()
loader = Gtk.gdk.PixbufLoader()
loader.write(thumb)
loader.close()
pixbuf = loader.get_pixbuf()
scaled_pb = self.get_pixbuf_of_size(pixbuf, max_thumbnail_dimension)
handle, temp_file = tempfile.mkstemp(suffix='.jpeg', prefix='gajim_httpupload_scaled_tmp', dir=gajim.TMP)
log.debug("Saving temporary jpeg image to '%s'..." % temp_file)
os.close(handle)
for quality in quality_steps:
scaled_pb.save(temp_file, "jpeg", {"quality": str(quality)})
with open(temp_file, 'rb') as content_file:
thumb = content_file.read()
thumb = urlquote(base64.standard_b64encode(thumb), '')
log.debug("gtk thumbnail jpeg quality %d produces an image of size %d..." % (quality, len(thumb)))
if len(thumb) < max_thumbnail_size:
break
except:
thumb = None
finally:
if temp_file:
os.unlink(temp_file)
if thumb: if thumb:
if len(thumb) > max_thumbnail_size: xhtml = '<body><br/><a href="%s"><img alt="%s" src="data:image/jpeg;base64,%s"/></a></body>' % \
log.info("Couldn't compress image enough, not sending any thumbnail") (get.getData(), get.getData(), thumb)
else:
log.info("Using thumbnail jpeg quality %d (image size: %d bytes)" % (quality, len(thumb)))
xhtml = '<body><br/><a href="%s"> <img alt="%s" src="data:image/png;base64,%s"/> </a></body>' % \
(get.getData(), get.getData(), thumb)
progress_window.close_dialog() progress_window.close_dialog()
id_ = gajim.get_an_id() id_ = gajim.get_an_id()
def add_oob_tag(): def add_oob_tag():
@@ -414,7 +353,7 @@ class Base(object):
ErrorDialog(_('Could not upload file'), ErrorDialog(_('Could not upload file'),
_('Got unexpected http response code from server: ') + str(response_code), _('Got unexpected http response code from server: ') + str(response_code),
transient_for=self.chat_control.parent_win.window) transient_for=self.chat_control.parent_win.window)
def uploader(): def uploader():
progress_messages.put(_('Uploading file via HTTP...')) progress_messages.put(_('Uploading file via HTTP...'))
try: try:
@@ -495,26 +434,6 @@ class Base(object):
self.dialog_type = 'image' self.dialog_type = 'image'
self.dlg = ImageChooserDialog(on_response_ok=self.on_file_dialog_ok, on_response_cancel=None) self.dlg = ImageChooserDialog(on_response_ok=self.on_file_dialog_ok, on_response_cancel=None)
def get_pixbuf_of_size(self, pixbuf, size):
# Creates a pixbuf that fits in the specified square of sizexsize
# while preserving the aspect ratio
# Returns scaled_pixbuf
image_width = pixbuf.get_width()
image_height = pixbuf.get_height()
if image_width > image_height:
if image_width > size:
image_height = int(size / float(image_width) * image_height)
image_width = int(size)
else:
if image_height > size:
image_width = int(size / float(image_height) * image_width)
image_height = int(size)
crop_pixbuf = pixbuf.scale_simple(image_width, image_height,
Gtk.gdk.INTERP_BILINEAR)
return crop_pixbuf
class StreamFileWithProgress: class StreamFileWithProgress:
def __init__(self, path, mode, callback=None, def __init__(self, path, mode, callback=None,

90
httpupload/thumbnail.py Normal file
View File

@@ -0,0 +1,90 @@
from gi.repository import GdkPixbuf
import base64
from io import BytesIO
import os
import sys
import logging
from urllib.parse import quote as urlquote
try:
from PIL import Image
pil_available = True
except:
pil_available = False
log = logging.getLogger('gajim.plugin_system.httpupload.thumbnail')
def scale_down_to(pixbuf, size):
# Creates a pixbuf that fits in the specified square of sizexsize
# while preserving the aspect ratio
# Returns scaled_pixbuf
image_width = pixbuf.get_width()
image_height = pixbuf.get_height()
if image_width > image_height:
if image_width > size:
image_height = int(size / float(image_width) * image_height)
image_width = int(size)
else:
if image_height > size:
image_width = int(size / float(image_height) * image_width)
image_height = int(size)
crop_pixbuf = pixbuf.scale_simple(image_width, image_height, GdkPixbuf.InterpType.BILINEAR)
return crop_pixbuf
max_thumbnail_size = 2048
max_thumbnail_dimension = 160
base64_size_factor = 4/3
def thumbnail(path_to_file):
"""
Generates a JPEG thumbnail and base64-encodes, ensuring that the encoded
size is less than max_thumbnail_size bytes. If this is not possible, returns
None.
"""
thumb = None
quality_steps = (100, 80, 60, 50, 40, 35, 30, 25, 23, 20, 18, 15, 13, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
# If the whole file is small enough, we'll just use that as a thumbnail
# without downsampling.
if os.path.getsize(path_to_file) * base64_size_factor < max_thumbnail_size:
with open(path_to_file, 'rb') as content_file:
thumb = urlquote(base64.standard_b64encode(content_file.read()), '')
log.info("Image small enough (%d bytes), not resampling" % len(thumb))
return thumb
elif pil_available:
log.info("PIL available, using it for image downsampling")
try:
for quality in quality_steps:
thumb = Image.open(path_to_file)
thumb.thumbnail((max_thumbnail_dimension, max_thumbnail_dimension), Image.ANTIALIAS)
output = BytesIO()
thumb.save(output, format='JPEG', quality=quality, optimize=True)
thumb = output.getvalue()
output.close()
thumb = urlquote(base64.standard_b64encode(thumb), '')
log.debug("pil thumbnail jpeg quality %d produces an image of size %d...", quality, len(thumb))
if len(thumb) < max_thumbnail_size:
log.debug("Size is acceptable.")
return thumb
except:
log.info("Exception occurred during PIL downsampling", exc_info=sys.exc_info())
thumb = None
# If we haven't returned by now we couldn't use PIL for one reason or
# another, so let's pass on to GdkPixbuf
log.info("using GdkPixBuf for image downsampling")
temp_file = None
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(path_to_file)
scaled_pb = scale_down_to(pixbuf, max_thumbnail_dimension)
for quality in quality_steps:
success, thumb_raw = scaled_pb.save_to_bufferv("jpeg", ["quality"], [str(quality)])
log.debug("gdkpixbuf thumbnail jpeg quality %d produces an image of size %d...",
quality,
len(thumb_raw) * base64_size_factor)
if len(thumb_raw) * base64_size_factor < max_thumbnail_size:
log.debug("Size is acceptable.")
return urlquote(base64.standard_b64encode(thumb_raw))
except:
log.info("Exception occurred during GdkPixbuf downsampling, not providing thumbnail", exc_info=sys.exc_info())
return None
log.info("No acceptably small thumbnail was generated.")