timecode 1.1.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'approximately', '~> 1.1'
4
+
5
+ group :development do
6
+ gem "jeweler", '1.8.4' # Last one without the stupid nokogiri dependency
7
+ gem "rake"
8
+ gem 'minitest'
9
+ end
data/History.txt CHANGED
@@ -1,3 +1,8 @@
1
+ === 2.1.0 / 2015-10-21
2
+
3
+ * Allow hour counts larger than 99. Note that when printed using to_s the hours will roll over.
4
+ * Improve fractional framerate handling.
5
+
1
6
  === 1.1.2 / 2011-10-11
2
7
 
3
8
  * Fix warnings on Ruby 1.9.3
data/Rakefile CHANGED
@@ -1,14 +1,27 @@
1
1
  require 'rubygems'
2
- require 'hoe'
3
- require './lib/timecode.rb'
4
-
5
- Hoe.spec('timecode') do |p|
6
- p.version = Timecode::VERSION
7
- p.readme_file = 'README.rdoc'
8
- p.extra_rdoc_files = FileList['*.rdoc']
2
+ require 'jeweler'
3
+ require './lib/timecode'
4
+ require 'thread'
5
+ Jeweler::Tasks.new do |gem|
6
+ gem.version = Timecode::VERSION
7
+ gem.name = "timecode"
8
+ gem.summary = "Timecode value class"
9
+ gem.email = "me@julik.nl"
10
+ gem.homepage = "http://guerilla-di.org/timecode"
11
+ gem.authors = ["Julik Tarkhanov"]
12
+ gem.license = 'MIT'
9
13
 
10
- p.developer('Julik', 'me@julik.nl')
11
- p.extra_dev_deps = {"bacon" => ">=0"}
12
- p.rubyforge_name = 'guerilla-di'
13
- p.remote_rdoc_dir = 'timecode'
14
- end
14
+ # Do not package invisibles
15
+ gem.files.exclude ".*"
16
+ end
17
+
18
+ require 'rake/testtask'
19
+ Rake::TestTask.new(:test) do |test|
20
+ test.libs << 'lib' << 'test'
21
+ test.pattern = 'test/**/test_*.rb'
22
+ test.verbose = true
23
+ end
24
+
25
+ Jeweler::RubygemsDotOrgTasks.new
26
+
27
+ task :default => [ :test ]
data/lib/timecode.rb CHANGED
@@ -1,4 +1,4 @@
1
- # Timecode is a convenience object for calculating SMPTE timecode natively.
1
+ # Timecode is a convenience object for calculating SMPTE timecode natively.
2
2
  # The promise is that you only have to store two values to know the timecode - the amount
3
3
  # of frames and the framerate. An additional perk might be to save the dropframeness,
4
4
  # but we avoid that at this point.
@@ -11,33 +11,49 @@
11
11
  # composed_of :source_tc, :class_name => 'Timecode',
12
12
  # :mapping => [%w(source_tc_frames total), %w(tape_fps fps)]
13
13
 
14
+ require "approximately"
15
+
14
16
  class Timecode
15
- VERSION = '1.1.2'
16
17
 
17
- include Comparable
18
-
18
+ VERSION = '2.1.0'
19
+
20
+ include Comparable, Approximately
21
+
19
22
  DEFAULT_FPS = 25.0
20
-
23
+
21
24
  #:stopdoc:
25
+
26
+ # Quoting the Flame project configs here (as of ver. 2013 at least)
27
+ # TIMECODE KEYWORD
28
+ # ----------------
29
+ # Specifies the default timecode format used by the project. Currently
30
+ # supported formats are 23.976, 24, 25, 29.97, 30, 50, 59.94 or 60 fps
31
+ # timecodes.
32
+ STANDARD_RATES = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60].map do | float |
33
+ Approximately.approx(float, 0.002) # Tolerance of 2 millisecs should do.
34
+ end.freeze
35
+
22
36
  NTSC_FPS = (30.0 * 1000 / 1001).freeze
23
37
  FILMSYNC_FPS = (24.0 * 1000 / 1001).freeze
24
38
  ALLOWED_FPS_DELTA = (0.001).freeze
25
-
39
+
26
40
  COMPLETE_TC_RE = /^(\d{2}):(\d{2}):(\d{2}):(\d{2})$/
27
41
  COMPLETE_TC_RE_24 = /^(\d{2}):(\d{2}):(\d{2})\+(\d{2})$/
