Refactor get_thumbnail and other fixes and improvments

pull/415/head
Christopher Usher 1 month ago committed by Mike Lang
parent f814945dbd
commit e0fc1eaf08

@ -4,25 +4,43 @@ from io import BytesIO
from PIL import Image from PIL import Image
from common import database
"""
A thumbnail template consists of a byte stream representation of a PNG template image along with two 4-tuples [left x, top y, right x, bottom y] describing the crop and location of the frame.
To create a thumbnail, the input frame is first cropped to the bounds of the "crop" box, def get_template(dbmanager, name, crop=None, location=None):
then resized to the size of the "location" box, then pasted underneath the template image at that """Fetch the thumbnail template and any missing parameters from the database"""
location within the template image. with dbmanager.get_conn() as conn:
query = """
SELECT image, crop, location FROM templates WHERE name = %s
"""
results = database.query(conn, query, name)
row = results.fetchone()
if row is None:
raise ValueError('Template {} not found'.format(name))
row = row._asdict()
if not crop:
crop = row['crop']
if not location:
location = row['location']
For example return row['image'], crop, location
crop = (50, 100, 1870, 980)
location = (320, 180, 1600, 900)
would crop the input frame from (50, 100) to (1870, 980), resize it to 720x1280,
and place it at (320, 180).
If the original frame and the template differ in size, the frame is first resized to the template.
This allows you to work with a consistent coordinate system regardless of the input frame size.
"""
def compose_thumbnail_template(template_data, frame_data, crop, location): def compose_thumbnail_template(template_data, frame_data, crop, location):
"""
A thumbnail template consists of a byte stream representation of a PNG template image along with two 4-tuples [left x, top y, right x, bottom y] describing the crop and location of the frame.
To create a thumbnail, the input frame is first cropped to the bounds of the "crop" box, then resized to the size of the "location" box, then pasted underneath the template image at that
location within the template image.
For example
crop = (50, 100, 1870, 980)
location = (320, 180, 1600, 900)
would crop the input frame from (50, 100) to (1870, 980), resize it to 720x1280, and place it at (320, 180).
If the original frame and the template differ in size, the frame is first resized to the template.
This allows you to work with a consistent coordinate system regardless of the input frame size.
"""
# PIL can't load an image from a byte string directly, we have to pretend to be a file # PIL can't load an image from a byte string directly, we have to pretend to be a file
template = Image.open(BytesIO(template_data)) template = Image.open(BytesIO(template_data))
@ -66,4 +84,4 @@ def cli(template, frame, crop, location):
if __name__ == '__main__': if __name__ == '__main__':
import argh import argh
argh.dispatch_command(cli) argh.dispatch_command(cli)

@ -18,7 +18,7 @@ from psycopg2 import sql
import common import common
from common.database import DBManager, query, get_column_placeholder from common.database import DBManager, query, get_column_placeholder
from common.segments import get_best_segments, archive_cut_segments, fast_cut_segments, full_cut_segments, smart_cut_segments, extract_frame, ContainsHoles, get_best_segments_for_frame from common.segments import get_best_segments, archive_cut_segments, fast_cut_segments, full_cut_segments, smart_cut_segments, extract_frame, ContainsHoles, get_best_segments_for_frame
from common.images import compose_thumbnail_template from common.images import compose_thumbnail_template, get_template
from common.stats import timed from common.stats import timed
from .upload_backends import Youtube, Local, LocalArchive, UploadError from .upload_backends import Youtube, Local, LocalArchive, UploadError
@ -84,25 +84,6 @@ CutJob = namedtuple('CutJob', [
# params which map directly from DB columns # params which map directly from DB columns
] + CUT_JOB_PARAMS) ] + CUT_JOB_PARAMS)
def get_template(dbmanager, name, crop, location):
"""Fetch the thumbnail template and any missing parameters from the database"""
with dbmanager.get_conn() as conn:
query = """
SELECT image, crop, location FROM templates WHERE name = %s
"""
results = database.query(conn, query, name)
row = results.fetchone()
if row is None:
raise ValueError('Template {} not found'.format(name))
row = row._asdict()
if not crop:
crop = row['crop']
if not location:
location = row['location']
return row['image'], crop, location
def get_duration(job): def get_duration(job):
"""Get total video duration of a job, in seconds""" """Get total video duration of a job, in seconds"""
# Due to ranges and transitions, this is actually non-trivial to calculate. # Due to ranges and transitions, this is actually non-trivial to calculate.

