titlekit 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.travis.yml +3 -0
  4. data/Gemfile +3 -0
  5. data/LICENSE +22 -0
  6. data/README.md +335 -0
  7. data/Rakefile +8 -0
  8. data/lib/titlekit/have.rb +90 -0
  9. data/lib/titlekit/job.rb +446 -0
  10. data/lib/titlekit/parsers/ass.rb +175 -0
  11. data/lib/titlekit/parsers/ass.treetop +72 -0
  12. data/lib/titlekit/parsers/srt.rb +139 -0
  13. data/lib/titlekit/parsers/srt.treetop +73 -0
  14. data/lib/titlekit/parsers/ssa.rb +201 -0
  15. data/lib/titlekit/parsers/ssa.treetop +72 -0
  16. data/lib/titlekit/specification.rb +131 -0
  17. data/lib/titlekit/utilities.rb +3 -0
  18. data/lib/titlekit/version.rb +3 -0
  19. data/lib/titlekit/want.rb +24 -0
  20. data/lib/titlekit.rb +9 -0
  21. data/spec/ass_spec.rb +113 -0
  22. data/spec/automatic_grouping/automatic_grouping_spec.rb +55 -0
  23. data/spec/automatic_grouping/dual_tracks/expected.srt +15 -0
  24. data/spec/automatic_grouping/dual_tracks/one.srt +11 -0
  25. data/spec/automatic_grouping/dual_tracks/out.srt +15 -0
  26. data/spec/automatic_grouping/dual_tracks/two.srt +11 -0
  27. data/spec/automatic_grouping/single_track/expected.srt +24 -0
  28. data/spec/automatic_grouping/single_track/one.srt +11 -0
  29. data/spec/automatic_grouping/single_track/out.srt +24 -0
  30. data/spec/automatic_grouping/single_track/two.srt +11 -0
  31. data/spec/encoding_detection/a/in.ass +0 -0
  32. data/spec/encoding_detection/b/in.srt +2389 -0
  33. data/spec/encoding_detection/b/out.srt +2389 -0
  34. data/spec/encoding_detection/c/in.srt +5320 -0
  35. data/spec/encoding_detection/c/out.srt +5320 -0
  36. data/spec/encoding_detection/encoding_detection_spec.rb +81 -0
  37. data/spec/files/ass/authentic.ass +0 -0
  38. data/spec/files/ass/hard.ass +37 -0
  39. data/spec/files/ass/simple.ass +28 -0
  40. data/spec/files/srt/authentic.srt +2708 -0
  41. data/spec/files/srt/coordinates.srt +13 -0
  42. data/spec/files/srt/simple.srt +12 -0
  43. data/spec/files/ssa/simple.ssa +26 -0
  44. data/spec/files/try/unsupported-output.try +0 -0
  45. data/spec/files/try/unsupported.try +7 -0
  46. data/spec/format_conversion/ass_srt/expected.srt +2327 -0
  47. data/spec/format_conversion/ass_srt/in.ass +485 -0
  48. data/spec/format_conversion/ass_srt/out.srt +2327 -0
  49. data/spec/format_conversion/format_conversion_spec.rb +112 -0
  50. data/spec/format_conversion/srt_ass/expected.ass +19 -0
  51. data/spec/format_conversion/srt_ass/in.srt +12 -0
  52. data/spec/format_conversion/srt_ass/out.ass +19 -0
  53. data/spec/format_conversion/srt_ssa/expected.ssa +19 -0
  54. data/spec/format_conversion/srt_ssa/in.srt +12 -0
  55. data/spec/format_conversion/srt_ssa/out.ssa +19 -0
  56. data/spec/format_conversion/ssa_srt/expected.srt +9 -0
  57. data/spec/format_conversion/ssa_srt/in.ssa +26 -0
  58. data/spec/format_conversion/ssa_srt/out.srt +9 -0
  59. data/spec/job_spec.rb +162 -0
  60. data/spec/simultaneous_subtitles/dual/ass/expected.ass +22 -0
  61. data/spec/simultaneous_subtitles/dual/ass/out.ass +22 -0
  62. data/spec/simultaneous_subtitles/dual/one.srt +11 -0
  63. data/spec/simultaneous_subtitles/dual/srt/expected.srt +27 -0
  64. data/spec/simultaneous_subtitles/dual/srt/out.srt +27 -0
  65. data/spec/simultaneous_subtitles/dual/ssa/expected.ssa +22 -0
  66. data/spec/simultaneous_subtitles/dual/ssa/out.ssa +22 -0
  67. data/spec/simultaneous_subtitles/dual/two.srt +11 -0
  68. data/spec/simultaneous_subtitles/simultaneous_subtitles_spec.rb +220 -0
  69. data/spec/simultaneous_subtitles/triple/ass/expected.ass +25 -0
  70. data/spec/simultaneous_subtitles/triple/ass/out.ass +25 -0
  71. data/spec/simultaneous_subtitles/triple/one.srt +11 -0
  72. data/spec/simultaneous_subtitles/triple/srt/expected.srt +55 -0
  73. data/spec/simultaneous_subtitles/triple/srt/out.srt +55 -0
  74. data/spec/simultaneous_subtitles/triple/ssa/expected.ssa +25 -0
  75. data/spec/simultaneous_subtitles/triple/ssa/out.ssa +25 -0
  76. data/spec/simultaneous_subtitles/triple/three.srt +11 -0
  77. data/spec/simultaneous_subtitles/triple/two.srt +11 -0
  78. data/spec/simultaneous_subtitles/triple_plus/ass/expected.ass +93 -0
  79. data/spec/simultaneous_subtitles/triple_plus/ass/out.ass +93 -0
  80. data/spec/simultaneous_subtitles/triple_plus/five.srt +11 -0
  81. data/spec/simultaneous_subtitles/triple_plus/four.srt +11 -0
  82. data/spec/simultaneous_subtitles/triple_plus/one.srt +11 -0
  83. data/spec/simultaneous_subtitles/triple_plus/six.srt +11 -0
  84. data/spec/simultaneous_subtitles/triple_plus/srt/expected.srt +149 -0
  85. data/spec/simultaneous_subtitles/triple_plus/srt/out.srt +149 -0
  86. data/spec/simultaneous_subtitles/triple_plus/ssa/expected.ssa +93 -0
  87. data/spec/simultaneous_subtitles/triple_plus/ssa/out.ssa +93 -0
  88. data/spec/simultaneous_subtitles/triple_plus/three.srt +11 -0
  89. data/spec/simultaneous_subtitles/triple_plus/two.srt +11 -0
  90. data/spec/spec_helper.rb +7 -0
  91. data/spec/specifications_spec.rb +138 -0
  92. data/spec/srt_spec.rb +134 -0
  93. data/spec/ssa_spec.rb +90 -0
  94. data/spec/timecode_correction/double_reference/expected.srt +13 -0
  95. data/spec/timecode_correction/double_reference/in.srt +12 -0
  96. data/spec/timecode_correction/double_reference/out.srt +13 -0
  97. data/spec/timecode_correction/framerate/expected.srt +5 -0
  98. data/spec/timecode_correction/framerate/in.srt +4 -0
  99. data/spec/timecode_correction/framerate/out.srt +5 -0
  100. data/spec/timecode_correction/framerate_plus_reference/expected.srt +13 -0
  101. data/spec/timecode_correction/framerate_plus_reference/in.srt +12 -0
  102. data/spec/timecode_correction/framerate_plus_reference/out.srt +13 -0
  103. data/spec/timecode_correction/single_reference/expected.srt +13 -0
  104. data/spec/timecode_correction/single_reference/in.srt +12 -0
  105. data/spec/timecode_correction/single_reference/out.srt +13 -0
  106. data/spec/timecode_correction/timecode_correction_spec.rb +124 -0
  107. data/spec/transcoding/gb2312-ascii/in.srt +12 -0
  108. data/spec/transcoding/iso-8859-1_utf-8/expected.srt +12 -0
  109. data/spec/transcoding/iso-8859-1_utf-8/in.srt +11 -0
  110. data/spec/transcoding/iso-8859-1_utf-8/out.srt +12 -0
  111. data/spec/transcoding/transcoding_spec.rb +116 -0
  112. data/spec/transcoding/utf-8_gbk/expected.srt +12 -0
  113. data/spec/transcoding/utf-8_gbk/in.srt +11 -0
  114. data/spec/transcoding/utf-8_gbk/out.srt +12 -0
  115. data/spec/transcoding/windows-1252_utf-8/expected.srt +12 -0
  116. data/spec/transcoding/windows-1252_utf-8/in.srt +11 -0
  117. data/spec/transcoding/windows-1252_utf-8/out.srt +12 -0
  118. data/titlekit.gemspec +28 -0
  119. metadata +313 -0
@@ -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