28
42
  DF_TC_RE = /^(\d{1,2}):(\d{1,2}):(\d{1,2});(\d{2})$/
29
- FRACTIONAL_TC_RE = /^(\d{2}):(\d{2}):(\d{2})\.(\d{1,8})$/
43
+ FRACTIONAL_TC_RE = /^(\d{2}):(\d{2}):(\d{2})[\.,](\d{1,8})$/
30
44
  TICKS_TC_RE = /^(\d{2}):(\d{2}):(\d{2}):(\d{3})$/
31
-
45
+
32
46
  WITH_FRACTIONS_OF_SECOND = "%02d:%02d:%02d.%02d"
47
+ WITH_SRT_FRACTION = "%02d:%02d:%02d,%02d"
48
+ WITH_FRACTIONS_OF_SECOND_COMMA = "%02d:%02d:%02d,%03d"
33
49
  WITH_FRAMES = "%02d:%02d:%02d:%02d"
34
50
  WITH_FRAMES_24 = "%02d:%02d:%02d+%02d"
35
-
51
+
36
52
  #:startdoc:
37
-
53
+
38
54
  # All Timecode lib errors inherit from this
39
55
  class Error < RuntimeError; end
40
-
56
+
41
57
  # Gets raised if timecode is out of range (like 100 hours long)
42
58
  class RangeError < Error; end
43
59
 
@@ -46,12 +62,12 @@ class Timecode
46
62
 
47
63
  # Gets raised when you try to compute two timecodes with different framerates together
48
64
  class WrongFramerate < ArgumentError; end
49
-
65
+
50
66
  # Initialize a new Timecode object with a certain amount of frames and a framerate
51
67
  # will be interpreted as the total number of frames
52
68
  def initialize(total = 0, fps = DEFAULT_FPS)
53
69
  raise WrongFramerate, "FPS cannot be zero" if fps.zero?
54
-
70
+ self.class.check_framerate!(fps)
55
71
  # If total is a string, use parse
56
72
  raise RangeError, "Timecode cannot be negative" if total.to_i < 0
57
73
  # Always cast framerate to float, and num of rames to integer
@@ -59,23 +75,54 @@ class Timecode
59
75
  @value = validate!
60
76
  freeze
61
77
  end
62
-
78
+
63
79
  def inspect # :nodoc:
64
- "#<Timecode:%s (%dF@%.2f)>" % [to_s, total, fps]
80
+ string_repr = if (framerate_in_delta(fps, 24))
81
+ WITH_FRAMES_24 % value_parts
82
+ else
83
+ WITH_FRAMES % value_parts
84
+ end
85
+ "#<Timecode:%s (%dF@%.2f)>" % [string_repr, total, fps]
65
86
  end
66
-
87
+
67
88
  class << self
68
-
89
+
90
+ # Returns the list of supported framerates for this subclass of Timecode
91
+ def supported_framerates
92
+ STANDARD_RATES + (@custom_framerates || [])
93
+ end
94
+
95
+ # Use this to add a custom framerate
96
+ def add_custom_framerate!(rate)
97
+ @custom_framerates ||= []
98
+ @custom_framerates.push(rate)
99
+ end
100
+
101
+ # Check the passed framerate and raise if it is not in the list
102
+ def check_framerate!(fps)
103
+ unless supported_framerates.include?(fps)
104
+ supported = "%s and %s are supported" % [supported_framerates[0..-2].join(", "), supported_framerates[-1]]
105
+ raise WrongFramerate, "Framerate #{fps} is not in the list of supported framerates (#{supported})"
106
+ end
107
+ end
108
+
69
109
  # Use initialize for integers and parsing for strings
70
- def new(from, fps = DEFAULT_FPS)
110
+ def new(from = nil, fps = DEFAULT_FPS)
71
111
  from.is_a?(String) ? parse(from, fps) : super(from, fps)
72
112
  end
73
-
113
+
74
114
  # Parse timecode and return zero if none matched
75
115
  def soft_parse(input, with_fps = DEFAULT_FPS)
76
116
  parse(input) rescue new(0, with_fps)
77
117
  end
78
-
118
+
119
+ # Parses the timecode contained in a passed filename as frame number in a sequence
120
+ def from_filename_in_sequence(filename_with_or_without_path, fps = DEFAULT_FPS)
121
+ b = File.basename(filename_with_or_without_path)
122
+ number = b.scan(/\d+/).flatten[-1].to_i
123
+ new(number, fps)
124
+ end
125
+
79
126
  # Parse timecode entered by the user. Will raise if the string cannot be parsed.
