Class: Timecode

Inherits:
Object
  • Object
show all
Includes:
Approximately, Comparable
Defined in:
lib/timecode.rb

Defined Under Namespace

Classes: CannotParse, ComputationValues, Error, RangeError, WrongDropFlag, WrongFramerate

Constant Summary collapse

VERSION =
'2.2.1'
DEFAULT_FPS =
25.0
STANDARD_RATES =

Quoting the Flame project configs here (as of ver. 2013 at least) TIMECODE KEYWORD


Specifies the default timecode format used by the project. Currently supported formats are 23.976, 24, 25, 29.97, 30, 50, 59.94 or 60 fps timecodes.

[23.976, 24, 25, 29.97, 30, 50, 59.94, 60].map do | float |
  Approximately.approx(float, 0.002) # Tolerance of 2 millisecs should do.
end.freeze
NTSC_FPS =
(30.0 * 1000 / 1001).freeze
FILMSYNC_FPS =
(24.0 * 1000 / 1001).freeze
ALLOWED_FPS_DELTA =
(0.001).freeze
COMPLETE_TC_RE =
/^(\d{2}):(\d{2}):(\d{2}):(\d{2})$/
COMPLETE_TC_RE_24 =
/^(\d{2}):(\d{2}):(\d{2})\+(\d{2})$/
DF_TC_RE =
/^(\d{1,2}):(\d{1,2}):(\d{1,2});(\d{2})$/
FRACTIONAL_TC_RE =
/^(\d{2}):(\d{2}):(\d{2})[\.,](\d{1,8})$/
TICKS_TC_RE =
/^(\d{2}):(\d{2}):(\d{2}):(\d{3})$/
WITH_FRACTIONS_OF_SECOND =
"%02d:%02d:%02d.%02d"
WITH_SRT_FRACTION =
"%02d:%02d:%02d,%02d"
WITH_FRACTIONS_OF_SECOND_COMMA =
"%02d:%02d:%02d,%03d"
WITH_FRAMES =
"%02d:%02d:%02d:%02d"
WITH_FRAMES_DF =
"%02d:%02d:%02d;%02d"
WITH_FRAMES_24 =
"%02d:%02d:%02d+%02d"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(total = 0, fps = DEFAULT_FPS, drop_frame = false) ⇒ Timecode

Initialize a new Timecode object with a certain amount of frames, a framerate and an optional drop frame flag will be interpreted as the total number of frames

Raises:



97
98
99
100
101
102
103
104
105
106
107
# File 'lib/timecode.rb', line 97

def initialize(total = 0, fps = DEFAULT_FPS, drop_frame = false)
  raise WrongFramerate, "FPS cannot be zero" if fps.zero?
  self.class.check_framerate!(fps)
  # If total is a string, use parse
  raise RangeError, "Timecode cannot be negative" if total.to_i < 0
  # Always cast framerate to float, and num of frames to integer
  @total, @fps = total.to_i, fps.to_f
  @drop_frame = drop_frame
  @value = validate!
  freeze
end

Class Method Details

.add_custom_framerate!(rate) ⇒ Object

Use this to add a custom framerate



126
127
128
129
# File 'lib/timecode.rb', line 126

def add_custom_framerate!(rate)
  @custom_framerates ||= []
  @custom_framerates.push(rate)
end

.at(hrs, mins, secs, frames, with_fps = DEFAULT_FPS, drop_frame = false) ⇒ Object

Initialize a Timecode object at this specfic timecode



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/timecode.rb', line 212

def at(hrs, mins, secs, frames, with_fps = DEFAULT_FPS, drop_frame = false)
  validate_atoms!(hrs, mins, secs, frames, with_fps)
  comp = ComputationValues.new(with_fps, drop_frame)
  if drop_frame && secs == 0 && (mins % 10) && (frames < comp.drop_count)
    frames = comp.drop_count
  end
  
  total = hrs * comp.frames_per_hour
  if drop_frame
    total += (mins / 10) * comp.frames_per_10_min
    total += (mins % 10) * comp.frames_per_min
  else
    total += mins * comp.frames_per_min
  end
  rounded_base = with_fps.round
  total += secs * rounded_base
  total += frames
  new(total, with_fps, drop_frame)