@ -89,8 +89,14 @@ CREATE TABLE events (
OR thumbnail_mode = 'NONE' OR thumbnail_mode = 'NONE'
OR thumbnail_last_written IS NOT NULL OR thumbnail_last_written IS NOT NULL
), ),
thumbnail_crop INTEGER[], -- left, upper, right, and lower pixel coordinates to crop the selected frame thumbnail_crop INTEGER[] CHECK (
thumbnail_location INTEGER[], -- left, top, right, bottom pixel coordinates to position the cropped frame cardinality(thumbnail_crop) = 4
OR thumbnail_crop IS NULL
), -- left, upper, right, and lower pixel coordinates to crop the selected frame
thumbnail_location INTEGER[] CHECK (
cardinality(thumbnail_crop) = 4
OR thumbnail_crop IS NULL
), -- left, top, right, bottom pixel coordinates to position the cropped frame
state event_state NOT NULL DEFAULT 'UNEDITED', state event_state NOT NULL DEFAULT 'UNEDITED',
uploader TEXT CHECK (state IN ('UNEDITED', 'EDITED', 'DONE') OR uploader IS NOT NULL), uploader TEXT CHECK (state IN ('UNEDITED', 'EDITED', 'DONE') OR uploader IS NOT NULL),
@ -185,6 +191,8 @@ CREATE TABLE templates (
image BYTEA NOT NULL, image BYTEA NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
attribution TEXT NOT NULL DEFAULT '', attribution TEXT NOT NULL DEFAULT '',
crop INTEGER[] NOT NULL, crop INTEGER[] NOT NULL CHECK (
location INTEGER[] NOT NULL cardinality(thumbnail_crop) = 4),
location INTEGER[] NOT NULL CHECK (
cardinality(thumbnail_crop) = 4),
); );

@ -8,7 +8,6 @@ import os
import subprocess import subprocess
from uuid import uuid4 from uuid import uuid4
import base64
import gevent import gevent
import gevent.backdoor import gevent.backdoor
import gevent.event import gevent.event
@ -18,7 +17,7 @@ from gevent.pywsgi import WSGIServer
from common import database, dateutil, get_best_segments, rough_cut_segments, fast_cut_segments, full_cut_segments, PromLogCountsHandler, install_stacksampler, serve_with_graceful_shutdown from common import database, dateutil, get_best_segments, rough_cut_segments, fast_cut_segments, full_cut_segments, PromLogCountsHandler, install_stacksampler, serve_with_graceful_shutdown
from common.flask_stats import request_stats, after_request from common.flask_stats import request_stats, after_request
from common.images import compose_thumbnail_template from common.images import compose_thumbnail_template, get_template
from common.segments import smart_cut_segments, feed_input, render_segments_waveform, extract_frame, list_segment_files, get_best_segments_for_frame from common.segments import smart_cut_segments, feed_input, render_segments_waveform, extract_frame, list_segment_files, get_best_segments_for_frame
from common.chat import get_batch_file_range, merge_messages from common.chat import get_batch_file_range, merge_messages
from common.cached_iterator import CachedIterator from common.cached_iterator import CachedIterator
@ -480,43 +479,53 @@ def get_frame(channel, quality):
@app.route('/thumbnail/<channel>/<quality>.png') @app.route('/thumbnail/<channel>/<quality>.png')
@request_stats @request_stats
def get_thumbnail(channel, quality): @has_path_args
def get_thumbnail_named_template(channel, quality):
""" """
Returns a PNG image which is a preview of how a thumbnail will be generated. Returns a PNG image which is a preview of how a thumbnail will be generated.
Params:
timestamp: Required. The frame to use as the thumbnail image.
Must be in ISO 8601 format (ie. yyyy-mm-ddTHH:MM:SS) and UTC.
template: Required. The template name to use.
Must be one of the template names as returned by GET /thrimshim/templates
crop: Left, upper, right, and lower pixel coordinates to crop the selected frame.
Default is to use the crop in the database.
location: Left, top, right, bottom pixel coordinates to position the cropped frame.
Default is to use the location in the databse.
""" """
crop = request.args.get('crop', None)
location = request.args.get('location', None)
if app.db_manager is None:
return 'A database connection is required to generate thumbnails', 501
try:
template, crop, location = get_template(app.db_manager, request.args['template'], crop, location)
except ValueError:
return 'Template {} not found'.format(request.args['template']), 404
return get_thumbnail(channel, quality, request.args['timestamp'], template, crop, location)
template_params = request.json
if template_params['image']: @app.route('/thumbnail/<channel>/<quality>.png', methods=['POST'])
template = base64.b64decode(template_params['image']) @request_stats
@has_path_args
def get_thumbnail_uploaded_template(channel, quality):
"""
Returns a PNG image which is a preview of how a thumbnail will be generated.
Params:
timestamp: Required. The frame to use as the thumbnail image.
Must be in ISO 8601 format (ie. yyyy-mm-ddTHH:MM:SS) and UTC.
crop: Required. Left, upper, right, and lower pixel coordinates to crop the selected frame.
location: Required. Left, top, right, bottom pixel coordinates to position the cropped frame.
"""
template = request.body
return get_thumbnail(channel, quality, request.args['timestamp'], template, request.args['crop'], request.args['location'])
crop = template_params['crop']
location = template_params['location']
else: def get_thumbnail(channel, quality, timestamp, template, crop, location):
if app.db_manager is None: """
return 'A database connection is required to generate thumbnails', 501 Generates a PNG thumbnail by combining a frame at timestamp with the template.
"""
with app.db_manager.get_conn() as conn:
query = """
SELECT image, crop, location FROM templates WHERE name = %s
"""
results = database.query(conn, query, template_params['name'])
row = results.fetchone()
if row is None:
return 'Template {} not found'.format(template_params['name']), 404
row = row._asdict()
template = row['image']
if not template_params['crop']:
crop = row['crop']
else:
crop = template_params['crop']
if not template_params['location']:
location = row['location']
else:
location = template_params['location']
timestamp = dateutil.parse_utc_only(template_params['timestamp']) timestamp = dateutil.parse_utc_only(timestamp)
hours_path = os.path.join(app.static_folder, channel, quality) hours_path = os.path.join(app.static_folder, channel, quality)
if not os.path.isdir(hours_path): if not os.path.isdir(hours_path):