80
127
  # The following formats are supported:
81
128
  # * 10h 20m 10s 1f (or any combination thereof) - will be disassembled to hours, frames, seconds and so on automatically
@@ -83,7 +130,7 @@ class Timecode
83
130
  # * 00:00:00:00 - will be parsed as zero TC
84
131
  def parse(spaced_input, with_fps = DEFAULT_FPS)
85
132
  input = spaced_input.strip
86
-
133
+
87
134
  # Drop frame goodbye
88
135
  if (input =~ DF_TC_RE)
89
136
  raise Error, "We do not support drop-frame TC"
@@ -129,32 +176,32 @@ class Timecode
129
176
  raise CannotParse, "Cannot parse #{input} into timecode, unknown format"
130
177
  end
131
178
  end
132
-
179
+
133
180
  # Initialize a Timecode object at this specfic timecode
134
181
  def at(hrs, mins, secs, frames, with_fps = DEFAULT_FPS)
135
182
  validate_atoms!(hrs, mins, secs, frames, with_fps)
136
183
  total = (hrs*(60*60*with_fps) + mins*(60*with_fps) + secs*with_fps + frames).round
137
184
  new(total, with_fps)
138
185
  end
139
-
186
+
140
187
  # Validate the passed atoms for the concrete framerate
141
188
  def validate_atoms!(hrs, mins, secs, frames, with_fps)
142
189
  case true
143
- when hrs > 99
144
- raise RangeError, "There can be no more than 99 hours, got #{hrs}"
190
+ when hrs > 999
191
+ raise RangeError, "There can be no more than 999 hours, got #{hrs}"
145
192
  when mins > 59
146
193
  raise RangeError, "There can be no more than 59 minutes, got #{mins}"
147
194
  when secs > 59
148
195
  raise RangeError, "There can be no more than 59 seconds, got #{secs}"
149
- when frames > (with_fps - 1)
150
- raise RangeError, "There can be no more than #{with_fps - 1} frames @#{with_fps}, got #{frames}"
196
+ when frames >= with_fps
197
+ raise RangeError, "There can be no more than #{with_fps} frames @#{with_fps}, got #{frames}"
151
198
  end
152
199
  end
153
-
200
+
154
201
  # Parse a timecode with fractional seconds instead of frames. This is how ffmpeg reports
155
202
  # a timecode
156
203
  def parse_with_fractional_seconds(tc_with_fractions_of_second, fps = DEFAULT_FPS)
157
- fraction_expr = /\.(\d+)$/
204
+ fraction_expr = /[\.,](\d+)$/
158
205
  fraction_part = ('.' + tc_with_fractions_of_second.scan(fraction_expr)[0][0]).to_f
159
206
 
160
207
  seconds_per_frame = 1.0 / fps.to_f
@@ -165,33 +212,33 @@ class Timecode
165
212
  parse(tc_with_frameno, fps)
166
213
  end
167
214
 
168
- # Parse a timecode with ticks of a second instead of frames. A 'tick' is defined as
215
+ # Parse a timecode with ticks of a second instead of frames. A 'tick' is defined as
169
216
  # 4 msec and has a range of 0 to 249. This format can show up in subtitle files for digital cinema
170
217
  # used by CineCanvas systems
171
218
  def parse_with_ticks(tc_with_ticks, fps = DEFAULT_FPS)
172
219
  ticks_expr = /(\d{3})$/
173
220
  num_ticks = tc_with_ticks.scan(ticks_expr).join.to_i
174
-
221
+
175
222
  raise RangeError, "Invalid tick count #{num_ticks}" if num_ticks > 249
176
-
223
+
177
224
  seconds_per_frame = 1.0 / fps
178
225
  frame_idx = ( (num_ticks * 0.004) / seconds_per_frame ).floor
179
226
  tc_with_frameno = tc_with_ticks.gsub(ticks_expr, "%02d" % frame_idx)
180
-
227
+
181
228
  parse(tc_with_frameno, fps)
182
229
  end
183
-
230
+
184
231
  # create a timecode from the number of seconds. This is how current time is supplied by
185
232
  # QuickTime and other systems which have non-frame-based timescales
186
233
  def from_seconds(seconds_float, the_fps = DEFAULT_FPS)
187
234
  total_frames = (seconds_float.to_f * the_fps.to_f).to_i
