diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index baee29e5..9733107b 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -495,6 +495,34 @@ def detect_threshold_command(ctx, threshold, min_scene_len, fade_bias, add_last_ threshold=threshold, min_scene_len=min_scene_len, fade_bias=fade_bias, add_final_scene=add_last_scene, min_percent=min_percent, block_size=block_size)) + +@click.command('export-html', add_help_option=False) +@click.option( + '--filename', '-f', metavar='NAME', default='$VIDEO_NAME-Scenes.html', + type=click.STRING, show_default=True, help= + 'Filename format to use for the scene list html file. You can use the' + ' $VIDEO_NAME macro in the file name.') +@click.option( + '--no-images', is_flag=True, flag_value=True, help= + 'Export the scene list including or excluding the saved images.') +@click.option( + '--image-width', '-w', metavar='pixels', + type=click.INT, help= + 'Width in pixels of the images in the resulting html table.') +@click.option( + '--image-height', '-h', metavar='pixels', + type=click.INT, help= + 'Height in pixels of the images in the resulting html table.') +@click.pass_context +def export_html_command(ctx, filename, no_images, image_width, image_height): + """ Exports scene list to a html file. Can also include scene images.""" + if not ctx.obj.save_images and not no_images: + raise click.BadParameter("save-images isn't enabled") + ctx.obj.export_html_command(filename, no_images, image_width, image_height) + ctx.obj.export_html = True + + + @click.command('list-scenes', add_help_option=False) @click.option( '--output', '-o', metavar='DIR', @@ -716,3 +744,5 @@ def colors_command(ctx): add_cli_command(scenedetect_cli, save_images_command) add_cli_command(scenedetect_cli, split_video_command) +add_cli_command(scenedetect_cli, export_html_command) + diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py index a0e8fb51..1f85557e 100644 --- a/scenedetect/cli/context.py +++ b/scenedetect/cli/context.py @@ -49,6 +49,7 @@ from scenedetect.scene_manager import SceneManager from scenedetect.scene_manager import write_scene_list +from scenedetect.scene_manager import write_scene_list_html from scenedetect.stats_manager import StatsManager from scenedetect.stats_manager import StatsFileCorrupt @@ -130,6 +131,13 @@ def __init__(self): self.scene_list_name_format = None # list-scenes -f/--filename self.scene_list_output = False # list-scenes -n/--no-output + self.export_html = False # export-html command + self.html_name_format = None # export-html -f/--filename + self.html_include_images = True # export-html --no-images + self.image_filenames = None # export-html used for embedding images + self.image_width = None # export-html -w/--image-width + self.image_height = None # export-html -h/--image-height + def cleanup(self): # type: () -> None @@ -181,9 +189,11 @@ def _generate_images(self, scene_list, video_name, image_num_format += str(math.floor(math.log(self.num_images, 10)) + 2) + 'd' timecode_list = dict() + self.image_filenames = dict() for i in range(len(scene_list)): timecode_list[i] = [] + self.image_filenames[i] = [] if self.num_images == 1: for i, (start_time, end_time) in enumerate(scene_list): @@ -212,14 +222,16 @@ def _generate_images(self, scene_list, video_name, self.video_manager.grab() ret_val, frame_im = self.video_manager.retrieve() if ret_val: + file_path = '%s.%s' % (filename_template.safe_substitute( + VIDEO_NAME=video_name, + SCENE_NUMBER=scene_num_format % (i + 1), + IMAGE_NUMBER=image_num_format % (j + 1)), + self.image_extension) + self.image_filenames[i].append(file_path) cv2.imwrite( - self.get_output_file_path( - '%s.%s' % (filename_template.safe_substitute( - VIDEO_NAME=video_name, - SCENE_NUMBER=scene_num_format % (i+1), - IMAGE_NUMBER=image_num_format % (j+1) - ), self.image_extension), - output_dir=output_dir), frame_im, imwrite_param) + self.get_output_file_path(file_path, + output_dir=output_dir), + frame_im, imwrite_param) else: completed = False break @@ -399,6 +411,22 @@ def process_input(self): image_name_template=self.image_name_format, output_dir=self.image_directory) + # Handle export-html command. + if self.export_html: + html_filename = Template(self.html_name_format).safe_substitute( + VIDEO_NAME=video_name) + if not html_filename.lower().endswith('.html'): + html_filename += '.html' + html_path = self.get_output_file_path( + html_filename, self.image_directory) + logging.info('Exporting to html file:\n %s:', html_path) + if not self.html_include_images: + self.image_filenames = None + write_scene_list_html(html_path, scene_list, cut_list, + image_filenames=self.image_filenames, + image_width=self.image_width, + image_height=self.image_height) + # Handle split-video command. if self.split_video: # Add proper extension to filename template if required. @@ -618,6 +646,25 @@ def list_scenes_command(self, output_path, filename_format, no_output_mode, quie logging.info('Scene list output directory set:\n %s', self.scene_list_directory) + def export_html_command(self, filename, no_images, image_width, image_height): + # type: (str, bool) -> None + """Export HTML command: Parses all options/arguments passed to the export-html command, + or with respect to the CLI, this function processes [export-html] options when calling: + scenedetect [global options] export-html [export-html options] [other commands...]. + + Raises: + click.BadParameter + """ + self.check_input_open() + + self.html_name_format = filename + if self.html_name_format is not None: + logging.info('Scene list html file name format:\n %s', self.html_name_format) + self.html_include_images = False if no_images else True + self.image_width = image_width + self.image_height = image_height + + def save_images_command(self, num_images, output, name_format, jpeg, webp, quality, png, compression): # type: (int, str, str, bool, bool, int, bool, int) -> None diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 34fee38f..f8779157 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -60,6 +60,10 @@ from scenedetect.platform import get_csv_writer from scenedetect.stats_manager import FrameMetricRegistered +from scenedetect.simpletable import SimpleTableCell, SimpleTableImage +from scenedetect.simpletable import SimpleTableRow, SimpleTable, HTMLPage + + ## ## SceneManager Helper Functions @@ -136,6 +140,96 @@ def write_scene_list(output_csv_file, scene_list, cut_list=None): '%d' % duration.get_frames(), duration.get_timecode(), '%.3f' % duration.get_seconds()]) +def write_scene_list_html(output_html_filename, scene_list, cut_list=None, css=None, css_class='mytable', + image_filenames=None, image_width=None, image_height=None): + """Writes the given list of scenes to an output file handle in html format. + + Arguments: + output_html_filename: filename of output html file + scene_list: List of pairs of FrameTimecodes denoting each scene's start/end FrameTimecode. + cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames + in the video that need to be split to generate individual scenes). If not passed, + the start times of each scene (besides the 0th scene) is used instead. + css: String containing all the css information for the resulting html page. + css_class: String containing the named css class + image_filenames: dict where key i contains a list with n elements (filenames of + the n saved images from that scene) + image_width: Optional desired width of images in table in pixels + image_height: Optional desired height of images in table in pixels + """ + if not css: + css = """ + table.mytable { + font-family: times; + font-size:12px; + color:#000000; + border-width: 1px; + border-color: #eeeeee; + border-collapse: collapse; + background-color: #ffffff; + width=100%; + max-width:550px; + table-layout:fixed; + } + table.mytable th { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + background-color: #e6eed6; + color:#000000; + } + table.mytable td { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + } + #code { + display:inline; + font-family: courier; + color: #3d9400; + } + #string { + display:inline; + font-weight: bold; + } + """ + + # Output Timecode list + timecode_table = SimpleTable([["Timecode List:"] + + (cut_list if cut_list else [start.get_timecode() for start, _ in scene_list[1:]])], + css_class=css_class) + + # Output list of scenes + header_row = ["Scene Number", "Start Frame", "Start Timecode", "Start Time (seconds)","End Frame", + "End Timecode", "End Time (seconds)", "Length (frames)", "Length (timecode)", "Length (seconds)"] + for i, (start, end) in enumerate(scene_list): + duration = end - start + + row = SimpleTableRow([ + '%d' % (i+1), + '%d' % start.get_frames(), start.get_timecode(), '%.3f' % start.get_seconds(), + '%d' % end.get_frames(), end.get_timecode(), '%.3f' % end.get_seconds(), + '%d' % duration.get_frames(), duration.get_timecode(), '%.3f' % duration.get_seconds()]) + + if image_filenames: + for image in image_filenames[i]: + row.add_cell(SimpleTableCell(SimpleTableImage(image, width=image_width, height=image_height))) + + if i == 0: + scene_table = SimpleTable(rows=[row], header_row=header_row, css_class=css_class) + else: + scene_table.add_row(row=row) + + # Write html file + page = HTMLPage() + page.add_table(timecode_table) + page.add_table(scene_table) + page.css = css + page.save(output_html_filename) + + ## ## SceneManager Class Implementation ## diff --git a/scenedetect/simpletable.py b/scenedetect/simpletable.py new file mode 100644 index 00000000..a5a7f53a --- /dev/null +++ b/scenedetect/simpletable.py @@ -0,0 +1,362 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +""" +simpletable.py - v0.1 2014-07-31 Matheus Vieira Portela + +This module provides simple classes and interfaces to generate simple HTML +tables based on Python native types, such as lists. + +Author's website: http://matheusvportela.wordpress.com/ + +v0.4 2019-05-24 by Walter Schwenger +""" + +__version__ = '0.4' +__date__ = '2019-05-24' +__author__ = 'Matheus Vieira Portela' + +### CHANGES ### +# 2014-07-31: v0.1 MVP: +# - First version +# 2014-08-05: v0.2 MVP: +# - Method for defining header rows +# - SimpleTable method to create a SimpleTable from lists +# - Method to create a table from a simple list of elements and a column size +# 2014-08-20: v0.3 MVP: +# - Enable SimplePage to accept a list of tables +# - Enable SimplePage to iterate over its tables +# 2019-05-24: v0.4 WS: +# - Added SimpleTableImage class to handle adding images to tables +# - Added test images and image example to __main__ + +### TODO ### +# + +### REFERENCES ### +# Decalage HTML.py module: http://www.decalage.info/python/html + +import codecs + + +class SimpleTableCell(object): + """A table class to create table cells. + + Example: + cell = SimpleTableCell('Hello, world!') + """ + + def __init__(self, text, header=False): + """Table cell constructor. + + Keyword arguments: + text -- text to be displayed + header -- flag to indicate this cell is a header cell. + """ + self.text = text + self.header = header + + def __str__(self): + """Return the HTML code for the table cell.""" + if self.header: + return '%s' %(self.text) + else: + return '%s' %(self.text) + + +class SimpleTableImage(object): + """A table class to create table cells with an image. + + Example: + cell = SimpleTableImage('images/image_1.jpg') + """ + + def __init__(self, image_file, width=None, height=None): + """Table cell constructor. + + Keyword arguments: + image_file -- relative filepath to image file to display. + width -- (optional) width of the image in pixels + height -- (optional) height of the image in pixels + """ + self.image_file = image_file + if width: + self.width=round(width) + else: + self.width=width + if height: + self.height=round(height) + else: + self.height=height + + def __str__(self): + """Return the HTML code for the table cell with the image.""" + output = '') + + for cell in self.cells: + row.append(str(cell)) + + row.append('') + + return '\n'.join(row) + + def __iter__(self): + """Iterate through row cells""" + for cell in self.cells: + yield cell + + def add_cell(self, cell): + """Add a SimpleTableCell object to the list of cells.""" + self.cells.append(cell) + + def add_cells(self, cells): + """Add a list of SimpleTableCell objects to the list of cells.""" + for cell in cells: + self.cells.append(cell) + + +class SimpleTable(object): + """A table class to create HTML tables, populated by HTML table rows. + + Example: + # Table from lists + table = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']]) + + # Table with header row + table = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']], + header_row=['Header1', 'Header2', 'Header3']) + + # Table from SimpleTableRow + rows = SimpleTableRow(['Hello,', 'world!']) + table = SimpleTable(rows) + """ + def __init__(self, rows=[], header_row=None, css_class=None): + """Table constructor. + + Keyword arguments: + rows -- iterable of SimpleTableRow + header_row -- row that will be displayed at the beginning of the table. + if this row is SimpleTableRow, it is the programmer's + responsibility to verify whether it was created with the + header flag set to True. + css_class -- table CSS class + """ + if isinstance(rows[0], SimpleTableRow): + self.rows = rows + else: + self.rows = [SimpleTableRow(row) for row in rows] + + if header_row is None: + self.header_row = None + elif isinstance(header_row, SimpleTableRow): + self.header_row = header_row + else: + self.header_row = SimpleTableRow(header_row, header=True) + + self.css_class = css_class + + def __str__(self): + """Return the HTML code for the table as a string.""" + table = [] + + if self.css_class: + table.append('' % self.css_class) + else: + table.append('
') + + if self.header_row: + table.append(str(self.header_row)) + + for row in self.rows: + table.append(str(row)) + + table.append('
') + + return '\n'.join(table) + + def __iter__(self): + """Iterate through table rows""" + for row in self.rows: + yield row + + def add_row(self, row): + """Add a SimpleTableRow object to the list of rows.""" + self.rows.append(row) + + def add_rows(self, rows): + """Add a list of SimpleTableRow objects to the list of rows.""" + for row in rows: + self.rows.append(row) + + +class HTMLPage(object): + """A class to create HTML pages containing CSS and tables.""" + def __init__(self, tables=[], css=None, encoding="utf-8"): + """HTML page constructor. + + Keyword arguments: + tables -- List of SimpleTable objects + css -- Cascading Style Sheet specification that is appended before the + table string + encoding -- Characters encoding. Default: UTF-8 + """ + self.tables = tables + self.css = css + self.encoding = encoding + + def __str__(self): + """Return the HTML page as a string.""" + page = [] + + if self.css: + page.append('' % self.css) + + # Set encoding + page.append('' % self.encoding) + + for table in self.tables: + page.append(str(table)) + page.append('
') + + return '\n'.join(page) + + def __iter__(self): + """Iterate through tables""" + for table in self.tables: + yield table + + def save(self, filename): + """Save HTML page to a file using the proper encoding""" + with codecs.open(filename, 'w', self.encoding) as outfile: + for line in str(self): + outfile.write(line) + + def add_table(self, table): + """Add a SimpleTable to the page list of tables""" + self.tables.append(table) + + +def fit_data_to_columns(data, num_cols): + """Format data into the configured number of columns in a proper format to + generate a SimpleTable. + + Example: + test_data = [str(x) for x in range(20)] + fitted_data = fit_data_to_columns(test_data, 5) + table = SimpleTable(fitted_data) + """ + num_iterations = len(data)/num_cols + + if len(data)%num_cols != 0: + num_iterations += 1 + + return [data[num_cols*i:num_cols*i + num_cols] for i in range(num_iterations)] + + +### Example usage ### +if __name__ == "__main__": + css = """ + table.mytable { + font-family: times; + font-size:12px; + color:#000000; + border-width: 1px; + border-color: #eeeeee; + border-collapse: collapse; + background-color: #ffffff; + width=100%; + max-width:550px; + table-layout:fixed; + } + table.mytable th { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + background-color: #e6eed6; + color:#000000; + } + table.mytable td { + border-width: 1px; + padding: 8px; + border-style: solid; + border-color: #eeeeee; + } + #code { + display:inline; + font-family: courier; + color: #3d9400; + } + #string { + display:inline; + font-weight: bold; + } + """ + table1 = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']], + header_row=['Header1', 'Header2', 'Header3'], + css_class='mytable') + table2 = SimpleTable([['Testing', 'this'], ['table', 'here']], + css_class='mytable') + + image_list = [] + image_list.append(SimpleTableImage('images/image_1.jpg')) + image_list.append(SimpleTableImage('images/image_2.jpg', width=200)) + image_list.append(SimpleTableImage('images/image_3.jpg', height=200)) + image_list.append(SimpleTableImage('images/image_4.jpg', width=200, height=200)) + + table3 = SimpleTable([image_list], + header_row=['No Size Specified', + 'width=200', + 'height=200', + 'width=200, height=200'], + css_class='mytable') + + page = HTMLPage() + page.add_table(table1) + page.add_table(table2) + page.add_table(table3) + page.css = css + page.save("test.html") \ No newline at end of file