@ -46,77 +46,66 @@ def cors(app):
return app(environ, _start_response) return app(environ, _start_response)
return handle return handle
def authenticate_artist(f):
""""Authenticate a token against the database to authenticate an artist def check_user(request, role):
""""Authenticate a token against the database to authenticate a user.
Reference: https://developers.google.com/identity/sign-in/web/backend-auth""" Reference: https://developers.google.com/identity/sign-in/web/backend-auth"""
try:
userToken = request.json['token']
except (KeyError, TypeError):
return 'User token required', 401
# check whether token is valid
try:
idinfo = google.oauth2.id_token.verify_oauth2_token(userToken, google.auth.transport.requests.Request(), None)
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
raise ValueError('Wrong issuer.')
except ValueError:
return 'Invalid token. Access denied.', 403
# check whether user is in the database
email = idinfo['email'].lower()
conn = app.db_manager.get_conn()
query = """
SELECT email, %(role)s
FROM roles
WHERE lower(email) = %(email)s AND %(role)
"""
results = database.query(conn, query, email=email, role=role)
row = results.fetchone()
if row is None:
return 'Unknown user. Access denied.', 403
return email, 200
def authenticate_artist(f):
""""Authenticate an artist."""
@wraps(f) @wraps(f)
def artist_auth_wrapper(*args, **kwargs): def artist_auth_wrapper(*args, **kwargs):
if app.no_authentication: if app.no_authentication:
return f(*args, editor='NOT_AUTH', **kwargs) return f(*args, editor='NOT_AUTH', **kwargs)
try: message, code = check_user(flask.request, 'artist')
userToken = flask.request.json['token'] if code != 200:
except (KeyError, TypeError): return message, code
return 'User token required', 401
# check whether token is valid
try:
idinfo = google.oauth2.id_token.verify_oauth2_token(userToken, google.auth.transport.requests.Request(), None)
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
raise ValueError('Wrong issuer.')
except ValueError:
return 'Invalid token. Access denied.', 403
# check whether user is in the database
email = idinfo['email'].lower()
conn = app.db_manager.get_conn()
results = database.query(conn, """
SELECT email, artist
FROM roles
WHERE lower(email) = %s AND artist""", email)
row = results.fetchone()
if row is None:
return 'Unknown user. Access denied.', 403
return f(*args, editor=email, **kwargs) return f(*args, artist=message, **kwargs)
return artist_auth_wrapper return artist_auth_wrapper
def authenticate_editor(f): def authenticate_editor(f):
"""Authenticate a token against the database to authenticate an editor. """Authenticate an editor."""
Reference: https://developers.google.com/identity/sign-in/web/backend-auth"""
@wraps(f) @wraps(f)
def editor_auth_wrapper(*args, **kwargs): def editor_auth_wrapper(*args, **kwargs):
if app.no_authentication: if app.no_authentication:
return f(*args, editor='NOT_AUTH', **kwargs) return f(*args, editor='NOT_AUTH', **kwargs)
try: message, code = check_user(flask.request, 'editor')
userToken = flask.request.json['token'] if code != 200:
except (KeyError, TypeError): return message, code
return 'User token required', 401
# check whether token is valid
try:
idinfo = google.oauth2.id_token.verify_oauth2_token(userToken, google.auth.transport.requests.Request(), None)
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
raise ValueError('Wrong issuer.')
except ValueError:
return 'Invalid token. Access denied.', 403
# check whether user is in the database
email = idinfo['email'].lower()
conn = app.db_manager.get_conn()
results = database.query(conn, """
SELECT email, editor
FROM roles
WHERE lower(email) = %s AND editor""", email)
row = results.fetchone()
if row is None:
return 'Unknown user. Access denied.', 403
return f(*args, editor=email, **kwargs) return f(*args, editor=message, **kwargs)
return editor_auth_wrapper return editor_auth_wrapper
@ -515,7 +504,7 @@ def update_row(ident, editor=None):
_write_audit_log(conn, ident, "update-row", editor, old_row, new_row) _write_audit_log(conn, ident, "update-row", editor, old_row, new_row)
logging.info('Row {} updated to state {}'.format(ident, new_row['state'])) logging.info('Row {} updated to state {}'.format(ident, new_row['state']))
return '' return '', 201
@app.route('/thrimshim/manual-link/<ident>', methods=['POST']) @app.route('/thrimshim/manual-link/<ident>', methods=['POST'])
@ -651,18 +640,13 @@ def get_template_metadata(name):
return 'Template {} not found'.format(name), 404 return 'Template {} not found'.format(name), 404
return json.dumps(row._asdict()) return json.dumps(row._asdict())
def validate_template(new_template):
@app.route('/thrimshim/add-template', methods=['POST'])
@request_stats
def add_template(artist=None):
"""Add a template to the database"""
new_template = flask.request.json
columns = ['name', 'image', 'description', 'attribution', 'crop', 'location'] columns = ['name', 'image', 'description', 'attribution', 'crop', 'location']
#check for missing fields #check for missing fields
missing = set(columns) - set(new_template) missing = set(columns) - set(new_template)
if missing: if missing:
return 'Fields missing in JSON: {}'.format(', '.join(missing)), 400 return None, 'Fields missing in JSON: {}'.format(', '.join(missing)), 400
# delete any extras # delete any extras
extras = set(new_template) - set(columns) extras = set(new_template) - set(columns)
for extra in extras: for extra in extras:
@ -672,13 +656,24 @@ def add_template(artist=None):
try: try:
new_template['image'] = base64.b64decode(new_template['image']) new_template['image'] = base64.b64decode(new_template['image'])
except binascii.Error: except binascii.Error:
return 'Template image must be valid base64', 400 return None, 'Template image must be valid base64', 400
# check for PNG file header # check for PNG file header
if not new_template['thumbnail_image'].startswith(b'\x89PNG\r\n\x1a\n'): if not new_template['thumbnail_image'].startswith(b'\x89PNG\r\n\x1a\n'):
return 'Template image must be a PNG', 400 return None, 'Template image must be a PNG', 400
with app.db_manager.get_conn() as conn: return columns, new_template, 200
@app.route('/thrimshim/add-template', methods=['POST'])
@request_stats
def add_template(artist=None):
"""Add a template to the database"""
columns, message, code = validate_template(flask.request.json)
if code != 200:
return message, code
new_template = message
with app.db_manager.get_conn() as conn:
#check if name is already in the database #check if name is already in the database
query = sql.SQL(""" query = sql.SQL("""
SELECT name FROM events WHERE name = %s SELECT name FROM events WHERE name = %s
@ -696,32 +691,16 @@ def add_template(artist=None):
) )
database.query(conn, query, **new_template) database.query(conn, query, **new_template)
return '' return '', 201
@app.route('/thrimshim/update-template/<name>', methods=['POST']) @app.route('/thrimshim/update-template/<name>', methods=['POST'])
@request_stats @request_stats
def update_template(name, artist=None): def update_template(name, artist=None):
"""Update a template in the database""" """Update a template in the database"""
new_template = flask.request.json columns, message, code = validate_template(flask.request.json)
if code != 200:
columns = ['name', 'image', 'description', 'attribution', 'crop', 'location'] return message, code
#check for missing fields new_template = message
missing = set(columns) - set(new_template)
if missing:
return 'Fields missing in JSON: {}'.format(', '.join(missing)), 400
# delete any extras
extras = set(new_template) - set(columns)
for extra in extras:
del new_template[extra]
#convert and validate template image
try:
new_template['image'] = base64.b64decode(new_template['image'])
except binascii.Error:
return 'Template image must be valid base64', 400
# check for PNG file header
if not new_template['thumbnail_image'].startswith(b'\x89PNG\r\n\x1a\n'):
return 'Template image must be a PNG', 400
with app.db_manage.get_conn() as conn: with app.db_manage.get_conn() as conn:
#check if template is in database #check if template is in database
@ -769,7 +748,7 @@ def get_thumbnail(ident):
if event['thumbnail_mode'] != 'NONE' and event['thumbnail_image']: if event['thumbnail_mode'] != 'NONE' and event['thumbnail_image']:
return flask.Response(event['thumbnail_image'], mimetype='image/png') return flask.Response(event['thumbnail_image'], mimetype='image/png')
else: else:
return '', 200 return '', 404

Loading…
Cancel
Save