188
235
  new(total_frames, the_fps)
189
236
  end
190
-
237
+
191
238
  # Some systems (like SGIs) and DPX format store timecode as unsigned integer, bit-packed. This method
192
239
  # unpacks such an integer into a timecode.
193
240
  def from_uint(uint, fps = DEFAULT_FPS)
194
- tc_elements = (0..7).to_a.reverse.map do | multiplier |
241
+ tc_elements = (0..7).to_a.reverse.map do | multiplier |
195
242
  ((uint >> (multiplier * 4)) & 0x0F)
196
243
  end.join.scan(/(\d{2})/).flatten.map{|e| e.to_i}
197
244
 
@@ -199,7 +246,7 @@ class Timecode
199
246
  at(*tc_elements)
200
247
  end
201
248
  end
202
-
249
+
203
250
  def coerce(to)
204
251
  me = case to
205
252
  when String
@@ -213,76 +260,81 @@ class Timecode
213
260
  end
214
261
  [me, to]
215
262
  end
216
-
263
+
217
264
  # is the timecode at 00:00:00:00
218
265
  def zero?
219
266
  @total.zero?
220
267
  end
221
-
268
+
222
269
  # get total frame count
223
270
  def total
224
271
  to_f
225
272
  end
226
-
273
+
227
274
  # get FPS
228
275
  def fps
229
276
  @fps
230
277
  end
231
-
278
+
232
279
  # get the number of frames
233
280
  def frames
234
281
  value_parts[3]
235
282
  end
236
-
283
+
237
284
  # get the number of seconds
238
285
  def seconds
239
286
  value_parts[2]
240
287
  end
241
-
288
+
242
289
  # get the number of minutes
243
290
  def minutes
244
291
  value_parts[1]
245
292
  end
246
-
293
+
247
294
  # get the number of hours
248
295
  def hours
249
296
  value_parts[0]
250
297
  end
251
-
298
+
252
299
  # get frame interval in fractions of a second
253
300
  def frame_interval
254
301
  1.0/@fps
255
302
  end
256
-
303
+
257
304
  # get the timecode as bit-packed unsigned 32 bit int (suitable for DPX and SGI)
258
305
  def to_uint
