From 8100456ad1cba2f5dc5570a93008d4ef4f836ca9 Mon Sep 17 00:00:00 2001 From: rosborne132 Date: Wed, 1 Feb 2023 06:44:54 -0800 Subject: [PATCH 1/2] extract burnin files Signed-off-by: rosborne132 --- .codecov.yml | 2 - .../adapters/burnins.py | 73 ---- .../contrib_adapters.plugin_manifest.json | 6 - .../adapters/ffmpeg_burnins.py | 406 ------------------ docs/tutorials/adapters.md | 6 - docs/tutorials/otio-plugins.md | 22 - 6 files changed, 515 deletions(-) delete mode 100644 contrib/opentimelineio_contrib/adapters/burnins.py delete mode 100644 contrib/opentimelineio_contrib/adapters/ffmpeg_burnins.py diff --git a/.codecov.yml b/.codecov.yml index 683a1787e..0c0fad659 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -19,8 +19,6 @@ comment: ignore: - "contrib/opentimelineio_contrib/adapters/tests/test_rvsession.py" - - "contrib/opentimelineio_contrib/adapters/tests/test_burnins.py" - - "contrib/opentimelineio_contrib/adapters/burnins.py" - "*aaf2*" - "*pkg_resources*" - "*pbr*" diff --git a/contrib/opentimelineio_contrib/adapters/burnins.py b/contrib/opentimelineio_contrib/adapters/burnins.py deleted file mode 100644 index 56212753e..000000000 --- a/contrib/opentimelineio_contrib/adapters/burnins.py +++ /dev/null @@ -1,73 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -"""FFMPEG Burnins Adapter""" -import os -import sys - - -def build_burnins(input_otio): - """ - Generates the burnin objects for each clip within the otio container - - :param input_otio: OTIO container - :rtype: [ffmpeg_burnins.Burnins(), ...] - """ - - if os.path.dirname(__file__) not in sys.path: - sys.path.append(os.path.dirname(__file__)) - - import ffmpeg_burnins - key = 'burnins' - - burnins = [] - for clip in input_otio.find_clips(): - - # per clip burnin data - burnin_data = clip.media_reference.metadata.get(key) - if not burnin_data: - # otherwise default to global burnin - burnin_data = input_otio.metadata.get(key) - - if not burnin_data: - continue - - media = clip.media_reference.target_url - if media.startswith('file://'): - media = media[7:] - streams = burnin_data.get('streams') - burnins.append(ffmpeg_burnins.Burnins(media, - streams=streams)) - burnins[-1].otio_media = media - burnins[-1].otio_overwrite = burnin_data.get('overwrite') - burnins[-1].otio_args = burnin_data.get('args') - - for burnin in burnin_data.get('burnins', []): - align = burnin.pop('align') - function = burnin.pop('function') - if function == 'text': - text = burnin.pop('text') - options = ffmpeg_burnins.TextOptions() - options.update(burnin) - burnins[-1].add_text(text, align, options=options) - elif function == 'frame_number': - options = ffmpeg_burnins.FrameNumberOptions() - options.update(burnin) - burnins[-1].add_frame_numbers(align, options=options) - elif function == 'timecode': - options = ffmpeg_burnins.TimeCodeOptions() - options.update(burnin) - burnins[-1].add_timecode(align, options=options) - else: - raise RuntimeError("Unknown function '%s'" % function) - - return burnins - - -def write_to_file(input_otio, filepath): - """required OTIO function hook""" - - for burnin in build_burnins(input_otio): - burnin.render(os.path.join(filepath, burnin.otio_media), - args=burnin.otio_args, - overwrite=burnin.otio_overwrite) diff --git a/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json b/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json index ebf6f5ef0..e5568e8f4 100644 --- a/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json +++ b/contrib/opentimelineio_contrib/adapters/contrib_adapters.plugin_manifest.json @@ -1,12 +1,6 @@ { "OTIO_SCHEMA" : "PluginManifest.1", "adapters": [ - { - "OTIO_SCHEMA" : "Adapter.1", - "name" : "burnins", - "filepath" : "burnins.py", - "suffixes" : [] - }, { "OTIO_SCHEMA": "Adapter.1", "name": "xges", diff --git a/contrib/opentimelineio_contrib/adapters/ffmpeg_burnins.py b/contrib/opentimelineio_contrib/adapters/ffmpeg_burnins.py deleted file mode 100644 index 198621567..000000000 --- a/contrib/opentimelineio_contrib/adapters/ffmpeg_burnins.py +++ /dev/null @@ -1,406 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -""" -This module provides an interface to allow users to easily -build out an FFMPEG command with all the correct filters -for applying text (with a background) to the rendered media. -""" -import os -import sys -import json -from subprocess import Popen, PIPE -from PIL import ImageFont - - -def _is_windows(): - """ - queries if the current operating system is Windows - - :rtype: bool - """ - return sys.platform.startswith('win') or \ - sys.platform.startswith('cygwin') - - -def _system_font(): - """ - attempts to determine a default system font - - :rtype: str - """ - if _is_windows(): - font_path = os.path.join(os.environ['WINDIR'], 'Fonts') - fonts = ('arial.ttf', 'calibri.ttf', 'times.ttf') - elif sys.platform.startswith('darwin'): - font_path = '/System/Library/Fonts' - fonts = ('Menlo.ttc',) - else: - # assuming linux - font_path = '/usr/share/fonts/msttcorefonts' - fonts = ('arial.ttf', 'times.ttf', 'couri.ttf') - - system_font = None - backup = None - for font in fonts: - font = os.path.join(font_path, font) - if os.path.exists(font): - system_font = font - break - else: - if os.path.exists(font_path): - for each in os.listdir(font_path): - ext = os.path.splitext(each)[-1] - if ext[1:].startswith('tt'): - system_font = os.path.join(font_path, each) - return system_font or backup - - -# Default valuues -FONT = _system_font() -FONT_SIZE = 16 -FONT_COLOR = 'white' -BG_COLOR = 'black' -BG_PADDING = 5 - -# FFMPEG command strings -FFMPEG = ('ffmpeg -loglevel panic -i %(input)s ' - '%(filters)s %(args)s%(output)s') -FFPROBE = ('ffprobe -v quiet -print_format json -show_format ' - '-show_streams %(source)s') -BOX = 'box=1:boxborderw=%(border)d:boxcolor=%(color)s@%(opacity).1f' -DRAWTEXT = ("drawtext=text='%(text)s':x=%(x)s:y=%(y)s:fontcolor=" - "%(color)s@%(opacity).1f:fontsize=%(size)d:fontfile='%(font)s'") -TIMECODE = ("drawtext=timecode='%(text)s':timecode_rate=%(fps).2f" - ":x=%(x)s:y=%(y)s:fontcolor=" - "%(color)s@%(opacity).1f:fontsize=%(size)d:fontfile='%(font)s'") - - -# Valid aligment parameters. -TOP_CENTERED = 'top_centered' -BOTTOM_CENTERED = 'bottom_centered' -TOP_LEFT = 'top_left' -BOTTOM_LEFT = 'bottom_left' -TOP_RIGHT = 'top_right' -BOTTOM_RIGHT = 'bottom_right' - - -class Options(dict): - """ - Base options class. - """ - _params = { - 'opacity': 1, - 'x_offset': 0, - 'y_offset': 0, - 'font': FONT, - 'font_size': FONT_SIZE, - 'bg_color': BG_COLOR, - 'bg_padding': BG_PADDING, - 'font_color': FONT_COLOR - } - - def __init__(self, **kwargs): - super().__init__() - params = self._params.copy() - params.update(kwargs) - super().update(**params) - - def __setitem__(self, key, value): - if key not in self._params: - raise KeyError("Not a valid option key '%s'" % key) - super().update({key: value}) - - -class FrameNumberOptions(Options): - """ - :key int frame_offset: offset the frame numbers - :key float opacity: opacity value (0-1) - :key str expression: expression that would be used instead of text - :key bool x_offset: X position offset - (does not apply to centered alignments) - :key bool y_offset: Y position offset - :key str font: path to the font file - :key int font_size: size to render the font in - :key str bg_color: background color of the box - :key int bg_padding: padding between the font and box - :key str font_color: color to render - """ - - def __init__(self, **kwargs): - self._params.update({ - 'frame_offset': 0, - 'expression': None - }) - super().__init__(**kwargs) - - -class TextOptions(Options): - """ - :key float opacity: opacity value (0-1) - :key str expression: expression that would be used instead of text - :key bool x_offset: X position offset - (does not apply to centered alignments) - :key bool y_offset: Y position offset - :key str font: path to the font file - :key int font_size: size to render the font in - :key str bg_color: background color of the box - :key int bg_padding: padding between the font and box - :key str font_color: color to render - """ - - -class TimeCodeOptions(Options): - """ - :key int frame_offset: offset the frame numbers - :key float fps: frame rate to calculate the timecode by - :key float opacity: opacity value (0-1) - :key bool x_offset: X position offset - (does not apply to centered alignments) - :key bool y_offset: Y position offset - :key str font: path to the font file - :key int font_size: size to render the font in - :key str bg_color: background color of the box - :key int bg_padding: padding between the font and box - :key str font_color: color to render - """ - - def __init__(self, **kwargs): - self._params.update({ - 'frame_offset': 0, - 'fps': 24 - }) - super().__init__(**kwargs) - - -class Burnins: - """ - Class that provides convenience API for building filter - flags for the FFMPEG command. - """ - - def __init__(self, source, streams=None): - """ - :param str source: source media file - :param [] streams: ffprobe stream data if parsed as a pre-process - """ - self.source = source - self.filters = { - 'drawtext': [] - } - self._streams = streams or _streams(self.source) - - def __repr__(self): - return '' % os.path.basename(self.source) - - @property - def start_frame(self): - """ - :rtype: int - """ - start_time = float(self._video_stream['start_time']) - return round(start_time * self.frame_rate) - - @property - def end_frame(self): - """ - :rtype: int - """ - end_time = float(self._video_stream['duration']) - return round(end_time * self.frame_rate) - - @property - def frame_rate(self): - """ - :rtype: int - """ - data = self._video_stream - tokens = data['r_frame_rate'].split('/') - return int(tokens[0]) / int(tokens[1]) - - @property - def _video_stream(self): - video_stream = None - for each in self._streams: - if each.get('codec_type') == 'video': - video_stream = each - break - else: - raise RuntimeError("Failed to locate video stream " - "from '%s'" % self.source) - return video_stream - - @property - def resolution(self): - """ - :rtype: (int, int) - """ - data = self._video_stream - return data['width'], data['height'] - - @property - def filter_string(self): - """ - Generates the filter string that would be applied - to the `-vf` argument - - :rtype: str - """ - return ','.join(self.filters['drawtext']) - - def add_timecode(self, align, options=None): - """ - Convenience method to create the frame number expression. - - :param enum align: alignment, must use provided enum flags - :param dict options: recommended to use TimeCodeOptions - """ - options = options or TimeCodeOptions() - timecode = _frames_to_timecode(options['frame_offset'], - self.frame_rate) - options = options.copy() - if not options.get('fps'): - options['fps'] = self.frame_rate - self._add_burnin(timecode.replace(':', r'\:'), - align, - options, - TIMECODE) - - def add_frame_numbers(self, align, options=None): - """ - Convenience method to create the frame number expression. - - :param enum align: alignment, must use provided enum flags - :param dict options: recommended to use FrameNumberOptions - """ - options = options or FrameNumberOptions() - options['expression'] = r'%%{eif\:n+%d\:d}' % options['frame_offset'] - text = str(int(self.end_frame + options['frame_offset'])) - self._add_burnin(text, align, options, DRAWTEXT) - - def add_text(self, text, align, options=None): - """ - Adding static text to a filter. - - :param str text: text to apply to the drawtext - :param enum align: alignment, must use provided enum flags - :param dict options: recommended to use TextOptions - """ - options = options or TextOptions() - self._add_burnin(text, align, options, DRAWTEXT) - - def _add_burnin(self, text, align, options, draw): - """ - Generic method for building the filter flags. - - :param str text: text to apply to the drawtext - :param enum align: alignment, must use provided enum flags - :param dict options: - """ - resolution = self.resolution - data = { - 'text': options.get('expression') or text, - 'color': options['font_color'], - 'size': options['font_size'] - } - data.update(options) - data.update(_drawtext(align, resolution, text, options)) - if 'font' in data and _is_windows(): - data['font'] = data['font'].replace(os.sep, r'\\' + os.sep) - data['font'] = data['font'].replace(':', r'\:') - self.filters['drawtext'].append(draw % data) - - if options.get('bg_color') is not None: - box = BOX % { - 'border': options['bg_padding'], - 'color': options['bg_color'], - 'opacity': options['opacity'] - } - self.filters['drawtext'][-1] += ':%s' % box - - def command(self, output=None, args=None, overwrite=False): - """ - Generate the entire FFMPEG command. - - :param str output: output file - :param str args: additional FFMPEG arguments - :param bool overwrite: overwrite the output if it exists - :returns: completed command - :rtype: str - """ - output = output or '' - if overwrite: - output = '-y %s' % output - return (FFMPEG % { - 'input': self.source, - 'output': output, - 'args': '%s ' % args if args else '', - 'filters': '-vf "%s"' % self.filter_string - }).strip() - - def render(self, output, args=None, overwrite=False): - """ - Render the media to a specified destination. - - :param str output: output file - :param str args: additional FFMPEG arguments - :param bool overwrite: overwrite the output if it exists - """ - if not overwrite and os.path.exists(output): - raise RuntimeError("Destination '%s' exists, please " - "use overwrite" % output) - command = self.command(output=output, - args=args, - overwrite=overwrite) - proc = Popen(command, shell=True) - proc.communicate() - if proc.returncode != 0: - raise RuntimeError("Failed to render '%s': %s'" - % (output, command)) - if not os.path.exists(output): - raise RuntimeError("Failed to generate '%s'" % output) - - -def _streams(source): - """ - :param str source: source media file - :rtype: [{}, ...] - """ - command = FFPROBE % {'source': source} - proc = Popen(command, shell=True, stdout=PIPE) - out = proc.communicate()[0] - if proc.returncode != 0: - raise RuntimeError("Failed to run: %s" % command) - return json.loads(out)['streams'] - - -def _drawtext(align, resolution, text, options): - """ - :rtype: {'x': int, 'y': int} - """ - x_pos = '0' - if align in (TOP_CENTERED, BOTTOM_CENTERED): - x_pos = 'w/2-tw/2' - elif align in (TOP_RIGHT, BOTTOM_RIGHT): - ifont = ImageFont.truetype(options['font'], - options['font_size']) - box_size = ifont.getsize(text) - x_pos = resolution[0] - (box_size[0] + options['x_offset']) - elif align in (TOP_LEFT, BOTTOM_LEFT): - x_pos = options['x_offset'] - - if align in (TOP_CENTERED, - TOP_RIGHT, - TOP_LEFT): - y_pos = '%d' % options['y_offset'] - else: - y_pos = 'h-text_h-%d' % (options['y_offset']) - return {'x': x_pos, 'y': y_pos} - - -def _frames_to_timecode(frames, framerate): - return '{:02d}:{:02d}:{:02d}:{:02d}'.format( - int(frames / (3600 * framerate)), - int(frames / (60 * framerate) % 60), - int(frames / framerate % 60), - int(frames % framerate)) diff --git a/docs/tutorials/adapters.md b/docs/tutorials/adapters.md index 587a8b6aa..39eed7d72 100644 --- a/docs/tutorials/adapters.md +++ b/docs/tutorials/adapters.md @@ -50,12 +50,6 @@ The contrib area hosts adapters which come from the community (_not_ supported - set `${OTIO_RV_PYTHON_LIB}` to point at the parent directory of `rvSession.py`: `setenv OTIO_RV_PYTHON_LIB /Applications/RV64.app/Contents/src/python` -## Text Burn-in Adapter - -Uses FFmpeg to burn text overlays into video media. - -- Status: supported via the `burnins` adapter. - ## GStreamer Editing Services Adapter - Status: supported via the `xges` adapter. diff --git a/docs/tutorials/otio-plugins.md b/docs/tutorials/otio-plugins.md index 6219e7f35..3cc23f70f 100644 --- a/docs/tutorials/otio-plugins.md +++ b/docs/tutorials/otio-plugins.md @@ -240,28 +240,6 @@ Adapter plugins convert to and from OpenTimelineIO. [Tutorial on how to write an adapter](write-an-adapter). -### burnins - -``` -FFMPEG Burnins Adapter -``` - -*source*: `opentimelineio_contrib/adapters/burnins.py` - - -*Supported Features (with arguments)*: - -- write_to_file: -``` -required OTIO function hook -``` - - input_otio - - filepath - - - - - ### xges ``` From 4718e8720cb98488144edc0f99a1bd8549d67042 Mon Sep 17 00:00:00 2001 From: rosborne132 Date: Wed, 1 Feb 2023 07:41:25 -0800 Subject: [PATCH 2/2] remove test Signed-off-by: rosborne132 --- .../adapters/tests/test_burnins.py | 175 ------------------ 1 file changed, 175 deletions(-) delete mode 100644 contrib/opentimelineio_contrib/adapters/tests/test_burnins.py diff --git a/contrib/opentimelineio_contrib/adapters/tests/test_burnins.py b/contrib/opentimelineio_contrib/adapters/tests/test_burnins.py deleted file mode 100644 index 622ec5a11..000000000 --- a/contrib/opentimelineio_contrib/adapters/tests/test_burnins.py +++ /dev/null @@ -1,175 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright Contributors to the OpenTimelineIO project - -"""Unit tests for the rv session file adapter""" - -import unittest - -import opentimelineio as otio - -MODULE = otio.adapters.from_name('burnins').module() -SAMPLE_DATA = """{ - "OTIO_SCHEMA": "Timeline.1", - "metadata": { - "burnins": { - "overwrite": true, - "burnins": [ - { - "text": "Top Center", - "align": "top_centered", - "font": "/System/Library/Fonts/Menlo.ttc", - "font_size": 48, - "function": "text" - }, - { - "align": "top_left", - "x_offset": 75, - "font": "/System/Library/Fonts/Menlo.ttc", - "frame_offset": 101, - "font_size": 48, - "function": "frame_number" - } - ], - "streams": [ - { - "codec_type": "video", - "codec_name": "h264", - "width": 1920, - "height": 1080, - "r_frame_rate": "30/1", - "start_time": "0.000000", - "duration": "20.000000" - } - ] - } - }, - "name": "TEST.MOV", - "tracks": { - "OTIO_SCHEMA": "Stack.1", - "children": [ - { - "OTIO_SCHEMA": "Track.1", - "children": [ - { - "OTIO_SCHEMA": "Clip.1", - "effects": [], - "markers": [], - "media_reference": { - "OTIO_SCHEMA": "ExternalReference.1", - "available_range": { - "OTIO_SCHEMA": "TimeRange.1", - "duration": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": 30.0, - "value": 600.0 - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": 30.0, - "value": 0.0 - } - }, - "metadata": {}, - "name": null, - "target_url": "file://TEST.MOV" - }, - "metadata": {}, - "name": "TEST.MOV", - "source_range": null - } - ], - "effects": [], - "kind": "Video", - "markers": [], - "metadata": {}, - "name": "TEST.MOV", - "source_range": null - } - ], - "effects": [], - "markers": [], - "metadata": {}, - "name": "tracks", - "source_range": null - } -}""" -WITH_BG = ('ffmpeg -loglevel panic -i TEST.MOV -vf "drawtext=text=' - '\'Top Center\':x=w/2-tw/2:y=0:fontcolor=white@1.0:fontsize' - '=48:fontfile=\'/System/Library/Fonts/Menlo.ttc\':box=1:boxbord' - 'erw=5:boxcolor=black@1.0,drawtext=text=\'' - r'%{eif\:n+101\:d}' - '\':x=75:y=0:fontcolor=white@1.0:fontsize=48:fontfile=\'/Syst' - 'em/Library/Fonts/Menlo.ttc\':box=1:boxborderw=5:boxcolor=bla' - 'ck@1.0" TEST.MOV') - -WITHOUT_BG = ('ffmpeg -loglevel panic -i TEST.MOV -vf "drawtext=text=' - '\'Top Center\':x=w/2-tw/2:y=0:fontcolor=white@1.0:fontsize' - '=48:fontfile=\'/System/Library/Fonts/Menlo.ttc\',' - 'drawtext=text=\'' - r'%{eif\:n+101\:d}' - '\':x=75:y=0:fontcolor=white@1.0:fontsize=48:fontfile=\'/System' - '/Library/Fonts/Menlo.ttc\'" TEST.MOV') -TIMECODE = ('ffmpeg -loglevel panic -i TEST.MOV -vf "drawtext=timecode=' - '\'Top Center\':timecode_rate=24.00:x=w/2-tw/2:y=0:fontcolor=' - 'white@1.0:fontsize=48:fontfile=\'/System/Library/Fonts/Menlo.' - 'ttc\':box=1:boxborderw=5:boxcolor=black@1.0,drawtext=timecode=' - r"'00\:00\:00\:00':timecode_rate=24.00:x=75:y=0:fontcolor=" - 'white@1.0:fontsize=48:fontfile=\'/System/Library/Fonts/Menlo.' - 'ttc\':box=1:boxborderw=5:boxcolor=black@1.0" TEST.MOV') - - -try: - import PIL # noqa - from PIL.Image import core as imaging # noqa - could_import_pillow = True -except (ImportError, SyntaxError): - could_import_pillow = False - - -@unittest.skipIf( - not could_import_pillow, - "Pillow Required for burnin unit tests. see:" - " https://python-pillow.org/" -) -class FFMPEGBurninsTest(unittest.TestCase): - """Test Cases for FFMPEG Burnins""" - - def test_burnins_with_background(self): - """ - Tests creating burnins with a background (box) - """ - timeline = otio.adapters.read_from_string(SAMPLE_DATA, "otio_json") - burnins = MODULE.build_burnins(timeline) - self.assertEqual(len(burnins), 1) - command = burnins[-1].command(burnins[-1].otio_media) - self.assertEqual(command, WITH_BG) - - def test_burnins_without_background(self): - """ - Tests creating burnins without a background (box) - """ - timeline = otio.adapters.read_from_string(SAMPLE_DATA, "otio_json") - for each in timeline.metadata['burnins']['burnins']: - each['bg_color'] = None - burnins = MODULE.build_burnins(timeline) - self.assertEqual(len(burnins), 1) - command = burnins[-1].command(burnins[-1].otio_media) - self.assertEqual(command, WITHOUT_BG) - - def test_burnins_with_timecode(self): - """ - Tests creating burnins with an animated timecode - """ - timeline = otio.adapters.read_from_string(SAMPLE_DATA, "otio_json") - for each in timeline.metadata['burnins']['burnins']: - each['function'] = 'timecode' - each['frame_offset'] = 0 - each['fps'] = 24 - burnins = MODULE.build_burnins(timeline) - self.assertEqual(len(burnins), 1) - command = burnins[-1].command(burnins[-1].otio_media) - self.assertEqual(command, TIMECODE) - - -if __name__ == '__main__': - unittest.main()