end

.check_framerate!(fps) ⇒ Object

Check the passed framerate and raise if it is not in the list



132
133
134
135
136
137
# File 'lib/timecode.rb', line 132

def check_framerate!(fps)
  unless supported_framerates.include?(fps)
    supported = "%s and %s are supported" % [supported_framerates[0..-2].join(", "), supported_framerates[-1]]
    raise WrongFramerate, "Framerate #{fps} is not in the list of supported framerates (#{supported})"
  end
end

.from_filename_in_sequence(filename_with_or_without_path, fps = DEFAULT_FPS) ⇒ Object

Parses the timecode contained in a passed filename as frame number in a sequence



150
151
152
153
154
# File 'lib/timecode.rb', line 150

def from_filename_in_sequence(filename_with_or_without_path, fps = DEFAULT_FPS)
  b = File.basename(filename_with_or_without_path)
  number = b.scan(/\d+/).flatten[-1].to_i
  new(number, fps)
end

.from_seconds(seconds_float, the_fps = DEFAULT_FPS, drop_frame = false) ⇒ Object

create a timecode from the number of seconds. This is how current time is supplied by QuickTime and other systems which have non-frame-based timescales



278
279
280
281
# File 'lib/timecode.rb', line 278

def from_seconds(seconds_float, the_fps = DEFAULT_FPS, drop_frame = false)
  total_frames = (seconds_float.to_f * the_fps.to_f).round.to_i
  new(total_frames, the_fps, drop_frame)
end

.from_uint(uint, fps = DEFAULT_FPS) ⇒ Object

Some systems (like SGIs) and DPX format store timecode as unsigned integer, bit-packed. This method unpacks such an integer into a timecode.



285
286
287
288
289
290
291
292
# File 'lib/timecode.rb', line 285

def from_uint(uint, fps = DEFAULT_FPS)
  tc_elements = (0..7).to_a.reverse.map do | multiplier |
    ((uint >> (multiplier * 4)) & 0x0F)
  end.join.scan(/(\d{2})/).flatten.map{|e| e.to_i}

  tc_elements << fps
  at(*tc_elements)
end

.new(from = nil, fps = DEFAULT_FPS, drop_frame = false) ⇒ Object

Use initialize for integers and parsing for strings



140
141
142
# File 'lib/timecode.rb', line 140

def new(from = nil, fps = DEFAULT_FPS, drop_frame = false)
  from.is_a?(String) ? parse(from, fps) : super(from, fps, drop_frame)
end

.parse(spaced_input, with_fps = DEFAULT_FPS) ⇒ Object

Parse timecode entered by the user. Will raise if the string cannot be parsed. The following formats are supported:

  • 10h 20m 10s 1f (or any combination thereof) - will be disassembled to hours, frames, seconds and so on automatically

  • 123 - will be parsed as 00:00:01:23

  • 00:00:00:00 - will be parsed as zero TC



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/timecode.rb', line 161

