subconv 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,259 @@
1
+ require 'subconv/utility'
2
+ require 'subconv/caption'
3
+
4
+ module Subconv
5
+ module Scc
6
+ # Transform an array of caption grids parsed from SCC into an array of captions
7
+ # with the caption content converted to a tree of text and style nodes
8
+ class Transformer
9
+ # Perform the transformation
10
+ # Continuous text blocks are collected in each caption grid and merged
11
+ # Empty grids will end the previously displayed caption
12
+ def transform(captions)
13
+ transformed_captions = []
14
+ return [] if captions.empty?
15
+
16
+ # Use fps from Scc
17
+ fps = captions.first.timecode.fps
18
+ last_time = Timecode.new(0, fps)
19
+
20
+ captions_open = []
21
+ captions.each do |caption|
22
+ if caption.grid.nil? || !captions_open.empty?
23
+ # Close any captions that might be displayed
24
+ captions_open.each do |caption_to_close|
25
+ caption_to_close.timespan = Utility::Timespan.new(last_time.dup, caption.timecode.dup)
26
+ end
27
+ transformed_captions.concat captions_open
28
+ # All captions are closed now
29
+ captions_open = []
30
+ end
31
+
32
+ unless caption.grid.nil?
33
+ # Collect text chunks in each row and create captions out of them
34
+ caption.grid.each_with_index do |row, row_number|
35
+ chunks = collect_chunks(row)
36
+ chunks.each_pair do |start_column, chunk|
37
+ content = transform_chunk(chunk)
38
+ position = position_from_grid(row_number, start_column)
39
+ captions_open.push(Subconv::Caption.new(
40
+ align: :start,
41
+ position: position,
42
+ content: content
43
+ ))
44
+ end
45
+ end
46
+
47
+ # Merge continuous rows?
48
+ # captions_open.each do |caption_open|
49
+ #
50
+ # end
51
+ end
52
+
53
+ last_time = caption.timecode
54
+ end
55
+
56
+ unless captions_open.empty?
57
+ # Close any captions that are still open at the end
58
+ captions_open.each do |caption_to_close|
59
+ caption_to_close.timespan = Utility::Timespan.new(last_time.dup, last_time + Timecode.from_seconds(5, fps))
60
+ end
61
+ transformed_captions.concat captions_open
62
+ end
63
+
64
+ transformed_captions
65
+ end
66
+
67
+ private
68
+
69
+ # Properties in order of priority (first element has the highest priority)
70
+ # The priority indicates in what order new style nodes should be created when their order
71
+ # would be indeterminate otherwise. This is required for getting deterministic output.
72
+ PROPERTIES = %i(color underline italics flash).freeze
73
+
74
+ # Get the relative priority of a property
75
+ def property_priority(property)
76
+ # First property has the highest priority
77
+ highest_property_priority - PROPERTIES.find_index(property)
78
+ end
79
+
80
+ # Get the highest possible property priority
81
+ def highest_property_priority
82
+ PROPERTIES.length - 1
83
+ end
84
+
85
+ # Map of properties to the corresponding Ruby class
86
+ PROPERTY_CLASS_MAP = {
87
+ color: ColorNode,
88
+ italics: ItalicsNode,
89
+ underline: UnderlineNode,
90
+ flash: FlashNode
91
+ }.freeze
92
+
93
+ # Collect all continuous character groups in a row
94
+ # Input: Grid row as array of Scc::Character instances
95
+ # Output: Hash with the starting column index as key and Scc::Character array as value
96
+ def collect_chunks(row)
97
+ chunks = {}
98
+
99
+ collecting = false
100
+ start_column = 0
101
+ current_chunk = []
102
+ row.each_with_index do |column, index|
103
+ if collecting
104
+ if column.nil?
105
+ # Stop collecting, write out chunk
106
+ collecting = false
107
+ chunks[start_column] = current_chunk
108
+ current_chunk = []
109
+ else
110
+ # Stay collecting
111
+ current_chunk.push(column)
112
+ end
113
+ else
114
+ unless column.nil?
115
+ # Start collecting
116
+ collecting = true
117
+ current_chunk.push(column)
118
+ # Remember first column
119
+ start_column = index
120
+ end
121
+ end
122
+ end
123
+
124
+ # Write out last chunk if still open
125
+ chunks[start_column] = current_chunk if collecting
126
+
127
+ chunks
128
+ end
129
+
130
+ # Convert a grid coordinate to a relative screen position inside the video
131
+ def position_from_grid(row, column)
132
+ # TODO: Handle different aspect ratios
133
+ # The following is only (presumably) true for 16:9 video
134
+ 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)
135
+ end
136
+
137
+ # Transform one chunk of Scc::Character instances into text and style nodes
138
+ # The parser goes through each character sequentially, opening and closing style nodes as necessary on the way
139
+ def transform_chunk(chunk)
140
+ default_style = CharacterStyle.default
141
+ # Start out with the default style
142
+ current_style = CharacterStyle.default
143
+ current_text = ''
144
+ # Start with a stack of just the root node
145
+ parent_node_stack = [RootNode.new]
146
+
147
+ chunk.each_with_index do |column, column_index|
148
+ # Gather the style properties that are different
149
+ differences = style_differences(current_style, column.style)
150
+
151
+ # Adjust the style by opening/closing nodes if there are any differences
152
+ unless differences.empty?
153
+ # Finalize currently open text node
154
+ unless current_text.empty?
155
+ # Insert text node into the children of the node on top of the stack
156
+ parent_node_stack.last.children.push(TextNode.new(current_text))
157
+ current_text = ''
158
+ end
159
+
160
+ # First close any nodes whose old value was different from the default value and has now changed
161
+ differences_to_close = differences & style_differences(current_style, default_style)
162
+
163
+ unless differences_to_close.empty?
164
+ # Find topmost node that corresponds to any of the differences to close
165
+ first_matching_node_index = parent_node_stack.find_index { |node|
166
+ differences_to_close.any? { |difference| node.instance_of?(node_class_for_property(difference)) }
167
+ }
168
+
169
+ fail 'No node for property to close found in stack' if first_matching_node_index.nil?
170
+
171
+ # Collect styles below it that should _not_ be closed for possible re-opening because they would otherwise get lost
172
+ reopen = parent_node_stack[first_matching_node_index..-1].select { |node|
173
+ !differences_to_close.any? { |difference| node.instance_of?(node_class_for_property(difference)) }
174
+ }.map { |node| property_for_node_class(node.class) }
175
+
176
+ # Add them to the differences (since the current style changed from what was assumed above)
177
+ differences += reopen
178
+
179
+ # Delete the matched node and all following nodes from the stack
180
+ parent_node_stack.pop(parent_node_stack.length - first_matching_node_index)
181
+ end
182
+
183
+ # Values that are different from both the former style and the default style must result in a new node
184
+ differences_to_open = differences & style_differences(column.style, default_style)
185
+
186
+ # Calculate how long each style persists
187
+ continuous_lengths = Hash[differences_to_open.map { |property|
188
+ length = 1
189
+ value_now = column.style.send(property)
190
+ (column_index + 1...chunk.length).each do |check_column_index|
191
+ break if chunk[check_column_index].style.send(property) != value_now
192
+ length += 1
193
+ end
194
+ # Sort first by length, then by property priority
195
+ [property, length * (highest_property_priority + 1) + property_priority(property)]
196
+ }]
197
+ # Sort new nodes by the length this style persists
198
+ differences_to_open.sort_by! do |property| continuous_lengths[property] end
199
+ differences_to_open.reverse!
200
+
201
+ # Open new nodes
202
+ differences_to_open.each do |property|
203
+ value = column.style.send(property)
204
+ new_node = node_from_property(property, value)
205
+ # Insert into currently active parent node
206
+ parent_node_stack.last.children.push(new_node)
207
+ # Push onto stack
208
+ parent_node_stack.push(new_node)
209
+ end
210
+
211
+ current_style = column.style
212
+ end
213
+
214
+ # Always add the character to the current text after adjusting the style if necessary
215
+ current_text << column.character
216
+ end
217
+
218
+ # Add any leftover text
219
+ unless current_text.empty?
220
+ parent_node_stack.last.children.push(TextNode.new(current_text))
221
+ end
222
+ # Return the root node
223
+ parent_node_stack.first
224
+ end
225
+
226
+ # Get the Ruby class for a given property (symbol)
227
+ def node_class_for_property(property)
228
+ PROPERTY_CLASS_MAP.fetch(property)
229
+ end
230
+
231
+ # Get the property (symbol) for a given Ruby class
232
+ def property_for_node_class(node_class)
233
+ PROPERTY_CLASS_MAP.invert.fetch(node_class)
234
+ end
235
+
236
+ # Create a Ruby node instance from a given property (symbol) and the property value
237
+ def node_from_property(property, value)
238
+ property_class = node_class_for_property(property)
239
+ if property_class == ColorNode
240
+ ColorNode.new(value.to_symbol)
241
+ else
242
+ fail 'Cannot create boolean property node for property off' unless value
243
+ property_class.new
244
+ end
245
+ end
246
+
247
+ # Determine all properties (as array of symbols) that are different between the
248
+ # Scc::CharacterStyle instances a and b
249
+ def style_differences(a, b)
250
+ PROPERTIES.select { |property|
251
+ value_a = a.send(property)
252
+ value_b = b.send(property)
253
+
254
+ value_a != value_b
255
+ }
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,42 @@
1
+ module Subconv
2
+ module Utility
3
+ class InvalidTimespanError < RuntimeError; end
4
+
5
+ class Timespan
6
+ def initialize(start_time, end_time)
7
+ @start_time = start_time
8
+ @end_time = end_time
9
+ fail InvalidTimespanError, 'Timespan end time is before start time' if @end_time < @start_time
10
+ fail InvalidTimespanError, 'Timespan is empty' if @start_time == @end_time
11
+ end
12
+
13
+ def ==(other)
14
+ self.class == other.class && @start_time == other.start_time && @end_time == other.end_time
15
+ end
16
+
17
+ attr_reader :start_time, :end_time
18
+ end
19
+
20
+ def self.clamp(value, min, max)
21
+ return min if value < min
22
+ return max if value > max
23
+ value
24
+ end
25
+
26
+ def self.node_to_tree_string(node, level = 0)
27
+ node_text = node.class.to_s
28
+ if node.is_a?(TextNode)
29
+ node_text << " \"#{node.text}\""
30
+ elsif node.is_a?(ColorNode)
31
+ node_text << " #{node.color}"
32
+ end
33
+ result = "\t" * level + node_text + "\n"
34
+ if node.is_a?(ContainerNode)
35
+ node.children.each { |child|
36
+ result << node_to_tree_string(child, level + 1)
37
+ }
38
+ end
39
+ result
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ module Subconv
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ require 'subconv/utility'
3
+ require 'subconv/caption'
4
+
5
+ module Subconv
6
+ module WebVtt
7
+ FILE_MAGIC = 'WEBVTT'.freeze
8
+
9
+ TIMECODE_FORMAT = '%02d:%02d:%02d.%03d'.freeze
10
+ CUE_FORMAT = '%{start_time} --> %{end_time} %{settings}'.freeze
11
+
12
+ # WebVTT caption writer
13
+ class Writer
14
+ def initialize(options = {})
15
+ @options = options
16
+ end
17
+
18
+ # Write captions to an IO stream
19
+ # captions must be an array of Caption instances
20
+ def write(io, captions)
21
+ io.write(FILE_MAGIC + "\n\n")
22
+
23
+ captions.each do |caption|
24
+ write_caption(io, caption)
25
+ end
26
+ end
27
+
28
+ # Write a single Scc::Caption to an IO stream
29
+ def write_caption(io, caption)
30
+ settings = {
31
+ 'align' => caption.align.to_s
32
+ }
33
+ if caption.position.is_a?(Position)
34
+ settings['line'] = webvtt_percentage(caption.position.y)
35
+ settings['position'] = webvtt_percentage(caption.position.x)
36
+ else
37
+ settings['line'] = case caption.position
38
+ when :top
39
+ # '0' would be better here, but Chrome does not support that yet
40
+ '5%'
41
+ when :bottom
42
+ '-1,end'
43
+ else
44
+ fail "Unknown position #{caption.position}"
45
+ end
46
+ end
47
+
48
+ # Remove align if it is the default value anyway
49
+ settings.delete('align') if settings['align'] == 'middle'
50
+
51
+ # Convert settings to string representation
52
+ settings_string = settings.map { |setting|
53
+ setting.join(':')
54
+ }.join(' ')
55
+
56
+ io.write(CUE_FORMAT % {
57
+ start_time: timecode_to_webvtt(caption.timespan.start_time),
58
+ end_time: timecode_to_webvtt(caption.timespan.end_time),
59
+ settings: settings_string
60
+ } + "\n")
61
+ text = node_to_webvtt_markup caption.content
62
+ if @options[:trim_line_whitespace]
63
+ # Trim leading and trailing whitespace from each line
64
+ text = text.split("\n").each(&:strip!).join("\n")
65
+ end
66
+ io.write "#{text}\n\n"
67
+ end
68
+
69
+ private
70
+
71
+ # Format a value between 0 and 1 as percentage with 3 digits behind the decimal point
72
+ def webvtt_percentage(value)
73
+ format('%.3f%%', (value * 100.0))
74
+ end
75
+
76
+ # Convert a timecode to the h/m/s format required by WebVTT
77
+ def timecode_to_webvtt(time)
78
+ value = time.to_seconds
79
+
80
+ milliseconds = ((value * 1000) % 1000).to_i
81
+ seconds = value.to_i % 60
82
+ minutes = (value.to_i / 60) % 60
83
+ hours = value.to_i / 60 / 60
84
+
85
+ format(TIMECODE_FORMAT, hours, minutes, seconds, milliseconds)
86
+ end
87
+
88
+ # Replace WebVTT special characters in the text
89
+ def escape_text(text)
90
+ text = text.dup
91
+ text.gsub!('&', '&amp;')
92
+ text.gsub!('<', '&lt;')
93
+ text.gsub!('>', '&rt;')
94
+ text
95
+ end
96
+
97
+ # Convert an array of nodes to their corresponding WebVT markup
98
+ def nodes_to_webvtt_markup(nodes)
99
+ nodes.map { |node| node_to_webvtt_markup(node) }.join
100
+ end
101
+
102
+ # Convert one node to its corresponding WebVTT markup
103
+ # Conversion is very straightforward. Container nodes are converted recursively by calling
104
+ # nodes_to_webvtt_markup from within this function. Recursion depth should not be a problem
105
+ # since their are not that many different properties.
106
+ def node_to_webvtt_markup(node)
107
+ # Text nodes just need to have their text converted
108
+ return escape_text(node.text) if node.instance_of?(TextNode)
109
+
110
+ # If it is not a text node, it must have children
111
+ children = nodes_to_webvtt_markup(node.children)
112
+
113
+ # Use an array because the === operator of Class does not work as expected (Array === Array is false)
114
+ case [node.class]
115
+ when [RootNode]
116
+ children
117
+ when [ItalicsNode]
118
+ '<i>' + children + '</i>'
119
+ when [UnderlineNode]
120
+ '<u>' + children + '</u>'
121
+ when [FlashNode]
122
+ '<c.blink>' + children + '</c>'
123
+ when [ColorNode]
124
+ '<c.' + node.color.to_s + '>' + children + '</c>'
125
+ else
126
+ fail "Unknown node class #{node.class}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,154 @@
1
+ require 'spec_helper'
2
+
3
+ module Subconv
4
+ describe CaptionFilter do
5
+ include TestHelpers
6
+
7
+ it 'should not do anything by default' do
8
+ expected, captions = caption_filter_process({}) { caption_all_node_types }
9
+ expect(captions).to eq(expected)
10
+ end
11
+
12
+ it 'should remove color nodes' do
13
+ expected, captions = caption_filter_process(remove_color: true) { caption_all_node_types }
14
+ expected[0].content.children[0] = TextNode.new('1')
15
+ expect(captions).to eq(expected)
16
+ end
17
+
18
+ it 'should remove flash nodes' do
19
+ expected, captions = caption_filter_process(remove_flash: true) { caption_all_node_types }
20
+ expected[0].content.children[1] = TextNode.new('2')
21
+ expect(captions).to eq(expected)
22
+ end
23
+
24
+ it 'should remove color and flash nodes' do
25
+ expected, captions = caption_filter_process(remove_color: true, remove_flash: true) { caption_all_node_types }
26
+ expected[0].content.children[0] = TextNode.new('12')
27
+ expected[0].content.children.delete_at 1
28
+ expect(captions).to eq(expected)
29
+ end
30
+
31
+ it 'should remove nodes recursively' do
32
+ captions = single_caption_with_content([
33
+ TextNode.new('a'),
34
+ ItalicsNode.new([
35
+ ColorNode.new(:blue, [
36
+ TextNode.new('b'),
37
+ FlashNode.new([
38
+ TextNode.new('c'),
39
+ UnderlineNode.new([
40
+ TextNode.new('de')
41
+ ])
42
+ ])
43
+ ]),
44
+ ColorNode.new(:cyan, [
45
+ UnderlineNode.new([
46
+ TextNode.new('f')
47
+ ])
48
+ ])
49
+ ])
50
+ ])
51
+ CaptionFilter.new(remove_color: true, remove_flash: true).process!(captions)
52
+ expected = single_caption_with_content([
53
+ TextNode.new('a'),
54
+ ItalicsNode.new([
55
+ TextNode.new('bc'),
56
+ UnderlineNode.new([
57
+ TextNode.new('de')
58
+ ]),
59
+ UnderlineNode.new([
60
+ TextNode.new('f')
61
+ ])
62
+ ])
63
+ ])
64
+ expect(captions).to eq(expected)
65
+ end
66
+
67
+ context 'when converting XY positions to simple top/bottom centered positions' do
68
+ it 'should remove the X position' do
69
+ expected, captions = caption_filter_process(xy_position_to_top_or_bottom: true) {
70
+ [
71
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.1), content: root_with_text('Test 1'), align: :start),
72
+ Caption.new(timespan: t2_3, position: Position.new(0.1, 0.7), content: root_with_text('Test 2'), align: :start)
73
+ ]
74
+ }
75
+ expected[0].position = :top
76
+ expected[1].position = :bottom
77
+ expected[0].align = :middle
78
+ expected[1].align = :middle
79
+ expect(captions).to eq(expected)
80
+ end
81
+
82
+ it 'should support simultaneous top and bottom captions' do
83
+ expected, captions = caption_filter_process(xy_position_to_top_or_bottom: true) {
84
+ [
85
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.1), content: root_with_text('Test 1'), align: :start),
86
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.7), content: root_with_text('Test 2'), align: :start)
87
+ ]
88
+ }
89
+ expected[0].position = :top
90
+ expected[1].position = :bottom
91
+ expected[0].align = :middle
92
+ expected[1].align = :middle
93
+ expect(captions).to eq(expected)
94
+ end
95
+
96
+ it 'should not split continuous on-screen lines starting in the top region to top and bottom' do
97
+ expected, captions = caption_filter_process(xy_position_to_top_or_bottom: true) {
98
+ [
99
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.49), content: root_with_text('Test 1'), align: :start),
100
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.51), content: root_with_text('Test 2'), align: :start),
101
+ Caption.new(timespan: t2_3, position: Position.new(0.1, 0.52), content: root_with_text('Test 2'), align: :start)
102
+ ]
103
+ }
104
+ expected[0].position = :top
105
+ expected[1].position = :top
106
+ expected[2].position = :bottom
107
+ expected[0].align = :middle
108
+ expected[1].align = :middle
109
+ expected[2].align = :middle
110
+ expect(captions).to eq(expected)
111
+ end
112
+
113
+ it 'should merge captions in the same region when requested' do
114
+ expected, captions = caption_filter_process(xy_position_to_top_or_bottom: true, merge_by_position: true) {
115
+ [
116
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.1), content: root_with_text('Test 1'), align: :start),
117
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.2), content: root_with_text('Test 2'), align: :start),
118
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.7), content: root_with_text('Test 3'), align: :start),
119
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.8), content: root_with_text('Test 4'), align: :start)
120
+ ]
121
+ }
122
+ expected.delete_at 1
123
+ expected.delete_at 2
124
+ expected[0].position = :top
125
+ expected[1].position = :bottom
126
+ expected[0].content.children[0].text = "Test 1\nTest 2"
127
+ expected[1].content.children[0].text = "Test 3\nTest 4"
128
+ expected[0].align = :middle
129
+ expected[1].align = :middle
130
+ expect(captions).to eq(expected)
131
+ end
132
+
133
+ it 'should not merge captions with different timecodes' do
134
+ expected, captions = caption_filter_process(xy_position_to_top_or_bottom: true, merge_by_position: true) {
135
+ [
136
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.1), content: root_with_text('Test 1'), align: :start),
137
+ Caption.new(timespan: t1_2, position: Position.new(0.1, 0.6), content: root_with_text('Test 2'), align: :start),
138
+ Caption.new(timespan: t2_3, position: Position.new(0.1, 0.7), content: root_with_text('Test 3'), align: :start),
139
+ Caption.new(timespan: t2_3, position: Position.new(0.1, 0.8), content: root_with_text('Test 4'), align: :start)
140
+ ]
141
+ }
142
+ expected.delete_at 3
143
+ expected[0].position = :top
144
+ expected[1].position = :bottom
145
+ expected[2].position = :bottom
146
+ expected[2].content.children[0].text = "Test 3\nTest 4"
147
+ expected[0].align = :middle
148
+ expected[1].align = :middle
149
+ expected[1].align = :middle
150
+ expect(captions).to eq(expected)
151
+ end
152
+ end
153
+ end
154
+ end