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 +9 -0
- data/History.txt +5 -0
- data/Rakefile +25 -12
- data/lib/timecode.rb +140 -83
- data/test/test_timecode.rb +352 -275
- data/timecode.gemspec +53 -0
- metadata +108 -69
- data/.DS_Store +0 -0
- data/.gemtest +0 -0
- data/Manifest.txt +0 -7
data/Gemfile
ADDED
data/History.txt
CHANGED
data/Rakefile
CHANGED
@@ -1,14 +1,27 @@
|
|
1
1
|
require 'rubygems'
|
2
|
-
require '
|
3
|
-
require './lib/timecode
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
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})
|
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
|
-
|
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
|
-
|
144
|
-
raise RangeError, "There can be no more than
|
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
|
150
|
-
raise RangeError, "There can be no more than #{with_fps
|
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 =
|
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
|
-
#
|
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
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
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
|
-
|
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
|
-
|
373
|
-
|
374
|
-
|
375
|
-
mins = (secs/60)
|
376
|
-
secs
|
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,
|
437
|
+
self.class.validate_atoms!(hrs, mins, secs, rest_frames, @fps)
|
381
438
|
|
382
|
-
[hrs, mins, secs,
|
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
|