diff --git a/tests/fixtures/videos/test_preview_tiles.mp4 b/tests/fixtures/videos/test_preview_tiles.mp4 new file mode 100644 index 0000000000..7aa7b27a3f Binary files /dev/null and b/tests/fixtures/videos/test_preview_tiles.mp4 differ diff --git a/tests/fixtures/videos/tile01.png b/tests/fixtures/videos/tile01.png new file mode 100644 index 0000000000..75bc837ed1 Binary files /dev/null and b/tests/fixtures/videos/tile01.png differ diff --git a/tests/tiles/__init__.py b/tests/tiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tiles/test_route_tiles.py b/tests/tiles/test_route_tiles.py new file mode 100644 index 0000000000..fcd98ca0af --- /dev/null +++ b/tests/tiles/test_route_tiles.py @@ -0,0 +1,63 @@ +import os + +from tests.base import ApiDBTestCase + +from zou.app.utils import fs + +from PIL import Image + +TEST_FOLDER = os.path.join("tests", "tmp") + + +class RouteTileTestCase(ApiDBTestCase): + def setUp(self): + super(RouteTileTestCase, self).setUp() + + self.delete_tile_folders() + self.generate_fixture_project_status() + self.generate_fixture_project() + self.generate_fixture_asset_type() + self.generate_fixture_asset() + self.generate_fixture_sequence() + self.generate_fixture_shot() + self.generate_fixture_department() + self.generate_fixture_task_type() + self.generate_fixture_task_status() + self.generate_fixture_task_status_wip() + self.generate_fixture_person() + self.generate_fixture_assigner() + self.generate_fixture_task() + self.generate_fixture_software() + self.generate_fixture_working_file() + self.generate_fixture_preview_file() + self.asset_id = self.asset.id + self.preview_file_id = self.preview_file.id + self.person_id = self.person.id + os.makedirs(TEST_FOLDER) + + def tearDown(self): + super(RouteTileTestCase, self).tearDown() + + self.delete_tile_folders() + + def delete_tile_folders(self): + fs.rm_rf(TEST_FOLDER) + + def test_extract_tile(self): + path = "/pictures/preview-files/%s" % self.preview_file_id + file_path_fixture = self.get_fixture_file_path( + "videos/test_preview_tiles.mp4" + ) + self.upload_file(path, file_path_fixture) + + path = "/actions/preview-files/%s/extract-tile" % self.preview_file_id + try: + self.get(path) + except Exception: + pass + path = "/movies/tiles/preview-files/%s.png" % self.preview_file_id + result_file_path = self.get_file_path("tile01.png") + self.download_file(path, result_file_path) + + result_image = Image.open(result_file_path) + self.assertEqual(result_image.size, (1424, 600)) diff --git a/tests/utils/preview01_tile.png b/tests/utils/preview01_tile.png new file mode 100644 index 0000000000..d6cf9a8e25 Binary files /dev/null and b/tests/utils/preview01_tile.png differ diff --git a/tests/utils/test_movie.py b/tests/utils/test_movie.py index 3cee4b0eb7..426749a478 100644 --- a/tests/utils/test_movie.py +++ b/tests/utils/test_movie.py @@ -2,6 +2,8 @@ import shutil import tempfile import unittest +from PIL import Image +import math from pathlib import Path from urllib import request @@ -123,3 +125,23 @@ def concat_testing(self, method, test_name): width_playlist, height_playlist = movie.get_movie_size(out) self.assertEqual(width, width_playlist) self.assertEqual(height, height_playlist) + + def test_create_tile(self): + video_path = "./tests/fixtures/videos/test_preview_tiles.mp4" + video_width, video_height = movie.get_movie_size(video_path) + tile_path = movie.generate_tile(video_path, movie_fps=25) + image = Image.open(tile_path) + img_width, img_height = image.size + + probe = ffmpeg.probe(video_path) + duration_in_seconds = float(probe['streams'][0]['duration']) + float_movie_fps = eval(probe['streams'][0]['r_frame_rate']) + duration_in_frames = int(duration_in_seconds * float_movie_fps) + rows = math.ceil((duration_in_frames / 8)) + + aspect_ratio = (video_width / video_height) + target_width = math.ceil(aspect_ratio * 100) + + os.remove(tile_path) + self.assertEqual(img_width, target_width * 8) + self.assertEqual(img_height, 100 * rows) diff --git a/tests/utils/test_tile.py b/tests/utils/test_tile.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zou/app/blueprints/crud/metadata_descriptor.py b/zou/app/blueprints/crud/metadata_descriptor.py index bdd29195db..b1fa20631e 100644 --- a/zou/app/blueprints/crud/metadata_descriptor.py +++ b/zou/app/blueprints/crud/metadata_descriptor.py @@ -56,7 +56,6 @@ def update_data(self, data, instance_id): Check if the data descriptor has a valid data_type and valid departments. """ - if "data_type" in data: types = [ type_name for type_name, label in METADATA_DESCRIPTOR_TYPES diff --git a/zou/app/blueprints/previews/__init__.py b/zou/app/blueprints/previews/__init__.py index dcc8dbd412..f00448c765 100644 --- a/zou/app/blueprints/previews/__init__.py +++ b/zou/app/blueprints/previews/__init__.py @@ -12,6 +12,7 @@ PreviewFileThumbnailSquareResource, PreviewFilePreviewResource, PreviewFileOriginalResource, + PreviewFileTileResource, CreateOrganisationThumbnailResource, OrganisationThumbnailResource, CreateProjectThumbnailResource, @@ -24,6 +25,7 @@ UpdateAnnotationsResource, UpdatePreviewPositionResource, ExtractFrameFromPreview, + ExtractTileFromPreview, ) routes = [ @@ -68,6 +70,10 @@ "/pictures/previews/preview-files/.png", PreviewFilePreviewResource, ), + ( + "/movies/tiles/preview-files/.png", + PreviewFileTileResource, + ), ( "/pictures/thumbnails/organisations/", CreateOrganisationThumbnailResource, @@ -101,7 +107,7 @@ SetMainPreviewResource, ), ( - "/data/preview-files//extract-frame", + "/actions/preview-files//extract-frame", ExtractFrameFromPreview, ), ( @@ -112,6 +118,10 @@ "/actions/preview-files//update-annotations", UpdateAnnotationsResource, ), + ( + "/actions/preview-files//extract-tile", + ExtractTileFromPreview, + ), ] blueprint = Blueprint("thumbnails", "thumbnails") api = configure_api_from_blueprint(blueprint, routes) diff --git a/zou/app/blueprints/previews/resources.py b/zou/app/blueprints/previews/resources.py index d8bebaf609..b82d5c10f3 100644 --- a/zou/app/blueprints/previews/resources.py +++ b/zou/app/blueprints/previews/resources.py @@ -218,12 +218,15 @@ def post(self, instance_id): original_file_name = ".".join(file_name_parts) if extension in ALLOWED_PICTURE_EXTENSION: - self.save_picture_preview(instance_id, uploaded_file) + metadada = self.save_picture_preview(instance_id, uploaded_file) preview_file = preview_files_service.update_preview_file( instance_id, { "extension": "png", "original_name": original_file_name, + "width": metadada["width"], + "height": metadada["height"], + "file_size": metadada["file_size"], "status": "ready", }, ) @@ -276,12 +279,17 @@ def save_picture_preview(self, instance_id, uploaded_file): tmp_folder, instance_id, uploaded_file ) file_size = fs.get_file_size(original_tmp_path) - preview_files_service.update_preview_file( - instance_id, {"file_size": file_size}, silent=True - ) - return preview_files_service.save_variants( + width, height = thumbnail_utils.get_dimensions(original_tmp_path) + preview_files_service.save_variants( instance_id, original_tmp_path ) + return { + "preview_file_id": instance_id, + "file_size": file_size, + "extension": "png", + "width": width, + "height": height, + } def save_movie_preview( self, preview_file_id, uploaded_file, normalize=True @@ -708,6 +716,11 @@ def __init__(self): BasePreviewPictureResource.__init__(self, "thumbnails") +class PreviewFileTileResource(BasePreviewPictureResource): + def __init__(self): + BasePreviewPictureResource.__init__(self, "tiles") + + class PreviewFilePreviewResource(BasePreviewPictureResource): """ Smaller version of uploaded image. @@ -1178,17 +1191,45 @@ def get(self, preview_file_id): preview_file = files_service.get_preview_file(preview_file_id) task = tasks_service.get_task(preview_file["task_id"]) user_service.check_manager_project_access(task["project_id"]) - user_service.check_entity_access(task["entity_id"]) extracted_frame_path = ( preview_files_service.extract_frame_from_preview_file( preview_file, args["frame_number"] ) ) + try: + return flask_send_file( + extracted_frame_path, + conditional=True, + mimetype="image/png", + as_attachment=False, + download_name=os.path.basename(extracted_frame_path), + ) + finally: + os.remove(extracted_frame_path) - return flask_send_file( - extracted_frame_path, - conditional=True, - mimetype="image/png", - as_attachment=False, - download_name=os.path.basename(extracted_frame_path), + +class ExtractTileFromPreview(Resource): + """ + Extract a tile from a preview_file + """ + + @jwt_required() + def get(self, preview_file_id): + preview_file = files_service.get_preview_file(preview_file_id) + task = tasks_service.get_task(preview_file["task_id"]) + user_service.check_manager_project_access(task["project_id"]) + user_service.check_entity_access(task["entity_id"]) + extracted_tile_path = ( + preview_files_service.extract_tile_from_preview_file(preview_file) ) + file_store.add_picture("tiles", preview_file_id, extracted_tile_path) + try: + return flask_send_file( + extracted_tile_path, + conditional=True, + mimetype="image/png", + as_attachment=False, + download_name=os.path.basename(extracted_tile_path), + ) + finally: + os.remove(extracted_tile_path) diff --git a/zou/app/models/preview_file.py b/zou/app/models/preview_file.py index 75177c61bb..9079c7dc23 100644 --- a/zou/app/models/preview_file.py +++ b/zou/app/models/preview_file.py @@ -39,6 +39,8 @@ class PreviewFile(db.Model, BaseMixin, SerializerMixin): ChoiceType(VALIDATION_STATUSES), default="neutral" ) annotations = db.Column(JSONB) + width = db.Column(db.Integer(), default=0) + height = db.Column(db.Integer(), default=0) task_id = db.Column( UUIDType(binary=False), db.ForeignKey("task.id"), index=True diff --git a/zou/app/services/playlists_service.py b/zou/app/services/playlists_service.py index 6a901fc3bd..0c8688f720 100644 --- a/zou/app/services/playlists_service.py +++ b/zou/app/services/playlists_service.py @@ -1,4 +1,5 @@ import base64 + import json import os import zlib @@ -173,6 +174,9 @@ def get_playlist_with_preview_file_revisions(playlist_id): if preview_file is not None: shot["preview_file_id"] = preview_file["id"] shot["preview_file_extension"] = preview_file["extension"] + shot["preview_file_revision"] = preview_file["revision"] + shot["preview_file_width"] = preview_file["width"] + shot["preview_file_height"] = preview_file["height"] shot["preview_file_status"] = preview_file["status"] shot["preview_file_annotations"] = preview_file["annotations"] shot["preview_file_task_id"] = preview_file["task_id"] @@ -242,6 +246,8 @@ def set_preview_files_for_entities(playlist_dict): "id": preview_file_id, "revision": preview_file.revision, "extension": preview_file.extension, + "width": preview_file.width, + "height": preview_file.height, "status": str(preview_file.status), "annotations": preview_file.annotations, "created_at": fields.serialize_value(preview_file.created_at), @@ -279,6 +285,8 @@ def get_preview_files_for_entity(entity_id): PreviewFile.position, PreviewFile.original_name, PreviewFile.extension, + PreviewFile.width, + PreviewFile.height, PreviewFile.status, PreviewFile.annotations, PreviewFile.created_at, @@ -300,6 +308,8 @@ def get_preview_files_for_entity(entity_id): preview_file_position, preview_file_original_name, preview_file_extension, + preview_file_width, + preview_file_height, preview_file_status, preview_file_annotations, preview_file_created_at, @@ -316,6 +326,8 @@ def get_preview_files_for_entity(entity_id): "position": preview_file_position, "original_name": preview_file_original_name, "extension": preview_file_extension, + "width": preview_file_width, + "height": preview_file_height, "status": preview_file_status, "annotations": preview_file_annotations, "created_at": preview_file_created_at, @@ -337,6 +349,8 @@ def get_preview_files_for_entity(entity_id): "revision": preview_file["revision"], "original_name": preview_file["original_name"], "extension": preview_file["extension"], + "width": preview_file["width"], + "height": preview_file["height"], "status": preview_file["status"], "annotations": preview_file["annotations"], "previews": preview_file["previews"], @@ -846,6 +860,9 @@ def generate_playlisted_entity_from_task(task_id): { "preview_file_id": preview_file["id"], "preview_file_extension": preview_file["extension"], + "preview_file_width": preview_file["width"], + "preview_file_height": preview_file["height"], + "preview_file_revision": preview_file["revision"], "preview_file_status": preview_file["status"], "preview_file_annotations": preview_file["annotations"], "preview_file_previews": preview_file["previews"], @@ -946,6 +963,8 @@ def _get_playlist_preview_file_list(preview_files): "id": str(preview_file.id), "revision": preview_file.revision, "extension": preview_file.extension, + "width": preview_file.width, + "height": preview_file.height, "status": str(preview_file.status), "annotations": preview_file.annotations, "created_at": fields.serialize_value(preview_file.created_at), diff --git a/zou/app/services/preview_files_service.py b/zou/app/services/preview_files_service.py index c002df9092..b6cd8d6764 100644 --- a/zou/app/services/preview_files_service.py +++ b/zou/app/services/preview_files_service.py @@ -1,4 +1,5 @@ import os + import re import time @@ -21,7 +22,10 @@ remote_job, thumbnail as thumbnail_utils, ) -from zou.app.services.exception import PreviewFileNotFoundException +from zou.app.services.exception import ( + ArgumentsException, + PreviewFileNotFoundException +) from zou.app.utils import fs @@ -245,12 +249,22 @@ def prepare_and_store_movie( # Build thumbnails size = movie.get_movie_size(normalized_movie_path) + width, height = size original_picture_path = movie.generate_thumbnail(normalized_movie_path) thumbnail_utils.turn_into_thumbnail(original_picture_path, size) save_variants(preview_file_id, original_picture_path) file_size = os.path.getsize(normalized_movie_path) current_app.logger.info("thumbnail created %s" % original_picture_path) + # Build tiles + try: + tile_path = movie.generate_tile(normalized_movie_path, fps) + file_store.add_picture("tiles", preview_file_id, tile_path) + os.remove(tile_path) + current_app.logger.info("tile created %s" % tile_path) + except Exception as exc: + current_app.logger.error("Failed to create tile", exc_info=1) + # Remove files and update status os.remove(uploaded_movie_path) if normalize: @@ -259,7 +273,12 @@ def prepare_and_store_movie( os.remove(normalized_movie_low_path) preview_file = update_preview_file_raw( - preview_file_raw, {"status": "ready", "file_size": file_size} + preview_file_raw, { + "status": "ready", + "file_size": file_size, + "width": width, + "height": height, + } ) return preview_file @@ -592,3 +611,136 @@ def replace_extracted_frame_for_preview_file(preview_file, frame_number): extracted_frame_path ) save_variants(preview_file["id"], extracted_frame_path) + + +def extract_tile_from_preview_file(preview_file): + project = get_project_from_preview_file(preview_file["id"]) + + if preview_file["extension"] == "mp4": + preview_file_path = fs.get_file_path_and_file( + config, + file_store.get_local_movie_path, + file_store.open_movie, + "previews", + preview_file["id"], + "mp4", + ) + fps = get_preview_file_fps(project) + extracted_tile_path = movie.generate_tile(preview_file_path, fps) + return extracted_tile_path + else: + return ArgumentsException("Preview file is not a movie") + + +def generate_tiles_for_movie_previews(): + """ + Generate tiles for all movie previews of open projects. + """ + preview_files = ( + PreviewFile.query.join(Task) + .join(Project) + .join(ProjectStatus) + .filter(ProjectStatus.name.in_(("Active", "open", "Open"))) + .filter(PreviewFile.status.not_in(("broken", "processing"))) + .filter(PreviewFile.extension == "mp4") + ) + for preview_file in preview_files: + try: + path = extract_tile_from_preview_file(preview_file.serialize()) + file_store.add_picture("tiles", str(preview_file.id), path) + print( + f"Tile generated preview file for {preview_file.id}", + ) + except Exception as e: + print( + "Failed to generate tile for preview file %s: %s", + str(preview_file.id), + e, + ) + return preview_files + + +def reset_movie_file_metadata(): + """ + Reset preview file size information of open projects. + """ + preview_files = ( + PreviewFile.query.join(Task) + .join(Project) + .join(ProjectStatus) + .filter(ProjectStatus.name.in_(("Active", "open", "Open"))) + .filter(PreviewFile.status.not_in(("broken", "processing"))) + .filter(PreviewFile.extension == "mp4") + ) + for preview_file in preview_files: + try: + preview_file_path = fs.get_file_path_and_file( + config, + file_store.get_local_movie_path, + file_store.open_movie, + "previews", + str(preview_file.id), + "mp4", + ) + size = movie.get_movie_size(preview_file_path) + file_size = os.path.getsize(preview_file_path) + width, height = size + update_preview_file_raw( + preview_file, { + "width": width, + "height": height, + "file_size": file_size, + } + ) + print( + f"Size information stored for {preview_file.id}", + ) + except Exception as e: + print( + "Failed to store information for preview file %s: %s", + str(preview_file.id), + e, + ) + + +def reset_picture_file_metadata(): + """ + Reset preview file size information of open projects. + """ + preview_files = ( + PreviewFile.query.join(Task) + .join(Project) + .join(ProjectStatus) + .filter(ProjectStatus.name.in_(("Active", "open", "Open"))) + .filter(PreviewFile.status.not_in(("broken", "processing"))) + .filter(PreviewFile.extension == "png") + ) + for preview_file in preview_files: + try: + preview_file_path = fs.get_file_path_and_file( + config, + file_store.get_local_picture_path, + file_store.open_picture, + "original", + str(preview_file.id), + "png", + ) + width, height = thumbnail_utils.get_dimensions(preview_file_path) + file_size = os.path.getsize(preview_file_path) + update_preview_file_raw( + preview_file, { + "width": width, + "height": height, + "file_size": file_size, + } + ) + print( + f"Size information stored for {preview_file.id}", + ) + except Exception as e: + print( + "Failed to store information for preview file %s: %s", + str(preview_file.id), + e, + ) + diff --git a/zou/app/services/tasks_service.py b/zou/app/services/tasks_service.py index 04155d7143..8d8cfd970c 100644 --- a/zou/app/services/tasks_service.py +++ b/zou/app/services/tasks_service.py @@ -661,6 +661,8 @@ def _build_preview_map_for_comments(comment_ids, is_client=False): "task_id": str(preview.task_id), "revision": preview.revision, "extension": preview.extension, + "width": preview.width, + "height": preview.height, "status": status, "validation_status": validation_status, "original_name": preview.original_name, diff --git a/zou/app/utils/commands.py b/zou/app/utils/commands.py index 92406c74db..3da5c43cf6 100644 --- a/zou/app/utils/commands.py +++ b/zou/app/utils/commands.py @@ -16,6 +16,7 @@ edits_service, index_service, persons_service, + preview_files_service, projects_service, shots_service, sync_service, @@ -623,3 +624,18 @@ def search_asset(query): for asset in assets: print(asset["name"], asset["id"]) return assets + + +def generate_tiles(): + with app.app_context(): + preview_files_service.generate_tiles_for_movie_previews() + + +def reset_movie_file_metadata(): + with app.app_context(): + preview_files_service.reset_movie_file_metadata() + + +def reset_picture_file_metadata(): + with app.app_context(): + preview_files_service.reset_picture_file_metadata() diff --git a/zou/app/utils/thumbnail.py b/zou/app/utils/thumbnail.py index 176eb35910..1acbd49710 100644 --- a/zou/app/utils/thumbnail.py +++ b/zou/app/utils/thumbnail.py @@ -93,6 +93,14 @@ def fit_to_target_size(im, size): return im +def get_dimensions(file_path): + """ + Return dimensions of given picture (width and height). + """ + im = Image.open(file_path) + return im.size + + def turn_into_thumbnail(file_path, size=None): """ Turn given picture into a smaller version. diff --git a/zou/cli.py b/zou/cli.py index 8b3684b2f8..eaf55ca06f 100755 --- a/zou/cli.py +++ b/zou/cli.py @@ -1,4 +1,5 @@ #!/usr/bin/env python + import os import sys import flask_migrate @@ -459,5 +460,29 @@ def search_asset(query): commands.search_asset(query) +@cli.command() +def generate_tiles(): + """ + Generate tiles for all movie previews in the database. + """ + commands.generate_tiles() + + +@cli.command() +def reset_movie_file_metadata(): + """ + Store height and width metadata for all movie previews in the database. + """ + commands.reset_movie_file_metadata() + + +@cli.command() +def reset_picture_file_metadata(): + """ + Store height and width metadata for all picture previews in the database. + """ + commands.reset_picture_file_metadata() + + if __name__ == "__main__": cli() diff --git a/zou/utils/movie.py b/zou/utils/movie.py index 133a2c142d..57767a9fd0 100644 --- a/zou/utils/movie.py +++ b/zou/utils/movie.py @@ -111,17 +111,40 @@ def get_all_frames(movie_path): .output(file_target_path, vsync=0) .run(quiet=True) ) - except subprocess.CalledProcessError as e: + except ffmpeg._run.Error as e: print(f"Error generating thumbnails: {e}") raise e return folder_path -def generate_tile(movie_path): +def generate_tile(movie_path, movie_fps): """ - ffmpeg -i {movie_path} -vf 'scale=150:100,tile=8x8' -an -vsync 0 tile%03d.png + Generates a tile from a movie. """ + folder_path = os.path.dirname(movie_path) + file_source_name = os.path.basename(movie_path) + file_target_name = f"{file_source_name[:-4]}_tile.png" + file_target_path = os.path.join(folder_path, file_target_name) + + probe = ffmpeg.probe(movie_path) + duration_in_seconds = float(probe["streams"][0]["duration"]) + float_movie_fps = float(movie_fps) + duration_in_frames = int(duration_in_seconds * float_movie_fps) + rows = min(math.ceil(duration_in_frames / 8), 240) + ratio = get_movie_ratio(movie_path) + height = 100 + width = math.ceil(height * ratio) + + try: + ffmpeg.input(movie_path).output( + file_target_path, vf=f"scale={width}:{height},tile=8x{rows}" + ).run(quiet=True) + except ffmpeg._run.Error as e: + log_ffmpeg_error(e, "An error occured while generating the tile.") + raise e + + return file_target_path def get_movie_size(movie_path): @@ -149,6 +172,14 @@ def get_movie_size(movie_path): return (width, height) +def get_movie_ratio(movie_path): + """ + Returns movie ratio (width / height). + """ + width, height = get_movie_size(movie_path) + return width / height + + def normalize_encoding( movie_path, task, file_target_path, fps, b, width, height, keyframes=1 ):