def parse(spaced_input, with_fps = DEFAULT_FPS)
  input = spaced_input.strip

  # 00:00:00;00
  if (input =~ DF_TC_RE)
    atoms_and_fps = input.scan(DF_TC_RE).to_a.flatten.map{|e| e.to_i} + [with_fps, true]
    return at(*atoms_and_fps)
  # 00:00:00:00
  elsif (input =~ COMPLETE_TC_RE)
    atoms_and_fps = input.scan(COMPLETE_TC_RE).to_a.flatten.map{|e| e.to_i} + [with_fps]
    return at(*atoms_and_fps)
  # 00:00:00+00
  elsif (input =~ COMPLETE_TC_RE_24)
    atoms_and_fps = input.scan(COMPLETE_TC_RE_24).to_a.flatten.map{|e| e.to_i} + [24]
    return at(*atoms_and_fps)
  # 00:00:00.0
  elsif input =~ FRACTIONAL_TC_RE
    parse_with_fractional_seconds(input, with_fps)
  # 00:00:00:000
  elsif input =~ TICKS_TC_RE
    parse_with_ticks(input, with_fps)
  # 10h 20m 10s 1f 00:00:00:01 - space separated is a sum of parts
  elsif input =~ /\s/
    parts = input.gsub(/\s/, ' ').split.reject{|e| e.strip.empty? }
    raise CannotParse, "No atoms" if parts.empty?
    parts.map{|part|  parse(part, with_fps) }.inject{|sum, p| sum + p.total }
  # 10s
  elsif input =~ /^(\d+)s$/
    return new(input.to_i * with_fps, with_fps)
  # 10h
  elsif input =~ /^(\d+)h$/i
    return new(input.to_i * 60 * 60 * with_fps, with_fps)
  # 20m
  elsif input =~ /^(\d+)m$/i
    return new(input.to_i * 60 * with_fps, with_fps)
  # 60f - 60 frames, or 2 seconds and 10 frames
  elsif input =~ /^(\d+)f$/i
    return new(input.to_i, with_fps)
  # Only a bunch of digits, treat 12345 as 00:01:23:45
  elsif (input =~ /^(\d+)$/)
    atoms_len = 2 * 4
    # left-pad input AND truncate if needed
    padded = input[0..atoms_len].rjust(8, "0")
    atoms = padded.scan(/(\d{2})/).flatten.map{|e| e.to_i } + [with_fps]
    return at(*atoms)
  else
    raise CannotParse, "Cannot parse #{input} into timecode, unknown format"
  end
end

.parse_with_fractional_seconds(tc_with_fractions_of_second, fps = DEFAULT_FPS) ⇒ Object

Parse a timecode with fractional seconds instead of frames. This is how ffmpeg reports a timecode



248
249
250
251
252
253
254
255
256
257
258
# File 'lib/timecode.rb', line 248

def parse_with_fractional_seconds(tc_with_fractions_of_second, fps = DEFAULT_FPS)
  fraction_expr = /[\.,](\d+)$/
  fraction_part = ('.' + tc_with_fractions_of_second.scan(fraction_expr)[0][0]).to_f

  seconds_per_frame = 1.0 / fps.to_f
  frame_idx = (fraction_part / seconds_per_frame).floor

  tc_with_frameno = tc_with_fractions_of_second.gsub(fraction_expr, ":%02d" % frame_idx)

  parse(tc_with_frameno, fps)
end

.parse_with_ticks(tc_with_ticks, fps = DEFAULT_FPS) ⇒ Object

Parse a timecode with ticks of a second instead of frames. A ‘tick’ is defined as 4 msec and has a range of 0 to 249. This format can show up in subtitle files for digital cinema used by CineCanvas systems

Raises:



263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/timecode.rb', line 263

def parse_with_ticks(tc_with_ticks, fps = DEFAULT_FPS)
  ticks_expr = /(\d{3})$/
  num_ticks = tc_with_ticks.scan(ticks_expr).join.to_i

  raise RangeError, "Invalid tick count #{num_ticks}" if num_ticks > 249

  seconds_per_frame = 1.0 / fps
  frame_idx = ( (num_ticks * 0.004) / seconds_per_frame ).floor
  tc_with_frameno = tc_with_ticks.gsub(ticks_expr, "%02d" % frame_idx)

  parse(tc_with_frameno, fps)
end

.soft_parse(input, with_fps = DEFAULT_FPS) ⇒ Object

Parse timecode and return zero if none matched



145
146
147
# File 'lib/timecode.rb', line 145

def soft_parse(input, with_fps = DEFAULT_FPS)
  parse(input) rescue new(0, with_fps)
end

.supported_frameratesObject

Returns the list of supported framerates for this subclass of Timecode



121
122
123
# File 'lib/timecode.rb', line 121

def supported_framerates
  STANDARD_RATES + (@custom_framerates || [])
end

.validate_atoms!(hrs, mins, secs, frames, with_fps) ⇒ Object

Validate the passed atoms for the concrete framerate



233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/timecode.rb', line 233

def validate_atoms!(hrs, mins, secs, frames, with_fps)
  case true
  when hrs > 999
      raise RangeError, "There can be no more than 999 hours, got #{hrs}"
    when mins > 59
      raise RangeError, "There can be no more than 59 minutes, got #{mins}"
    when secs > 59
      raise RangeError, "There can be no more than 59 seconds, got #{secs}"
    when frames >= with_fps
      raise RangeError, "There can be no more than #{with_fps} frames @#{with_fps}, got #{frames}"
  end
