Class: Timecode

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

Overview

Timecode is a convenience object for calculating SMPTE timecode natively. The promise is that you only have to store two values to know the timecode - the amount of frames and the framerate. An additional perk might be to save the dropframeness, but we avoid that at this point.

You can calculate in timecode objects ass well as with conventional integers and floats. Timecode is immutable and can be used as a value object. Timecode objects are sortable.

Here’s how to use it with ActiveRecord (your column names will be source_tc_frames_total and tape_fps)

composed_of :source_tc, :class_name => 'Timecode',
  :mapping => [%w(source_tc_frames total), %w(tape_fps fps)]

Defined Under Namespace

Classes: CannotParse, Error, RangeError, WrongFramerate

Constant Summary collapse

VERSION =
'0.2.1'
DEFAULT_FPS =
25.0
NTSC_FPS =

:stopdoc:

(30.0 * 1000 / 1001).freeze
ALLOWED_FPS_DELTA =
(0.001).freeze
COMPLETE_TC_RE =
/^(\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})$/
WITH_FRACTIONS_OF_SECOND =
"%02d:%02d:%02d.%02d"
WITH_FRAMES =
"%02d:%02d:%02d:%02d"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

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

Initialize a new Timecode object with a certain amount of frames and a framerate will be interpreted as the total number of frames

Raises:



47
48
49
50
51
52
53
54
55
56
# File 'lib/timecode.rb', line 47

def initialize(total = 0, fps = DEFAULT_FPS)
  raise WrongFramerate, "FPS cannot be zero" if fps.zero?
  
  # 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 rames to integer
  @total, @fps = total.to_i, fps.to_f
  @value = validate!
  freeze
end

Class Method Details

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

Initialize a Timecode object at this specfic timecode



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

def at(hrs, mins, secs, frames, with_fps = DEFAULT_FPS)
  validate_atoms!(hrs, mins, secs, frames, with_fps)
  total = (hrs*(60*60*with_fps) +  mins*(60*with_fps) + secs*with_fps + frames).round
  new(total, with_fps)
end

.from_seconds(seconds_float, the_fps = DEFAULT_FPS) ⇒ 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



156
157
158
159
# File 'lib/timecode.rb', line 156

def from_seconds(seconds_float, the_fps = DEFAULT_FPS)
  total_frames = (seconds_float.to_f * the_fps.to_f).to_i
  new(total_frames, the_fps)
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.



163
164
165
166
167
168
169
170
# File 'lib/timecode.rb', line 163

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, fps = DEFAULT_FPS) ⇒ Object

Use initialize for integers and parsing for strings



65
66
67
# File 'lib/timecode.rb', line 65

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

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



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/timecode.rb', line 79

def parse(input, with_fps = DEFAULT_FPS)
  # Drop frame goodbye
  if (input =~ DF_TC_RE)
    raise Error, "We do not support drop-frame TC"
  # 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.0
  elsif input =~ FRACTIONAL_TC_RE
    parse_with_fractional_seconds(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



142
143
144
145
146
147
148
149
150
151
152
# File 'lib/timecode.rb', line 142

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

.soft_parse(input, with_fps = DEFAULT_FPS) ⇒ Object

Parse timecode and return zero if none matched



70
71
72
# File 'lib/timecode.rb', line 70

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

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

Validate the passed atoms for the concrete framerate



127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/timecode.rb', line 127

def validate_atoms!(hrs, mins, secs, frames, with_fps)
  case true
    when hrs > 99
      raise RangeError, "There can be no more than 99 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 -1)
      raise RangeError, "There can be no more than #{with_fps -1} frames @#{with_fps}, got #{frames}"
  end
end

Instance Method Details

#*(arg) ⇒ Object

Multiply the timecode by a number

Raises:



293
294
295
296
# File 'lib/timecode.rb', line 293

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

#+(arg) ⇒ Object

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



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

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

#-(arg) ⇒ Object

Subtract a number of frames



282
283
284
285
286
287
288
289
290
# File 'lib/timecode.rb', line 282

def -(arg)
  if (arg.is_a?(Timecode) &&  framerate_in_delta(arg.fps, @fps))
    self.class.new(@total-arg.total, @fps)
  elsif (arg.is_a?(Timecode))
    raise WrongFramerate, "You are calculating timecodes with different framerates"
  else
    self.class.new(@total-arg, @fps)
  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



305
306
307
# File 'lib/timecode.rb', line 305

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

#<=>(other_tc) ⇒ Object

Timecodes can be compared to each other



310
311
312
313
314
315
316
# File 'lib/timecode.rb', line 310

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)


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

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

#coerce(to) ⇒ Object



173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/timecode.rb', line 173

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) ⇒ Object

Convert to different framerate 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).



245
246
247
# File 'lib/timecode.rb', line 245

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

#fpsObject

get FPS



198
199
200
# File 'lib/timecode.rb', line 198

def fps
  @fps
end

#frame_intervalObject

get frame interval in fractions of a second



223
224
225
# File 'lib/timecode.rb', line 223

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



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

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

#framesObject

get the number of frames



203
204
205
# File 'lib/timecode.rb', line 203

def frames
  value_parts[3]
end

#hoursObject

get the number of hours



218
219
220
# File 'lib/timecode.rb', line 218

def hours
  value_parts[0]
end

#inspectObject

:nodoc:



58
59
60
# File 'lib/timecode.rb', line 58

def inspect # :nodoc:
  "#<Timecode:%s (%dF@%.2f)>" % [to_s, total, fps]
end

#minutesObject

get the number of minutes



213
214
215
# File 'lib/timecode.rb', line 213

def minutes
  value_parts[1]
end

#secondsObject

get the number of seconds



208
209
210
# File 'lib/timecode.rb', line 208

def seconds
  value_parts[2]
end

#succObject

Get the next frame



299
300
301
# File 'lib/timecode.rb', line 299

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

#to_fObject

get total frames as float



255
256
257
# File 'lib/timecode.rb', line 255

def to_f
  @total
end

#to_iObject

get total frames as integer



260
261
262
# File 'lib/timecode.rb', line 260

def to_i
  @total
end

#to_sObject

get formatted SMPTE timecode



250
251
252
# File 'lib/timecode.rb', line 250

def to_s
  WITH_FRAMES % value_parts
end

#to_secondsObject

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



238
239
240
# File 'lib/timecode.rb', line 238

def to_seconds
  (@total / @fps)
end

#to_uintObject

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



228
229
230
231
232
233
234
235
# File 'lib/timecode.rb', line 228

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



193
194
195
# File 'lib/timecode.rb', line 193

def total
  to_f
end

#with_frames_as_fractionObject 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"


322
323
324
325
326
# File 'lib/timecode.rb', line 322

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

#zero?Boolean

is the timecode at 00:00:00:00

Returns:

  • (Boolean)


188
189
190
# File 'lib/timecode.rb', line 188

def zero?
  @total.zero?
end