class Subconv::Scc::Transformer
Transform an array of caption grids parsed from SCC into an array of captions with the caption content converted to a tree of text and style nodes
Constants
- PROPERTIES
Properties in order of priority (first element has the highest priority) The priority indicates in what order new style nodes should be created when their order would be indeterminate otherwise. This is required for getting deterministic output.
- PROPERTY_CLASS_MAP
Map of properties to the corresponding Ruby class
Public Instance Methods
Transform paint-on captions to pop-on captions by combining successive captions that only add text. As soon as text is removed or text that is already on-screen is changed, a caption is produced.
# File lib/subconv/scc/transformer.rb, line 64 def combine_paint_on_captions(captions) first_paint_on_caption = nil last_paint_on_caption = nil # Insert nil pseudo-element at end for flushing (captions + [nil]).each_with_object([]) do |caption, result_captions| if caption&.paint_on_mode? first_paint_on_caption ||= caption # Detect when characters disappear/change; until then: skip all paint-on captions (that just add text). # At the same time, always produce a cue for empty grids so explicit display clears do not get lost. # Simple character replacement cues that replace standard characters with extended ones should also never trigger a new caption. if !last_paint_on_caption.nil? && !caption.char_replacement? && (!require_grid_object(last_paint_on_caption.grid).without_identical_characters(require_grid_object(caption.grid)).empty? || last_paint_on_caption.grid.nil?) # Take timecode from the first caption of the current batch, but the grid from the current caption result_captions << Scc::Caption.new(timecode: first_paint_on_caption.timecode, grid: last_paint_on_caption.grid, mode: :pop_on) # Caption produced, so this marks a new segment first_paint_on_caption = caption end last_paint_on_caption = caption else # Flush out last paint-on caption if necessary result_captions << Scc::Caption.new(timecode: first_paint_on_caption.timecode, grid: last_paint_on_caption.grid, mode: :pop_on) unless first_paint_on_caption.nil? first_paint_on_caption = nil last_paint_on_caption = nil result_captions << caption unless caption.nil? end end end
Perform the transformation Continuous text blocks are collected in each caption grid and merged Empty grids will end the previously displayed caption
# File lib/subconv/scc/transformer.rb, line 14 def transform(captions) transformed_captions = [] return [] if captions.empty? # Use fps from Scc fps = captions.first.timecode.fps last_time = Timecode.new(0, fps) captions_open = [] captions.each do |caption| if caption.grid.nil? || !captions_open.empty? # Close any captions that might be displayed captions_open.each do |caption_to_close| caption_to_close.timespan = Utility::Timespan.new(last_time.dup, caption.timecode.dup) end transformed_captions.concat captions_open # All captions are closed now captions_open = [] end # Collect text chunks in each row and create captions out of them caption.grid&.each_with_index do |row, row_number| chunks = collect_chunks(row) chunks.each_pair do |start_column, chunk| content = transform_chunk(chunk) position = position_from_grid(row_number, start_column) captions_open.push(Subconv::Caption.new( align: :start, position: position, content: content )) end end last_time = caption.timecode end unless captions_open.empty? # Close any captions that are still open at the end captions_open.each do |caption_to_close| caption_to_close.timespan = Utility::Timespan.new(last_time.dup, last_time + Timecode.from_seconds(5, fps)) end transformed_captions.concat captions_open end transformed_captions end
Private Instance Methods
Collect all continuous character groups in a row Input: Grid
row as array of Scc::Character instances Output: Hash with the starting column index as key and Scc::Character array as value
# File lib/subconv/scc/transformer.rb, line 120 def collect_chunks(row) chunks = {} collecting = false start_column = 0 current_chunk = [] row.each_with_index do |column, index| if collecting if column.nil? # Stop collecting, write out chunk collecting = false chunks[start_column] = current_chunk current_chunk = [] else # Stay collecting current_chunk.push(column) end else unless column.nil? # Start collecting collecting = true current_chunk.push(column) # Remember first column start_column = index end end end # Write out last chunk if still open chunks[start_column] = current_chunk if collecting chunks end
Get the highest possible property priority
# File lib/subconv/scc/transformer.rb, line 105 def highest_property_priority PROPERTIES.length - 1 end
Get the Ruby class for a given property (symbol)
# File lib/subconv/scc/transformer.rb, line 251 def node_class_for_property(property) PROPERTY_CLASS_MAP.fetch(property) end
Create a Ruby node instance from a given property (symbol) and the property value
# File lib/subconv/scc/transformer.rb, line 261 def node_from_property(property, value) property_class = node_class_for_property(property) if property_class == ColorNode ColorNode.new(value.to_symbol) else fail 'Cannot create boolean property node for property off' unless value property_class.new end end
Convert a grid coordinate to a relative screen position inside the video
# File lib/subconv/scc/transformer.rb, line 155 def position_from_grid(row, column) # TODO: Handle different aspect ratios # The following is only (presumably) true for 16:9 video Position.new(((column.to_f / Scc::GRID_COLUMNS) * 0.8 + 0.1) * 0.75 + 0.125, (row.to_f / Scc::GRID_ROWS) * 0.8 + 0.1) end
Get the property (symbol) for a given Ruby class
# File lib/subconv/scc/transformer.rb, line 256 def property_for_node_class(node_class) PROPERTY_CLASS_MAP.invert.fetch(node_class) end
Get the relative priority of a property
# File lib/subconv/scc/transformer.rb, line 99 def property_priority(property) # First property has the highest priority highest_property_priority - PROPERTIES.find_index(property) end
Shorthand for creating an empty grid if it is nil (in case the grid object is required). Grids can be nil on Scc::Caption
instances when the grid would be empty
# File lib/subconv/scc/transformer.rb, line 285 def require_grid_object(grid) grid || Scc::Grid.new end
Determine all properties (as array of symbols) that are different between the Scc::CharacterStyle
instances style_a and style_b
# File lib/subconv/scc/transformer.rb, line 274 def style_differences(style_a, style_b) PROPERTIES.reject { |property| value_a = style_a.send(property) value_b = style_b.send(property) value_a == value_b } end
Transform one chunk of Scc::Character instances into text and style nodes The parser goes through each character sequentially, opening and closing style nodes as necessary on the way
# File lib/subconv/scc/transformer.rb, line 163 def transform_chunk(chunk) default_style = CharacterStyle.default # Start out with the default style current_style = CharacterStyle.default current_text = +'' # Start with a stack of just the root node parent_node_stack = [RootNode.new] chunk.each_with_index do |column, column_index| # Gather the style properties that are different differences = style_differences(current_style, column.style) # Adjust the style by opening/closing nodes if there are any differences unless differences.empty? # Finalize currently open text node unless current_text.empty? # Insert text node into the children of the node on top of the stack parent_node_stack.last.children.push(TextNode.new(current_text)) current_text = +'' end # First close any nodes whose old value was different from the default value and has now changed differences_to_close = differences & style_differences(current_style, default_style) unless differences_to_close.empty? # Find topmost node that corresponds to any of the differences to close first_matching_node_index = parent_node_stack.find_index { |node| differences_to_close.any? { |difference| node.instance_of?(node_class_for_property(difference)) } } fail 'No node for property to close found in stack' if first_matching_node_index.nil? # Collect styles below it that should _not_ be closed for possible re-opening because they would otherwise get lost reopen_node_classes = parent_node_stack[first_matching_node_index..-1].reject { |node| differences_to_close.any? { |difference| node.instance_of?(node_class_for_property(difference)) } } reopen = reopen_node_classes.map { |node| property_for_node_class(node.class) } # Add them to the differences (since the current style changed from what was assumed above) differences += reopen # Delete the matched node and all following nodes from the stack parent_node_stack.pop(parent_node_stack.length - first_matching_node_index) end # Values that are different from both the former style and the default style must result in a new node differences_to_open = differences & style_differences(column.style, default_style) # Calculate how long each style persists continuous_lengths = Hash[differences_to_open.map { |property| length = 1 value_now = column.style.send(property) (column_index + 1...chunk.length).each do |check_column_index| break if chunk[check_column_index].style.send(property) != value_now length += 1 end # Sort first by length, then by property priority [property, length * (highest_property_priority + 1) + property_priority(property)] }] # Sort new nodes by the length this style persists differences_to_open.sort_by! do |property| continuous_lengths[property] end differences_to_open.reverse! # Open new nodes differences_to_open.each do |property| value = column.style.send(property) new_node = node_from_property(property, value) # Insert into currently active parent node parent_node_stack.last.children.push(new_node) # Push onto stack parent_node_stack.push(new_node) end current_style = column.style end # Always add the character to the current text after adjusting the style if necessary current_text << column.character end # Add any leftover text parent_node_stack.last.children.push(TextNode.new(current_text)) unless current_text.empty? # Return the root node parent_node_stack.first end