end

Instance Method Details

#*(arg) ⇒ Object

Multiply the timecode by a number

Raises:



437
438
439
440
# File 'lib/timecode.rb', line 437

def *(arg)
  raise RangeError, "Timecode multiplier cannot be negative" if (arg < 0)
  self.class.new(@total*arg.to_i, @fps, @drop_frame)
end

#+(arg) ⇒ Object

add number of frames (or another timecode) to this one



401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/timecode.rb', line 401

def +(arg)
  if (arg.is_a?(Timecode) && framerate_in_delta(arg.fps, @fps) && (arg.drop? == @drop_frame))
    self.class.new(@total + arg.total, @fps, @drop_frame)
  elsif (arg.is_a?(Timecode))
    if (arg.drop? != @drop_frame)
      raise WrongDropFlag, "You are calculating timecodes with different drop flag values"
    else
      raise WrongFramerate, "You are calculating timecodes with different framerates"
    end      
  else
    self.class.new(@total + arg, @fps, @drop_frame)
  end
end

#-(arg) ⇒ Object

Subtract a number of frames



422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'lib/timecode.rb', line 422

def -(arg)
  if (arg.is_a?(Timecode) &&  framerate_in_delta(arg.fps, @fps) && (arg.drop? == @drop_frame))
    self.class.new(@total-arg.total, @fps, @drop_frame)
  elsif (arg.is_a?(Timecode))
    if (arg.drop? != @drop_frame)
      raise WrongDropFlag, "You are calculating timecodes with different drop flag values"
    else
      raise WrongFramerate, "You are calculating timecodes with different framerates"
    end      
  else
    self.class.new(@total-arg, @fps, @drop_frame)
  end
end

#/(arg) ⇒ Object

Get the number of times a passed timecode fits into this time span (if performed with Timecode) or a Timecode that multiplied by arg will give this one



449
450
451
# File 'lib/timecode.rb', line 449

def /(arg)
  arg.is_a?(Timecode) ?  (@total / arg.total) : self.class.new(@total / arg, @fps, @drop_frame)
end

#<=>(other_tc) ⇒ Object

Timecodes can be compared to each other



454
455
456
457
458
459
460
# File 'lib/timecode.rb', line 454

def <=>(other_tc)
  if framerate_in_delta(fps, other_tc.fps)
    self.total <=> other_tc.total
  else
    raise WrongFramerate, "Cannot compare timecodes with different framerates"
  end
end

#adjacent_to?(another) ⇒ Boolean

Tells whether the passes timecode is immediately to the left or to the right of that one with a 1 frame difference

Returns:

  • (Boolean)


417
418
419
# File 'lib/timecode.rb', line 417

def adjacent_to?(another)
  (self.succ == another) || (another.succ == self)
end

#coerce(to) ⇒ Object



295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/timecode.rb', line 295

def coerce(to)
  me = case to
    when String
      to_s
    when Integer
      to_i
    when Float
      to_f
    else
      self
  end
  [me, to]
end

#convert(new_fps, drop_frame = @drop_frame) ⇒ Object

Convert to different framerate and drop frame based on the total frames. Therefore, 1 second of PAL video will convert to 25 frames of NTSC (this is suitable for PAL to film TC conversions and back).



372
373
374
# File 'lib/timecode.rb', line 372

def convert(new_fps, drop_frame = @drop_frame)
  self.class.new(@total, new_fps, drop_frame)
end

#drop?Boolean

get DF

Returns:

  • (Boolean)


320
321
322
# File 'lib/timecode.rb', line 320

def drop?
  @drop_frame
end

#fpsObject

get FPS



325
326
327
# File 'lib/timecode.rb', line 325

def fps
  @fps
end

#frame_intervalObject

get frame interval in fractions of a second



350
351
352
# File 'lib/timecode.rb', line 350

def frame_interval
  1.0/@fps
end

#framerate_in_delta(one, two) ⇒ Object

Validate that framerates are within a small delta deviation considerable for floats



481
482
483
# File 'lib/timecode.rb', line 481

