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

combine_paint_on_captions(captions) click to toggle source

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
transform(captions) click to toggle source

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_chunks(row) click to toggle source

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
highest_property_priority() click to toggle source

Get the highest possible property priority

# File lib/subconv/scc/transformer.rb, line 105
def highest_property_priority
  PROPERTIES.length - 1
end
node_class_for_property(property) click to toggle source

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
node_from_property(property, value) click to toggle source

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
position_from_grid(row, column) click to toggle source

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
property_for_node_class(node_class) click to toggle source

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
property_priority(property) click to toggle source

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
require_grid_object(grid) click to toggle source

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
style_differences(style_a, style_b) click to toggle source

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_chunk(chunk) click to toggle source

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