titlekit 1.0.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 +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
|