timecode 1.1.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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