subconv 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +39 -0
- data/.rubocop.yml +74 -0
- data/.travis.yml +4 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/Rakefile +10 -0
- data/bin/subconv +58 -0
- data/dist/eia608.css +106 -0
- data/lib/subconv.rb +5 -0
- data/lib/subconv/caption.rb +106 -0
- data/lib/subconv/caption_filter.rb +129 -0
- data/lib/subconv/scc/reader.rb +470 -0
- data/lib/subconv/scc/transformer.rb +259 -0
- data/lib/subconv/utility.rb +42 -0
- data/lib/subconv/version.rb +3 -0
- data/lib/subconv/webvtt/writer.rb +131 -0
- data/spec/caption_filter_spec.rb +154 -0
- data/spec/scc/reader_spec.rb +311 -0
- data/spec/scc/transformer_spec.rb +167 -0
- data/spec/spec_helper.rb +5 -0
- data/spec/test_helpers.rb +73 -0
- data/spec/webvtt/writer_spec.rb +106 -0
- data/subconv.gemspec +34 -0
- metadata +188 -0
@@ -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,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!('&', '&')
|
92
|
+
text.gsub!('<', '<')
|
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
|