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.
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