259
306
  elements = (("%02d" * 4) % [hours,minutes,seconds,frames]).split(//).map{|e| e.to_i }
260
307
  uint = 0
261
308
  elements.reverse.each_with_index do | p, i |
262
- uint |= p << 4 * i
309
+ uint |= p << 4 * i
263
310
  end
264
311
  uint
265
312
  end
266
-
313
+
267
314
  # get the timecode as a floating-point number of seconds (used in Quicktime)
268
315
  def to_seconds
269
316
  (@total / @fps)
270
317
  end
271
-
318
+
272
319
  # Convert to different framerate based on the total frames. Therefore,
273
- # 1 second of PAL video will convert to 25 frames of NTSC (this
320
+ # 1 second of PAL video will convert to 25 frames of NTSC (this
274
321
  # is suitable for PAL to film TC conversions and back).
275
322
  def convert(new_fps)
276
323
  self.class.new(@total, new_fps)
277
324
  end
278
-
279
- # get formatted SMPTE timecode
325
+
326
+ # Get formatted SMPTE timecode. Hour count larger than 99 will roll over to the next
327
+ # remainder (129 hours will produce "29:00:00:00:00"). If you need the whole hour count
328
+ # use `to_s_without_rollover`
280
329
  def to_s
281
- if (framerate_in_delta(fps, 24))
282
- WITH_FRAMES_24 % value_parts
283
- else
284
- WITH_FRAMES % value_parts
285
- end
330
+ vs = value_parts
331
+ vs[0] = vs[0] % 100 # Rollover any values > 99
332
+ WITH_FRAMES % vs
333
+ end
334
+
335
+ # Get formatted SMPTE timecode. Hours might be larger than 99 and will not roll over
336
+ def to_s_without_rollover
337
+ WITH_FRAMES % value_parts
286
338
  end
287
339
 
288
340
  # get total frames as float
@@ -294,7 +346,7 @@ class Timecode
294
346
  def to_i
295
347
  @total
296
348
  end
297
-
349
+
298
350
  # add number of frames (or another timecode) to this one
299
351
  def +(arg)
300
352
  if (arg.is_a?(Timecode) && framerate_in_delta(arg.fps, @fps))
@@ -305,13 +357,13 @@ class Timecode
305
357
  self.class.new(@total + arg, @fps)
306
358
  end
307
359
  end
308
-
360
+
309
361
  # Tells whether the passes timecode is immediately to the left or to the right of that one
310
362
  # with a 1 frame difference
311
363
  def adjacent_to?(another)
312
364
  (self.succ == another) || (another.succ == self)
313
365
  end
314
-
366
+
315
367
  # Subtract a number of frames
316
368
  def -(arg)
317
369
  if (arg.is_a?(Timecode) && framerate_in_delta(arg.fps, @fps))
@@ -322,68 +374,73 @@ class Timecode
322
374
  self.class.new(@total-arg, @fps)
323
375
  end
324
376
  end
325
-
377
+
326
378
  # Multiply the timecode by a number
327
379
  def *(arg)
328
380
  raise RangeError, "Timecode multiplier cannot be negative" if (arg < 0)
329
381
  self.class.new(@total*arg.to_i, @fps)
330
382
  end
331
-
383
+
332
384
  # Get the next frame
333
385
  def succ
334
386
  self.class.new(@total + 1, @fps)
335
387
  end
336
-
337
- # Get the number of times a passed timecode fits into this time span (if performed with Timecode) or
388
+
389
+ # Get the number of times a passed timecode fits into this time span (if performed with Timecode) or
338
390
  # a Timecode that multiplied by arg will give this one
339
391
  def /(arg)
340
392
  arg.is_a?(Timecode) ? (@total / arg.total) : self.class.new(@total / arg, @fps)
341
393
  end
342
-
394
+
343
395
  # Timecodes can be compared to each other
344
396
  def <=>(other_tc)
345
397
  if framerate_in_delta(fps, other_tc.fps)
346
398
  self.total <=> other_tc.total
347
- else
399
+ else
348
400
  raise WrongFramerate, "Cannot compare timecodes with different framerates"
349
401
  end
350
402
  end
351
-
403
+
352
404
  # FFmpeg expects a fraction of a second as the last element instead of number of frames. Use this
353
405
  # method to get the timecode that adheres to that expectation. The return of this method can be fed
354
406
  # to ffmpeg directly.
355
407
  # Timecode.parse("00:00:10:24", 25).with_frames_as_fraction #=> "00:00:10.96"
356
- def with_frames_as_fraction
408
+ def with_frames_as_fraction(pattern = WITH_FRACTIONS_OF_SECOND)
357
409
  vp = value_parts.dup
358
410
  vp[-1] = (100.0 / @fps) * vp[-1]
359
- WITH_FRACTIONS_OF_SECOND % vp
411
+ pattern % vp
360
412
  end
361
413
  alias_method :with_fractional_seconds, :with_frames_as_fraction
362
-
414
+
415
+ # SRT uses a fraction of a second as the last element instead of number of frames, with a comma as
416
+ # the separator
417
+ # Timecode.parse("00:00:10:24", 25).with_srt_fraction #=> "00:00:10,96"
418
+ def with_srt_fraction
419
+ with_frames_as_fraction(WITH_SRT_FRACTION)
420
+ end
421
+
363
422
  # Validate that framerates are within a small delta deviation considerable for floats
364
423
  def framerate_in_delta(one, two)
365
424
  (one.to_f - two.to_f).abs <= ALLOWED_FPS_DELTA
366
425
  end
367
-
426
+
368
427
  private
369
-
428
+
370
429
  # Prepare and format the values for TC output
371
430
  def validate!
372
- frames = @total
373
- secs = (@total.to_f/@fps).floor
374
- frames-=(secs*@fps)
375
- mins = (secs/60).floor
376
- secs -= (mins*60)
377
- hrs = (mins/60).floor
378
- mins-= (hrs*60)
431
+ secs = (@total / @fps).floor
432
+ rest_frames = (@total % @fps).floor
433
+ hrs = secs.to_i / 3600
434
+ mins = (secs.to_i / 60) % 60
435
+ secs = secs % 60
379
436
 
380
- self.class.validate_atoms!(hrs, mins, secs, frames, @fps)
437
+ self.class.validate_atoms!(hrs, mins, secs, rest_frames, @fps)
381
438
 
382
- [hrs, mins, secs, frames]
439
+ [hrs, mins, secs, rest_frames]
383
440
  end
384
-
441
+
385
442
  def value_parts
386
443
  @value ||= validate!
387
444
  end
388
-
445
+
389
446
  end