titlekit 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +3 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +335 -0
- data/Rakefile +8 -0
- data/lib/titlekit/have.rb +90 -0
- data/lib/titlekit/job.rb +446 -0
- data/lib/titlekit/parsers/ass.rb +175 -0
- data/lib/titlekit/parsers/ass.treetop +72 -0
- data/lib/titlekit/parsers/srt.rb +139 -0
- data/lib/titlekit/parsers/srt.treetop +73 -0
- data/lib/titlekit/parsers/ssa.rb +201 -0
- data/lib/titlekit/parsers/ssa.treetop +72 -0
- data/lib/titlekit/specification.rb +131 -0
- data/lib/titlekit/utilities.rb +3 -0
- data/lib/titlekit/version.rb +3 -0
- data/lib/titlekit/want.rb +24 -0
- data/lib/titlekit.rb +9 -0
- data/spec/ass_spec.rb +113 -0
- data/spec/automatic_grouping/automatic_grouping_spec.rb +55 -0
- data/spec/automatic_grouping/dual_tracks/expected.srt +15 -0
- data/spec/automatic_grouping/dual_tracks/one.srt +11 -0
- data/spec/automatic_grouping/dual_tracks/out.srt +15 -0
- data/spec/automatic_grouping/dual_tracks/two.srt +11 -0
- data/spec/automatic_grouping/single_track/expected.srt +24 -0
- data/spec/automatic_grouping/single_track/one.srt +11 -0
- data/spec/automatic_grouping/single_track/out.srt +24 -0
- data/spec/automatic_grouping/single_track/two.srt +11 -0
- data/spec/encoding_detection/a/in.ass +0 -0
- data/spec/encoding_detection/b/in.srt +2389 -0
- data/spec/encoding_detection/b/out.srt +2389 -0
- data/spec/encoding_detection/c/in.srt +5320 -0
- data/spec/encoding_detection/c/out.srt +5320 -0
- data/spec/encoding_detection/encoding_detection_spec.rb +81 -0
- data/spec/files/ass/authentic.ass +0 -0
- data/spec/files/ass/hard.ass +37 -0
- data/spec/files/ass/simple.ass +28 -0
- data/spec/files/srt/authentic.srt +2708 -0
- data/spec/files/srt/coordinates.srt +13 -0
- data/spec/files/srt/simple.srt +12 -0
- data/spec/files/ssa/simple.ssa +26 -0
- data/spec/files/try/unsupported-output.try +0 -0
- data/spec/files/try/unsupported.try +7 -0
- data/spec/format_conversion/ass_srt/expected.srt +2327 -0
- data/spec/format_conversion/ass_srt/in.ass +485 -0
- data/spec/format_conversion/ass_srt/out.srt +2327 -0
- data/spec/format_conversion/format_conversion_spec.rb +112 -0
- data/spec/format_conversion/srt_ass/expected.ass +19 -0
- data/spec/format_conversion/srt_ass/in.srt +12 -0
- data/spec/format_conversion/srt_ass/out.ass +19 -0
- data/spec/format_conversion/srt_ssa/expected.ssa +19 -0
- data/spec/format_conversion/srt_ssa/in.srt +12 -0
- data/spec/format_conversion/srt_ssa/out.ssa +19 -0
- data/spec/format_conversion/ssa_srt/expected.srt +9 -0
- data/spec/format_conversion/ssa_srt/in.ssa +26 -0
- data/spec/format_conversion/ssa_srt/out.srt +9 -0
- data/spec/job_spec.rb +162 -0
- data/spec/simultaneous_subtitles/dual/ass/expected.ass +22 -0
- data/spec/simultaneous_subtitles/dual/ass/out.ass +22 -0
- data/spec/simultaneous_subtitles/dual/one.srt +11 -0
- data/spec/simultaneous_subtitles/dual/srt/expected.srt +27 -0
- data/spec/simultaneous_subtitles/dual/srt/out.srt +27 -0
- data/spec/simultaneous_subtitles/dual/ssa/expected.ssa +22 -0
- data/spec/simultaneous_subtitles/dual/ssa/out.ssa +22 -0
- data/spec/simultaneous_subtitles/dual/two.srt +11 -0
- data/spec/simultaneous_subtitles/simultaneous_subtitles_spec.rb +220 -0
- data/spec/simultaneous_subtitles/triple/ass/expected.ass +25 -0
- data/spec/simultaneous_subtitles/triple/ass/out.ass +25 -0
- data/spec/simultaneous_subtitles/triple/one.srt +11 -0
- data/spec/simultaneous_subtitles/triple/srt/expected.srt +55 -0
- data/spec/simultaneous_subtitles/triple/srt/out.srt +55 -0
- data/spec/simultaneous_subtitles/triple/ssa/expected.ssa +25 -0
- data/spec/simultaneous_subtitles/triple/ssa/out.ssa +25 -0
- data/spec/simultaneous_subtitles/triple/three.srt +11 -0
- data/spec/simultaneous_subtitles/triple/two.srt +11 -0
- data/spec/simultaneous_subtitles/triple_plus/ass/expected.ass +93 -0
- data/spec/simultaneous_subtitles/triple_plus/ass/out.ass +93 -0
- data/spec/simultaneous_subtitles/triple_plus/five.srt +11 -0
- data/spec/simultaneous_subtitles/triple_plus/four.srt +11 -0
- data/spec/simultaneous_subtitles/triple_plus/one.srt +11 -0
- data/spec/simultaneous_subtitles/triple_plus/six.srt +11 -0
- data/spec/simultaneous_subtitles/triple_plus/srt/expected.srt +149 -0
- data/spec/simultaneous_subtitles/triple_plus/srt/out.srt +149 -0
- data/spec/simultaneous_subtitles/triple_plus/ssa/expected.ssa +93 -0
- data/spec/simultaneous_subtitles/triple_plus/ssa/out.ssa +93 -0
- data/spec/simultaneous_subtitles/triple_plus/three.srt +11 -0
- data/spec/simultaneous_subtitles/triple_plus/two.srt +11 -0
- data/spec/spec_helper.rb +7 -0
- data/spec/specifications_spec.rb +138 -0
- data/spec/srt_spec.rb +134 -0
- data/spec/ssa_spec.rb +90 -0
- data/spec/timecode_correction/double_reference/expected.srt +13 -0
- data/spec/timecode_correction/double_reference/in.srt +12 -0
- data/spec/timecode_correction/double_reference/out.srt +13 -0
- data/spec/timecode_correction/framerate/expected.srt +5 -0
- data/spec/timecode_correction/framerate/in.srt +4 -0
- data/spec/timecode_correction/framerate/out.srt +5 -0
- data/spec/timecode_correction/framerate_plus_reference/expected.srt +13 -0
- data/spec/timecode_correction/framerate_plus_reference/in.srt +12 -0
- data/spec/timecode_correction/framerate_plus_reference/out.srt +13 -0
- data/spec/timecode_correction/single_reference/expected.srt +13 -0
- data/spec/timecode_correction/single_reference/in.srt +12 -0
- data/spec/timecode_correction/single_reference/out.srt +13 -0
- data/spec/timecode_correction/timecode_correction_spec.rb +124 -0
- data/spec/transcoding/gb2312-ascii/in.srt +12 -0
- data/spec/transcoding/iso-8859-1_utf-8/expected.srt +12 -0
- data/spec/transcoding/iso-8859-1_utf-8/in.srt +11 -0
- data/spec/transcoding/iso-8859-1_utf-8/out.srt +12 -0
- data/spec/transcoding/transcoding_spec.rb +116 -0
- data/spec/transcoding/utf-8_gbk/expected.srt +12 -0
- data/spec/transcoding/utf-8_gbk/in.srt +11 -0
- data/spec/transcoding/utf-8_gbk/out.srt +12 -0
- data/spec/transcoding/windows-1252_utf-8/expected.srt +12 -0
- data/spec/transcoding/windows-1252_utf-8/in.srt +11 -0
- data/spec/transcoding/windows-1252_utf-8/out.srt +12 -0
- data/titlekit.gemspec +28 -0
- metadata +313 -0
data/lib/titlekit/job.rb
ADDED
@@ -0,0 +1,446 @@
|
|
1
|
+
module Titlekit
|
2
|
+
class AbortJob < StandardError
|
3
|
+
end
|
4
|
+
|
5
|
+
class Job
|
6
|
+
|
7
|
+
# Returns everything we {Have}
|
8
|
+
#
|
9
|
+
# @return [Array<Have>] All assigned {Have} specifications
|
10
|
+
attr_reader :haves
|
11
|
+
|
12
|
+
# Returns everything we {Want}
|
13
|
+
#
|
14
|
+
# @return [Array<Want>] All assigned {Want} specifications
|
15
|
+
attr_reader :wants
|
16
|
+
|
17
|
+
# Returns the job report, which documents the direct cause of failures
|
18
|
+
# and any other unusual events that occur on the job. (regardless if it
|
19
|
+
# failed or succeeded)
|
20
|
+
#
|
21
|
+
# @return [Array<String>] All reported messages
|
22
|
+
attr_reader :report
|
23
|
+
|
24
|
+
# Starts a new job.
|
25
|
+
#
|
26
|
+
# A job requires at least one thing you {Have} and one thing you {Want}
|
27
|
+
# in order to be runable. Use {Job#have} and {Job#want} to add
|
28
|
+
# and obtain specification interfaces for the job.
|
29
|
+
#
|
30
|
+
def initialize
|
31
|
+
@haves = []
|
32
|
+
@wants = []
|
33
|
+
@report = []
|
34
|
+
|
35
|
+
require 'rchardet19'
|
36
|
+
|
37
|
+
begin
|
38
|
+
if Gem::Specification.find_by_name('charlock_holmes')
|
39
|
+
require 'charlock_holmes'
|
40
|
+
end
|
41
|
+
rescue Gem::LoadError
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Fulfills the job.
|
46
|
+
#
|
47
|
+
# @return [Boolean] true if the job succeeds, false if it fails.
|
48
|
+
# {Job#report} provides information in case of failure.
|
49
|
+
def run
|
50
|
+
@wants.each do |want|
|
51
|
+
@haves.each do |have|
|
52
|
+
import(have)
|
53
|
+
retime(have, want)
|
54
|
+
cull(have)
|
55
|
+
group(have)
|
56
|
+
|
57
|
+
want.subtitles += have.subtitles.clone
|
58
|
+
end
|
59
|
+
|
60
|
+
polish(want)
|
61
|
+
export(want)
|
62
|
+
end
|
63
|
+
|
64
|
+
return true
|
65
|
+
rescue AbortJob
|
66
|
+
return false
|
67
|
+
end
|
68
|
+
|
69
|
+
# Adds a new {Have} specification to your job.
|
70
|
+
#
|
71
|
+
# @example Using a block without a variable (careful: the scope changes!)
|
72
|
+
# job.have do
|
73
|
+
# encoding('utf-8')
|
74
|
+
# file('path/to/my/input.srt')
|
75
|
+
# fps(25)
|
76
|
+
# end
|
77
|
+
#
|
78
|
+
# @example Using a block and providing a variable
|
79
|
+
# job.have do |have|
|
80
|
+
# have.encoding('utf-8')
|
81
|
+
# have.file('path/to/my/input.srt')
|
82
|
+
# have.fps(25)
|
83
|
+
# end
|
84
|
+
#
|
85
|
+
# @example Catching the reference and assigning things at any later point
|
86
|
+
# have = job.have
|
87
|
+
# have.encoding('utf-8')
|
88
|
+
# have.file('path/to/my/input.srt')
|
89
|
+
# have.fps(25)
|
90
|
+
#
|
91
|
+
# @example Cloning a previous specification and extending on it
|
92
|
+
# have2 = job.have(template: have1)
|
93
|
+
# have2.encoding('ISO-8859-1')
|
94
|
+
# have2.file('path/to/my/input2.srt')
|
95
|
+
#
|
96
|
+
# @param template [Have] optionally you can specify another {Have} as a
|
97
|
+
# template, from which all properties but the file path are cloned
|
98
|
+
# @return [Have] a reference to the newly assigned {Have}
|
99
|
+
def have(*args, template: nil, &block)
|
100
|
+
specification = Have.new
|
101
|
+
|
102
|
+
if template
|
103
|
+
specification.fps = template.fps.clone
|
104
|
+
specification.references = template.references.clone
|
105
|
+
end
|
106
|
+
|
107
|
+
if block
|
108
|
+
if block.arity < 1
|
109
|
+
specification.instance_eval(&block)
|
110
|
+
else
|
111
|
+
block[specification]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
@haves << specification
|
116
|
+
|
117
|
+
return specification
|
118
|
+
end
|
119
|
+
|
120
|
+
# Adds a new {Want} specification to your job.
|
121
|
+
#
|
122
|
+
# @example Using a block without a variable (careful: the scope changes!)
|
123
|
+
# job.want do
|
124
|
+
# encoding('utf-8')
|
125
|
+
# file('path/to/my/output.srt')
|
126
|
+
# fps(23.976)
|
127
|
+
# end
|
128
|
+
#
|
129
|
+
# @example Using a block and providing a variable
|
130
|
+
# job.want do |want|
|
131
|
+
# want.encoding('utf-8')
|
132
|
+
# want.file('path/to/my/output.srt')
|
133
|
+
# want.fps((23.976)
|
134
|
+
# end
|
135
|
+
#
|
136
|
+
# @example Catching the reference and assigning things at any later point
|
137
|
+
# want = job.want
|
138
|
+
# want.encoding('utf-8')
|
139
|
+
# want.file('path/to/my/output.srt')
|
140
|
+
# want.fps((23.976)
|
141
|
+
#
|
142
|
+
# @example Cloning a previous specification and extending on it
|
143
|
+
# want2 = job.want(template: want1)
|
144
|
+
# want2.encoding('ISO-8859-1')
|
145
|
+
# want2.file('path/to/my/output.ass')
|
146
|
+
#
|
147
|
+
# @param template [Want] optionally you can specify another {Want} as a
|
148
|
+
# template, from which all properties but the file path are cloned
|
149
|
+
# @return [Want] a reference to the newly assigned {Want}
|
150
|
+
def want(*args, template: nil, &block)
|
151
|
+
specification = Want.new
|
152
|
+
|
153
|
+
if template
|
154
|
+
specification.fps = template.fps.clone
|
155
|
+
specification.references = template.references.clone
|
156
|
+
end
|
157
|
+
|
158
|
+
if block
|
159
|
+
if block.arity < 1
|
160
|
+
specification.instance_eval(&block)
|
161
|
+
else
|
162
|
+
block[specification]
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
@wants << specification
|
167
|
+
|
168
|
+
return specification
|
169
|
+
end
|
170
|
+
|
171
|
+
private
|
172
|
+
|
173
|
+
# Imports what we {Have}
|
174
|
+
#
|
175
|
+
# @param [Have] What we {Have}
|
176
|
+
def import(have)
|
177
|
+
begin
|
178
|
+
data = File.read(have.file)
|
179
|
+
rescue
|
180
|
+
@report << "Failure while reading #{have.file}"
|
181
|
+
raise AbortJob
|
182
|
+
end
|
183
|
+
|
184
|
+
begin
|
185
|
+
if [:detect, :charlock_holmes].include?(have.encoding) && defined?(CharlockHolmes)
|
186
|
+
detection = CharlockHolmes::EncodingDetector.detect(data)
|
187
|
+
@report << "Assuming #{detection[:encoding]} for #{have.file} (detected by charlock_holmes with #{detection[:confidence]}% confidence)"
|
188
|
+
data.force_encoding(detection[:encoding])
|
189
|
+
elsif [:detect, :rchardet19].include?(have.encoding) && defined?(CharDet)
|
190
|
+
detection = CharDet.detect(data)
|
191
|
+
@report << "Assuming #{detection.encoding} for #{have.file} (detected by rchardet19 with #{(detection.confidence*100).to_i}% confidence)"
|
192
|
+
data.force_encoding(detection.encoding)
|
193
|
+
else
|
194
|
+
@report << "Assuming #{have.encoding} for #{have.file} (user-supplied)"
|
195
|
+
data.force_encoding(have.encoding)
|
196
|
+
end
|
197
|
+
rescue
|
198
|
+
@report << "Failure while setting encoding for #{have.file}"
|
199
|
+
raise AbortJob
|
200
|
+
end
|
201
|
+
|
202
|
+
begin
|
203
|
+
data.encode!('UTF-8')
|
204
|
+
rescue
|
205
|
+
@report << "Failure while transcoding #{have.file} from #{data.encoding} to intermediate UTF-8 encoding"
|
206
|
+
raise AbortJob
|
207
|
+
end
|
208
|
+
|
209
|
+
begin
|
210
|
+
have.subtitles = case File.extname(have.file)
|
211
|
+
when '.ass'
|
212
|
+
ASS.import(data)
|
213
|
+
when '.ssa'
|
214
|
+
SSA.import(data)
|
215
|
+
when '.srt'
|
216
|
+
SRT.import(data)
|
217
|
+
else
|
218
|
+
raise 'Not supported'
|
219
|
+
end
|
220
|
+
rescue
|
221
|
+
@report << "Failure while importing #{File.extname(have.file)[1..3].upcase} from #{have.file}"
|
222
|
+
raise AbortJob
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Transfers the subtitles from the state we {Have}, to the state we {Want}.
|
227
|
+
#
|
228
|
+
# @params have [Have] What we {Have}
|
229
|
+
# @params want [Want] What we {Want}
|
230
|
+
def retime(have, want)
|
231
|
+
matching_references = want.references.keys & have.references.keys
|
232
|
+
|
233
|
+
# Resolve subtitle references by getting actual timecodes
|
234
|
+
matching_references.each do |reference|
|
235
|
+
if (index = have.references[reference][:subtitle])
|
236
|
+
have.references[reference][:timecode] = have.subtitles[index][:start]
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
case matching_references.length
|
241
|
+
when 3..(infinity = 1.0/0)
|
242
|
+
# "synchronization jitter" correction by interpolating ? Consider !
|
243
|
+
when 2
|
244
|
+
retime_by_double_reference(have,
|
245
|
+
want,
|
246
|
+
matching_references[0],
|
247
|
+
matching_references[1])
|
248
|
+
when 1
|
249
|
+
if have.fps && want.fps
|
250
|
+
retime_by_framerate_plus_reference(have, want, matching_references[0])
|
251
|
+
else
|
252
|
+
retime_by_single_reference(have, want, matching_references[0])
|
253
|
+
end
|
254
|
+
when 0
|
255
|
+
if have.fps && want.fps
|
256
|
+
retime_by_framerate(have, want)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# Clean out subtitles that fell out of the usable time range
|
262
|
+
def cull(have)
|
263
|
+
have.subtitles.reject! { |subtitle| subtitle[:end] < 0 }
|
264
|
+
have.subtitles.each do |subtitle|
|
265
|
+
subtitle[:start] = 0 if subtitle[:start] < 0
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
# Assign track identification fields for distinguishing
|
270
|
+
# between continuous/simultaneous subtitles
|
271
|
+
def group(have)
|
272
|
+
if have.track
|
273
|
+
# Assign a custom track identifier if one was supplied
|
274
|
+
have.subtitles.each { |subtitle| subtitle[:track] = have.track }
|
275
|
+
elsif @haves.index(have) == 0 || @haves[@haves.index(have) - 1].subtitles.empty?
|
276
|
+
# Otherwise let the path be the track identifier for the first subtitle
|
277
|
+
have.subtitles.each { |subtitle| subtitle[:track] = have.file }
|
278
|
+
else
|
279
|
+
# For the 2nd+ subtitles determine the track association by detecting
|
280
|
+
# collisions against the previously imported subtitles
|
281
|
+
|
282
|
+
collisions = 0
|
283
|
+
|
284
|
+
have.subtitles.each do |subtitle|
|
285
|
+
@haves[@haves.index(have) - 1].subtitles.each do |previous_subtitle|
|
286
|
+
collisions += 1 if (subtitle[:start] > previous_subtitle[:start] &&
|
287
|
+
subtitle[:start] < previous_subtitle[:end]) ||
|
288
|
+
(subtitle[:end] > previous_subtitle[:start] &&
|
289
|
+
subtitle[:end] < previous_subtitle[:end]) ||
|
290
|
+
(previous_subtitle[:start] > subtitle[:start] &&
|
291
|
+
previous_subtitle[:start] < subtitle[:end]) ||
|
292
|
+
(previous_subtitle[:end] > subtitle[:start] &&
|
293
|
+
previous_subtitle[:end] < subtitle[:end]) ||
|
294
|
+
(subtitle[:start] == previous_subtitle[:start] ||
|
295
|
+
subtitle[:end] == previous_subtitle[:end])
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
if collisions.to_f / have.subtitles.length.to_f > 0.01
|
300
|
+
# Add a new track if there are > 1% collisions between these
|
301
|
+
# subtitles and the ones that were last imported
|
302
|
+
have.subtitles.each { |subtitle| subtitle[:track] = have.file }
|
303
|
+
else
|
304
|
+
# Otherwise continue using the previous track identifier
|
305
|
+
# (= Assume that these and the previous subtitles are one track)
|
306
|
+
previous_track = @haves[@haves.index(have) - 1].subtitles.first[:track]
|
307
|
+
have.subtitles.each { |subtitle| subtitle[:track] = previous_track }
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
# Polishes what we {Want}
|
313
|
+
#
|
314
|
+
# @params want [Want] What we {Want} polished
|
315
|
+
def polish(want)
|
316
|
+
# Glue subtitle starts
|
317
|
+
want.subtitles.sort_by! { |subtitle| subtitle[:start] }
|
318
|
+
want.subtitles.each_cons(2) do |pair|
|
319
|
+
distance = pair[1][:start] - pair[0][:start]
|
320
|
+
if distance < want.glue_treshold
|
321
|
+
pair[0][:start] += distance / 2
|
322
|
+
pair[1][:start] -= distance / 2
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# Glue subtitles ends
|
327
|
+
want.subtitles.sort_by! { |subtitle| subtitle[:end] }
|
328
|
+
want.subtitles.each_cons(2) do |pair|
|
329
|
+
if pair[1][:end]-pair[0][:end] < want.glue_treshold
|
330
|
+
pair[0][:end] += (pair[1][:end]-pair[0][:end]) / 2
|
331
|
+
pair[1][:end] -= (pair[1][:end]-pair[0][:end]) / 2
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
# Exports what we {Want}
|
337
|
+
#
|
338
|
+
# @param want [Want] What we {Want}
|
339
|
+
def export(want)
|
340
|
+
begin
|
341
|
+
data = case File.extname(want.file)
|
342
|
+
when '.ass'
|
343
|
+
ASS.master(want.subtitles)
|
344
|
+
ASS.export(want.subtitles)
|
345
|
+
when '.ssa'
|
346
|
+
SSA.master(want.subtitles)
|
347
|
+
SSA.export(want.subtitles)
|
348
|
+
when '.srt'
|
349
|
+
SRT.master(want.subtitles)
|
350
|
+
SRT.export(want.subtitles)
|
351
|
+
else
|
352
|
+
raise 'Not supported'
|
353
|
+
end
|
354
|
+
rescue
|
355
|
+
@report << "Failure while exporting #{File.extname(want.file)[1..3].upcase} for #{want.file}"
|
356
|
+
raise AbortJob
|
357
|
+
ensure
|
358
|
+
want.subtitles = nil
|
359
|
+
end
|
360
|
+
|
361
|
+
if want.encoding
|
362
|
+
begin
|
363
|
+
data.encode!(want.encoding)
|
364
|
+
rescue
|
365
|
+
@report << "Failure while transcoding from #{data.encoding} to #{want.encoding} for #{want.file}"
|
366
|
+
raise AbortJob
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
begin
|
371
|
+
IO.write(want.file, data)
|
372
|
+
rescue
|
373
|
+
@report << "Failure while writing to #{want.file}"
|
374
|
+
raise AbortJob
|
375
|
+
end
|
376
|
+
end
|
377
|
+
|
378
|
+
# Applies a simple timeshift to the subtitle we {Have}.
|
379
|
+
# Each subtitle gets shifted forward/backward by the same amount of seconds.
|
380
|
+
#
|
381
|
+
# @param have [Have] the subtitles we {Have}
|
382
|
+
# @param want [Want] the subtitles we {Want}
|
383
|
+
# @param reference [Symbol, String] the key of the reference
|
384
|
+
def retime_by_single_reference(have, want, reference)
|
385
|
+
amount = want.references[reference][:timecode] -
|
386
|
+
have.references[reference][:timecode]
|
387
|
+
|
388
|
+
have.subtitles.each do |subtitle|
|
389
|
+
subtitle[:start] += amount
|
390
|
+
subtitle[:end] += amount
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
def retime_by_framerate_plus_reference(have, want, reference)
|
395
|
+
ratio = want.fps.to_f / have.fps.to_f
|
396
|
+
have.references[reference][:timecode] *= ratio
|
397
|
+
have.subtitles.each do |subtitle|
|
398
|
+
subtitle[:start] *= ratio
|
399
|
+
subtitle[:end] *= ratio
|
400
|
+
end
|
401
|
+
|
402
|
+
amount = want.references[reference][:timecode] -
|
403
|
+
have.references[reference][:timecode]
|
404
|
+
|
405
|
+
have.subtitles.each do |subtitle|
|
406
|
+
subtitle[:start] += amount
|
407
|
+
subtitle[:end] += amount
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
# Applies a progressive timeshift on the subtitles we {Have}
|
412
|
+
# Two points in time are known for both of which a different shift in time
|
413
|
+
# should be applied. Thus a steadily increasing or decreasing forwards or
|
414
|
+
# backwards shift will be applied to each subtitle, depending on its
|
415
|
+
# place in the time continuum
|
416
|
+
#
|
417
|
+
# @param have [Have] the subtitles we {Have}
|
418
|
+
# @param [Array<Float>] origins the two points in time (given in seconds)
|
419
|
+
# which shall be shifted differently
|
420
|
+
# @param [Array<Float>] targets the two amounts of time by which to shift
|
421
|
+
# either of the two points that shall be shifted
|
422
|
+
def retime_by_double_reference(have, want, reference_a, reference_b)
|
423
|
+
origins = [ have.references[reference_a][:timecode],
|
424
|
+
have.references[reference_b][:timecode] ]
|
425
|
+
|
426
|
+
targets = [ want.references[reference_a][:timecode],
|
427
|
+
want.references[reference_b][:timecode] ]
|
428
|
+
|
429
|
+
rescale_factor = (targets[1] - targets[0]) / (origins[1] - origins[0])
|
430
|
+
rebase_shift = targets[0] - origins[0] * rescale_factor
|
431
|
+
|
432
|
+
have.subtitles.each do |subtitle|
|
433
|
+
subtitle[:start] = subtitle[:start] * rescale_factor + rebase_shift
|
434
|
+
subtitle[:end] = subtitle[:end] * rescale_factor + rebase_shift
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
438
|
+
def retime_by_framerate(have, want)
|
439
|
+
ratio = want.fps.to_f / have.fps.to_f
|
440
|
+
have.subtitles.each do |subtitle|
|
441
|
+
subtitle[:start] *= ratio
|
442
|
+
subtitle[:end] *= ratio
|
443
|
+
end
|
444
|
+
end
|
445
|
+
end
|
446
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'treetop'
|
2
|
+
|
3
|
+
module Titlekit
|
4
|
+
module ASS
|
5
|
+
|
6
|
+
class Subtitles < Treetop::Runtime::SyntaxNode
|
7
|
+
def build
|
8
|
+
event_section.events.build
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class ScriptInfo < Treetop::Runtime::SyntaxNode
|
13
|
+
def build
|
14
|
+
# elements.map { |subtitle| subtitle.build }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class V4PStyles < Treetop::Runtime::SyntaxNode
|
19
|
+
def build
|
20
|
+
# elements.map { |subtitle| subtitle.build }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class Events < Treetop::Runtime::SyntaxNode
|
25
|
+
def build
|
26
|
+
elements.map do |line|
|
27
|
+
subtitle = {}
|
28
|
+
|
29
|
+
fields = line.text_value.split(',')
|
30
|
+
|
31
|
+
subtitle[:id] = elements.index(line) + 1
|
32
|
+
subtitle[:start] = SSA.parse_timecode(fields[1])
|
33
|
+
subtitle[:end] = SSA.parse_timecode(fields[2])
|
34
|
+
subtitle[:lines] = fields[9..-1].join.gsub('\N', "\n")
|
35
|
+
|
36
|
+
subtitle
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Parses the supplied string and returns the results.
|
42
|
+
#
|
43
|
+
#
|
44
|
+
def self.import(string)
|
45
|
+
Treetop.load(File.join(__dir__, 'ass'))
|
46
|
+
parser = ASSParser.new
|
47
|
+
syntax_tree = parser.parse(string)
|
48
|
+
|
49
|
+
if syntax_tree
|
50
|
+
return syntax_tree.build
|
51
|
+
else
|
52
|
+
failure = "failure_index #{parser.failure_index}\n"
|
53
|
+
failure += "failure_line #{parser.failure_line}\n"
|
54
|
+
failure += "failure_column #{parser.failure_column}\n"
|
55
|
+
failure += "failure_reason #{parser.failure_reason}\n"
|
56
|
+
|
57
|
+
raise failure
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Master the subtitles for best possible usage of the format's features.
|
62
|
+
#
|
63
|
+
# @param subtitles [Array<Hash>] the subtitles to master
|
64
|
+
def self.master(subtitles)
|
65
|
+
tracks = subtitles.map { |subtitle| subtitle[:track] }.uniq
|
66
|
+
|
67
|
+
if tracks.length == 1
|
68
|
+
|
69
|
+
# maybe styling? aside that: nada más!
|
70
|
+
|
71
|
+
elsif tracks.length == 2 || tracks.length == 3
|
72
|
+
|
73
|
+
subtitles.each do |subtitle|
|
74
|
+
case tracks.index(subtitle[:track])
|
75
|
+
when 0
|
76
|
+
subtitle[:style] = 'Default'
|
77
|
+
when 1
|
78
|
+
subtitle[:style] = 'Top'
|
79
|
+
when 2
|
80
|
+
subtitle[:style] = 'Middle'
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
elsif tracks.length >= 4
|
85
|
+
|
86
|
+
mastered_subtitles = []
|
87
|
+
|
88
|
+
# Determine timeframes with a discrete state
|
89
|
+
cuts = subtitles.map { |s| [s[:start], s[:end]] }.flatten.uniq.sort
|
90
|
+
frames = []
|
91
|
+
cuts.each_cons(2) do |pair|
|
92
|
+
frames << { start: pair[0], end: pair[1] }
|
93
|
+
end
|
94
|
+
|
95
|
+
frames.each do |frame|
|
96
|
+
intersecting = subtitles.select do |subtitle|
|
97
|
+
(subtitle[:end] == frame[:end] || subtitle[:start] == frame[:start] ||
|
98
|
+
(subtitle[:start] < frame[:start] && subtitle[:end] > frame[:end]))
|
99
|
+
end
|
100
|
+
|
101
|
+
if intersecting.any?
|
102
|
+
intersecting.sort_by! { |subtitle| tracks.index(subtitle[:track]) }
|
103
|
+
intersecting.each do |subtitle|
|
104
|
+
new_subtitle = {}
|
105
|
+
new_subtitle[:id] = mastered_subtitles.length+1
|
106
|
+
new_subtitle[:start] = frame[:start]
|
107
|
+
new_subtitle[:end] = frame[:end]
|
108
|
+
|
109
|
+
color = DEFAULT_PALETTE[tracks.index(subtitle[:track]) % DEFAULT_PALETTE.length]
|
110
|
+
new_subtitle[:style] = color
|
111
|
+
new_subtitle[:lines] = subtitle[:lines]
|
112
|
+
|
113
|
+
mastered_subtitles << new_subtitle
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
subtitles.replace(mastered_subtitles)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.export(subtitles)
|
123
|
+
result = ''
|
124
|
+
|
125
|
+
result << "[Script Info]\nScriptType: v4.00+\n\n"
|
126
|
+
result << "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
127
|
+
result << "Style: Default,Arial,16,&H00FFFFFF,&H00FFFFFF,&H40000000,&H40000000,0,0,0,0,100,100,0,0.00,1,3,0,2,20,20,20,1\n"
|
128
|
+
result << "Style: Top,Arial,16,&H00FFFFFF,&H00FFFFFF,&H40000000,&H40000000,0,0,0,0,100,100,0,0.00,1,3,0,8,20,20,20,1\n"
|
129
|
+
result << "Style: Middle,Arial,16,&H00FFFFFF,&H00FFFFFF,&H40000000,&H40000000,0,0,0,0,100,100,0,0.00,1,3,0,5,20,20,20,1\n"
|
130
|
+
|
131
|
+
DEFAULT_PALETTE.each do |color|
|
132
|
+
processed_color = '&H00' + (color[4..5] + color[2..3] + color[0..1])
|
133
|
+
result << "Style: #{color},Arial,16,#{processed_color},#{processed_color},&H40000000,&H40000000,0,0,0,0,100,100,0,0.00,1,3,0,2,20,20,20,1\n"
|
134
|
+
end
|
135
|
+
|
136
|
+
result << "\n" # Close styles section
|
137
|
+
|
138
|
+
result << "[Events]\nFormat: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text\n"
|
139
|
+
|
140
|
+
subtitles.each do |subtitle|
|
141
|
+
fields = [
|
142
|
+
'Dialogue: 0', # Format: Marked
|
143
|
+
SSA.build_timecode(subtitle[:start]), # Start
|
144
|
+
SSA.build_timecode(subtitle[:end]), # End
|
145
|
+
subtitle[:style] || 'Default', # Style
|
146
|
+
'', # Name
|
147
|
+
'0000', # MarginL
|
148
|
+
'0000', # MarginR
|
149
|
+
'0000', # MarginV
|
150
|
+
'',# Effect
|
151
|
+
subtitle[:lines].gsub("\n", '\N') # Text
|
152
|
+
]
|
153
|
+
|
154
|
+
result << fields.join(',') + "\n"
|
155
|
+
end
|
156
|
+
|
157
|
+
return result
|
158
|
+
end
|
159
|
+
|
160
|
+
protected
|
161
|
+
|
162
|
+
def self.build_timecode(seconds)
|
163
|
+
sprintf("%01d:%02d:%02d.%s",
|
164
|
+
seconds / 3600,
|
165
|
+
(seconds%3600) / 60,
|
166
|
+
seconds % 60,
|
167
|
+
sprintf("%.2f", seconds)[-2, 3])
|
168
|
+
end
|
169
|
+
|
170
|
+
def self.parse_timecode(timecode)
|
171
|
+
mres = timecode.match(/(?<h>\d):(?<m>\d{2}):(?<s>\d{2})[:|\.](?<ms>\d+)/)
|
172
|
+
return "#{mres["h"].to_i * 3600 + mres["m"].to_i * 60 + mres["s"].to_i}.#{mres["ms"]}".to_f
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
grammar ASS
|
2
|
+
rule subtitles
|
3
|
+
info?
|
4
|
+
styles?
|
5
|
+
event_section
|
6
|
+
fonts?
|
7
|
+
graphics?
|
8
|
+
end_of_data
|
9
|
+
|
10
|
+
<Titlekit::ASS::Subtitles>
|
11
|
+
end
|
12
|
+
|
13
|
+
rule info
|
14
|
+
'[Script Info]' end_of_line lines* end_of_section <Titlekit::ASS::ScriptInfo>
|
15
|
+
end
|
16
|
+
|
17
|
+
rule styles
|
18
|
+
'[V4+ Styles]' end_of_line lines* end_of_section <Titlekit::ASS::V4PStyles>
|
19
|
+
end
|
20
|
+
|
21
|
+
rule event_section
|
22
|
+
'[Events]' end_of_line line events end_of_section
|
23
|
+
end
|
24
|
+
|
25
|
+
rule events
|
26
|
+
line* <Titlekit::ASS::Events>
|
27
|
+
end
|
28
|
+
|
29
|
+
rule event
|
30
|
+
'Dialogue'
|
31
|
+
end
|
32
|
+
|
33
|
+
rule fonts
|
34
|
+
'[Fonts]' end_of_line lines* end_of_section
|
35
|
+
end
|
36
|
+
|
37
|
+
rule graphics
|
38
|
+
'[Graphics]' end_of_line lines* end_of_section
|
39
|
+
end
|
40
|
+
|
41
|
+
rule lines
|
42
|
+
line+
|
43
|
+
end
|
44
|
+
|
45
|
+
rule line
|
46
|
+
string (end_of_line / end_of_file)
|
47
|
+
end
|
48
|
+
|
49
|
+
rule end_of_section
|
50
|
+
end_of_line+ / end_of_file
|
51
|
+
end
|
52
|
+
|
53
|
+
rule end_of_line
|
54
|
+
"\r\n" / "\n" / "\r"
|
55
|
+
end
|
56
|
+
|
57
|
+
rule end_of_data
|
58
|
+
end_of_line+ / end_of_file
|
59
|
+
end
|
60
|
+
|
61
|
+
rule end_of_file
|
62
|
+
!.
|
63
|
+
end
|
64
|
+
|
65
|
+
rule number
|
66
|
+
[0-9]+
|
67
|
+
end
|
68
|
+
|
69
|
+
rule string
|
70
|
+
(!end_of_line .)+
|
71
|
+
end
|
72
|
+
end
|