def framerate_in_delta(one, two)
  (one.to_f - two.to_f).abs <= ALLOWED_FPS_DELTA
end

#framesObject

get the number of frames



330
331
332
# File 'lib/timecode.rb', line 330

def frames
  value_parts[3]
end

#hoursObject

get the number of hours



345
346
347
# File 'lib/timecode.rb', line 345

def hours
  value_parts[0]
end

#inspectObject

:nodoc:



109
110
111
112
113
114
115
116
# File 'lib/timecode.rb', line 109

def inspect # :nodoc:
  string_repr = if (framerate_in_delta(fps, 24))
    WITH_FRAMES_24 % value_parts
  else
    WITH_FRAMES % value_parts
  end
  "#<Timecode:%s (%dF@%.2f)>" % [string_repr, total, fps]
end

#minutesObject

get the number of minutes



340
341
342
# File 'lib/timecode.rb', line 340

def minutes
  value_parts[1]
end

#secondsObject

get the number of seconds



335
336
337
# File 'lib/timecode.rb', line 335

def seconds
  value_parts[2]
end

#succObject

Get the next frame



443
444
445
# File 'lib/timecode.rb', line 443

def succ
  self.class.new(@total + 1, @fps)
end

#to_fObject

get total frames as float



391
392
393
# File 'lib/timecode.rb', line 391

def to_f
  @total
end

#to_iObject

get total frames as integer



396
397
398
# File 'lib/timecode.rb', line 396

def to_i
  @total
end

#to_sObject

Get formatted SMPTE timecode. Hour count larger than 99 will roll over to the next remainder (129 hours will produce “29:00:00:00:00”). If you need the whole hour count use ‘to_s_without_rollover`



379
380
381
382
383
# File 'lib/timecode.rb', line 379

def to_s
  vs = value_parts
  vs[0] = vs[0] % 100 # Rollover any values > 99
  (@drop_frame ? WITH_FRAMES_DF : WITH_FRAMES) % vs 
end

#to_s_without_rolloverObject

Get formatted SMPTE timecode. Hours might be larger than 99 and will not roll over



386
387
388
# File 'lib/timecode.rb', line 386

def to_s_without_rollover
  WITH_FRAMES % value_parts
end

#to_secondsObject

get the timecode as a floating-point number of seconds (used in Quicktime)



365
366
367
# File 'lib/timecode.rb', line 365

def to_seconds
  (@total / @fps)
end

#to_uintObject

get the timecode as bit-packed unsigned 32 bit int (suitable for DPX and SGI)



355
356
357
358
359
360
361
362
# File 'lib/timecode.rb', line 355

def to_uint
  elements = (("%02d" * 4) % [hours,minutes,seconds,frames]).split(//).map{|e| e.to_i }
  uint = 0
  elements.reverse.each_with_index do | p, i |
    uint |= p << 4 * i
  end
  uint
end

#totalObject

get total frame count



315
316
317
# File 'lib/timecode.rb', line 315

def total
  to_f
end

#with_frames_as_fraction(pattern = WITH_FRACTIONS_OF_SECOND) ⇒ Object Also known as: with_fractional_seconds

FFmpeg expects a fraction of a second as the last element instead of number of frames. Use this method to get the timecode that adheres to that expectation. The return of this method can be fed to ffmpeg directly.

Timecode.parse("00:00:10:24", 25).with_frames_as_fraction #=> "00:00:10.96"


466
467
468
469
470
# File 'lib/timecode.rb', line 466

def with_frames_as_fraction(pattern = WITH_FRACTIONS_OF_SECOND)
  vp = value_parts.dup
  vp[-1] = (100.0 / @fps) * vp[-1]
  pattern % vp
end

#with_srt_fractionObject

SRT uses a fraction of a second as the last element instead of number of frames, with a comma as the separator

Timecode.parse("00:00:10:24", 25).with_srt_fraction #=> "00:00:10,96"


476
477
478
# File 'lib/timecode.rb', line 476

def with_srt_fraction
  with_frames_as_fraction(WITH_SRT_FRACTION)
end

#zero?Boolean

is the timecode at 00:00:00:00

Returns:

  • (Boolean)


310
311
312
# File 'lib/timecode.rb', line 310

def zero?
  @total.zero?
end