sequin 0.0.1
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/.gitignore +5 -0
- data/CHANGELOG +5 -0
- data/Gemfile +4 -0
- data/LICENSE +0 -0
- data/README.md +210 -0
- data/Rakefile +2 -0
- data/bin/seq +5 -0
- data/lib/listing.rb +334 -0
- data/lib/membership.rb +83 -0
- data/lib/sequin.rb +258 -0
- data/lib/sequin/version.rb +3 -0
- data/sequin.gemspec +21 -0
- metadata +76 -0
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
File without changes
|
data/README.md
ADDED
@@ -0,0 +1,210 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
Sequin
|
4
|
+
======
|
5
|
+
|
6
|
+
Description
|
7
|
+
--------
|
8
|
+
|
9
|
+
Sequin is a collection of thor tasks that allow you to list, test and modify sequences of files on disk.
|
10
|
+
|
11
|
+
Installation
|
12
|
+
--------
|
13
|
+
|
14
|
+
$ gem install sequin
|
15
|
+
|
16
|
+
or
|
17
|
+
|
18
|
+
$ sudo gem install sequin
|
19
|
+
|
20
|
+
Usage
|
21
|
+
--------
|
22
|
+
|
23
|
+
The command is seq, followed by a task:
|
24
|
+
|
25
|
+
Tasks
|
26
|
+
|
27
|
+
* seq list [FILES] # List files as sequences.
|
28
|
+
* seq match [FILES] -r, --regex=REGEX # Match filename naming conventions using a regular expression.
|
29
|
+
* seq check [SIGNATURE] # Check the existence of files defined by a sequence signature.
|
30
|
+
* seq generate [NAME] # Generate a seq config file containing regular expression definitions used in the seq match task.
|
31
|
+
* seq help [TASK] # Describe available tasks or one specific task.
|
32
|
+
* seq usage # Print the usage banner.
|
33
|
+
|
34
|
+
All tasks have a single letter alias. Use m instead of match, c instead of check etc.
|
35
|
+
|
36
|
+
|
37
|
+
### List Sequences
|
38
|
+
|
39
|
+
seq list [FILES]
|
40
|
+
|
41
|
+
List the given files as sequences where possible.
|
42
|
+
|
43
|
+
Options:
|
44
|
+
|
45
|
+
* -v, [--verbose=N] Specify how verbose to be when listing missing frames.
|
46
|
+
|
47
|
+
* 0 Dont mention missing frames
|
48
|
+
* 1 show missing frame count
|
49
|
+
* 2 list missing frame numbers
|
50
|
+
* Default: 1
|
51
|
+
|
52
|
+
Each line of output from this task is a **sequence signature.** It consists of a **filename template**: a filename where the sequence digits replaced with "#"s, followed by a **range spec**: start, end, and by frames in square brackets.
|
53
|
+
|
54
|
+
For example: `../test/a/op_063_8900.####.jpg [0 to 25 by 1]`
|
55
|
+
|
56
|
+
|
57
|
+
|
58
|
+
If any frames are missing, the range spec will be colored red in the output, as will other information about the missing frames according to the verbose options.
|
59
|
+
|
60
|
+
$ seq l * -v 2
|
61
|
+
h264_960x540_film.mov
|
62
|
+
mjpega_1920x1080.mov
|
63
|
+
#### [1 to 30 by 1]
|
64
|
+
006_057_s3d_lf_r04.####.dpx [1 to 30 by 1]
|
65
|
+
006_057_s3d_lf_r05.#.dpx [1 to 30 by 1] - missing: [ 11,14,15,18,22 ]
|
66
|
+
006_057_s3d_lf_r06.#.dpx [1 to 30 by 1]
|
67
|
+
008_055_s3d_lf_r04.####.dpx [1 to 50 by 1]
|
68
|
+
fn_by_three.#.jpg [1 to 25 by 3] - missing: [ 19 ]
|
69
|
+
fn_by_two.#.jpg [1 to 29 by 2]
|
70
|
+
|
71
|
+
$ seq l 006* -v 1
|
72
|
+
006_057_s3d_lf_r04.####.dpx [1 to 30 by 1]
|
73
|
+
006_057_s3d_lf_r05.#.dpx [1 to 30 by 1] - missing: [ 5 frames ]
|
74
|
+
006_057_s3d_lf_r06.#.dpx [1 to 30 by 1]
|
75
|
+
|
76
|
+
|
77
|
+
### Match
|
78
|
+
|
79
|
+
seq match [FILES] -r, --regex=REGEX
|
80
|
+
|
81
|
+
Make sure filenames conform to file naming conventions by testing them against a regular expression.
|
82
|
+
|
83
|
+
Options:
|
84
|
+
|
85
|
+
* -r, --regex=REGEX # Specify a regular expression literally, or by reference to one listed in ~/.seq.yml
|
86
|
+
* -m, [--show-matched] # List files that match the regex, in green
|
87
|
+
* -f, [--show-failed] # List files that failed to match the regex, in red
|
88
|
+
* -a, [--show-all] # List all the files in order with color coding.
|
89
|
+
|
90
|
+
Normally you wouldn't want to type lengthy regular expressions on the command line, so instead they can be stored in a yaml file and referred to by the keys in the yaml data structure. The keys would typically represent projects and filetypes.
|
91
|
+
|
92
|
+
The yaml file looks something like this:
|
93
|
+
|
94
|
+
potter:
|
95
|
+
dpx: ^[0-9]{3}_[0-9]{3}_s3d_(rt|lf)_r[0-9]{2}\.[0-9]{4}\.dpx$
|
96
|
+
mcx: ^[0-9]{3}_[0-9]{3}_ver[0-9]{2,3}\.[0-9]{4}\.xml$
|
97
|
+
conan:
|
98
|
+
dpx: ^[a-z]{2}_[0-9]{3}_[0-9]{4}_(L|R|C)\.[0-9]{4,5}\.dpx$
|
99
|
+
mov: ^[a-z]{2}_h264_[0-9]{3,4}x[0-9]{3,4}_(film|pal|ntsc)\.mov$
|
100
|
+
|
101
|
+
The regular expression can then be referred to on the command line with `--regex potter:dpx` and so on.
|
102
|
+
|
103
|
+
$ seq m * -r potter:dpx
|
104
|
+
Testing against regular expression: /^[0-9]{3}_[0-9]{3}_s3d_(rt|lf)_r[0-9]{2}\.[0-9]{4}\.dpx$/
|
105
|
+
h264_960x540_film.mov FAILED
|
106
|
+
mjpega_1920x1080.mov FAILED
|
107
|
+
#### [1 to 30 by 1] (0 MATCHED, 30 FAILED)
|
108
|
+
006_057_s3d_lf_r04.####.dpx [1 to 30 by 1] (30 MATCHED)
|
109
|
+
006_057_s3d_lf_r05.#.dpx [1 to 30 by 1] (0 MATCHED, 25 FAILED)
|
110
|
+
006_057_s3d_lf_r06.#.dpx [1 to 30 by 1] (0 MATCHED, 30 FAILED)
|
111
|
+
008_055_s3d_lf_r04.####.dpx [1 to 50 by 1] (50 MATCHED)
|
112
|
+
fn_by_three.#.jpg [1 to 28 by 3] (0 MATCHED, 10 FAILED)
|
113
|
+
fn_by_two.#.jpg [1 to 29 by 2] (0 MATCHED, 15 FAILED)
|
114
|
+
op_063_8900.####.jpg [0 to 25 by 1] (0 MATCHED, 26 FAILED)
|
115
|
+
|
116
|
+
|
117
|
+
|
118
|
+
|
119
|
+
### Check membership
|
120
|
+
|
121
|
+
seq check [SIGNATURE]
|
122
|
+
|
123
|
+
Check the existence of files defined by a sequence signature.
|
124
|
+
|
125
|
+
Options:
|
126
|
+
|
127
|
+
* -f, [--show-found] # List files from the signature that are on disk.
|
128
|
+
* -m, [--show-missing] # List files from the signature that are missing on disk.
|
129
|
+
* -e, [--show-extra] # List files that are on disk but not in the signature.
|
130
|
+
* -a, [--show-all] # List the union of all files on disk or in the signature.
|
131
|
+
|
132
|
+
|
133
|
+
Files may either be found, missing, or extra. Extra means the file exists on disk but not in the signature. With no flags, the output will be just a single line showing the number of found, missing or extra. By using the flags, you can list the individual files in the sequence with color coding and and in order.
|
134
|
+
|
135
|
+
$ seq c 006_057_s3d_lf_r04.####.dpx [1 to 30 by 1]
|
136
|
+
006_057_s3d_lf_r04.####.dpx [found: 30] [missing: 0] [extra: 0]
|
137
|
+
|
138
|
+
$ seq c 006_057_s3d_lf_r04.####.dpx [5 to 35 by 1]
|
139
|
+
006_057_s3d_lf_r04.####.dpx [found: 26] [missing: 5] [extra: 4]
|
140
|
+
|
141
|
+
$ seq c 006_057_s3d_lf_r04.####.dpx [5 to 35 by 2] -m
|
142
|
+
006_057_s3d_lf_r04.####.dpx [found: 13] [missing: 3] [extra: 17]
|
143
|
+
006_057_s3d_lf_r04.0031.dpx
|
144
|
+
006_057_s3d_lf_r04.0033.dpx
|
145
|
+
006_057_s3d_lf_r04.0035.dpx
|
146
|
+
|
147
|
+
### Generate
|
148
|
+
|
149
|
+
seq generate [NAME]
|
150
|
+
|
151
|
+
Generate the .sequin config file in your home directory ~/.seq.yml
|
152
|
+
|
153
|
+
Options:
|
154
|
+
* -f, [--force] # Overwrite the file if it exists.
|
155
|
+
|
156
|
+
Currently, NAME must be config. This file contains regular expression definitions used in the `seq match` task.
|
157
|
+
|
158
|
+
$ seq g config
|
159
|
+
Skipping write of /Users/julianmann/.seq.yml because it exists. Use --force to remove it.
|
160
|
+
|
161
|
+
$ seq g config -f
|
162
|
+
Generating /Users/julianmann/.seq.yml
|
163
|
+
|
164
|
+
How Sequin finds sequences
|
165
|
+
-------
|
166
|
+
|
167
|
+
The goal of the sequence listing algorithm is to find sequences without the need
|
168
|
+
for the user to specify where the numbers are in the filenames, or what characters
|
169
|
+
delimit the numbers. This can be tricky because a filename may contain many parts
|
170
|
+
containing numbers. Here's a real world example: `004_055_s3d_lf_r04.0001.dpx`
|
171
|
+
How can we know which number part correctly represents the sequence?
|
172
|
+
It could be any of the following
|
173
|
+
|
174
|
+
###_055_s3d_lf_r04.0001.dpx
|
175
|
+
004_###_s3d_lf_r04.0001.dpx
|
176
|
+
004_055_s#d_lf_r04.0001.dpx
|
177
|
+
004_055_s3d_lf_r##.0001.dpx
|
178
|
+
004_055_s3d_lf_r04.####.dpx
|
179
|
+
|
180
|
+
The strategy we employ is to use the number part that causes the file to be in the largest sequence.
|
181
|
+
|
182
|
+
Consider the files:
|
183
|
+
|
184
|
+
file.04.0001.ext
|
185
|
+
file.04.0002.ext
|
186
|
+
file.04.0003.ext
|
187
|
+
file.05.0001.ext
|
188
|
+
file.05.0002.ext
|
189
|
+
file.05.0003.ext
|
190
|
+
|
191
|
+
There are 5 possible sequences here. Each file could be in 2 of them, one containing 2 files, one containing 3.
|
192
|
+
|
193
|
+
file.04.0001.ext could be in file.##.0001.ext or file.04.####.ext
|
194
|
+
|
195
|
+
There are more files in `file.04.####.ext` than `file.##.0001.ext`, so we resolve to `file.04.####.ext` for this file.
|
196
|
+
|
197
|
+
|
198
|
+
To Do
|
199
|
+
-------
|
200
|
+
|
201
|
+
* Add support for listing recursively
|
202
|
+
* Add modifier tasks such as pad, offset, replace etc.
|
203
|
+
* Test colors in shells other than bash.
|
204
|
+
* Test on Ruby 1.8.7. Currently works under Ruby 1.9.2
|
205
|
+
|
206
|
+
|
207
|
+
License
|
208
|
+
-------
|
209
|
+
|
210
|
+
See the LICENSE file for further details.
|
data/Rakefile
ADDED
data/bin/seq
ADDED
data/lib/listing.rb
ADDED
@@ -0,0 +1,334 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'pp'
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
# add colors to String class
|
6
|
+
class String
|
7
|
+
def colorize(code)
|
8
|
+
"\e[0;#{code.to_s}m#{self}\e[0m"
|
9
|
+
|
10
|
+
# self
|
11
|
+
end
|
12
|
+
|
13
|
+
# I tried to put "define_method" in a loop but it was having none of it
|
14
|
+
# Please accept my humble apologies
|
15
|
+
def red; self.colorize(31);end
|
16
|
+
def green; self.colorize(32);end
|
17
|
+
def yellow; self.colorize(33);end
|
18
|
+
def blue; self.colorize(34);end
|
19
|
+
def purple; self.colorize(35);end
|
20
|
+
def cyan; self.colorize(36);end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
module Sequin
|
26
|
+
|
27
|
+
class Sequin::Listing
|
28
|
+
|
29
|
+
# The goal of this sequence listing algorithm is to find sequences without the need
|
30
|
+
# for the user to specify where the numbers are in the filenames, or what characters
|
31
|
+
# delimit the numbers. This can be tricky because a filename may contain many parts
|
32
|
+
# containing numbers. Here's a real world example: 004_055_s3d_lf_r04.0001.dpx
|
33
|
+
# How can we know which number part correctly represents the sequence?
|
34
|
+
# It could be any of the following
|
35
|
+
# ###_055_s3d_lf_r04.0001.dpx
|
36
|
+
# 004_###_s3d_lf_r04.0001.dpx
|
37
|
+
# 004_055_s#d_lf_r04.0001.dpx
|
38
|
+
# 004_055_s3d_lf_r##.0001.dpx
|
39
|
+
# 004_055_s3d_lf_r04.####.dpx
|
40
|
+
#
|
41
|
+
# The strategy we employ is to use the number part that causes the file to be in the largest sequence.
|
42
|
+
|
43
|
+
# Consider the files:
|
44
|
+
# file.04.0001.ext
|
45
|
+
# file.04.0002.ext
|
46
|
+
# file.04.0003.ext
|
47
|
+
# file.05.0001.ext
|
48
|
+
# file.05.0002.ext
|
49
|
+
# file.05.0003.ext
|
50
|
+
#
|
51
|
+
# There are 5 possible sequences here. Each file could be in 2 of them, one containing 2 files, one containing 3.
|
52
|
+
# file.04.0001.ext could be in file.##.0001.ext or file.04.####.ext
|
53
|
+
# There are more files in file.04.####.ext than file.##.0001.ext, so we use that sequence for this file.
|
54
|
+
|
55
|
+
|
56
|
+
|
57
|
+
def initialize(files)
|
58
|
+
@sequences = Hash.new
|
59
|
+
|
60
|
+
# make a hash containing a signature key for all possible sequences
|
61
|
+
all_seqs = all_possible_sequences files
|
62
|
+
|
63
|
+
# now select from that hash the best sequences according
|
64
|
+
# to the strategy outlined above
|
65
|
+
select_best_sequences(all_seqs, files)
|
66
|
+
|
67
|
+
# we need to make a note of the padding because
|
68
|
+
# we will be converting the number part to integers
|
69
|
+
each_sequence { |k,v| v[:padding] = v[:numbers].min.length }
|
70
|
+
|
71
|
+
# convert the number part to integers and sort them
|
72
|
+
# so we can figure out the range, by_frame, and missing frames
|
73
|
+
each_sequence { |k,v| v[:numbers].collect! {|el| el.to_i} }
|
74
|
+
each_sequence { |k,v| v[:numbers].sort! }
|
75
|
+
|
76
|
+
# find the by_frame so that we can figure out which
|
77
|
+
# frames are missing, if any. If we don't get
|
78
|
+
# the by_frame for a sequence that is by 2 for example, we would
|
79
|
+
# think that every other frame was missing.
|
80
|
+
each_sequence {|k,v| v[:by] = calc_by_frame v[:numbers]}
|
81
|
+
|
82
|
+
# now we know the by frame, we can find out which frames
|
83
|
+
# are missing because the user will want to know this.
|
84
|
+
each_sequence do |k,v|
|
85
|
+
range = v[:numbers].min..v[:numbers].max
|
86
|
+
v[:missing] = []
|
87
|
+
range.step(v[:by]) do |val|
|
88
|
+
v[:missing] << val unless v[:numbers].include? val
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# we no longer need the filenames. We have enough info to
|
93
|
+
# recreate them if necessary
|
94
|
+
each_sequence {|k,v| v.delete(:names) }
|
95
|
+
|
96
|
+
# here we change the hash sequence keys (which are arrays). We make the number slot
|
97
|
+
# represent the padding. This is so we can create a sequence key
|
98
|
+
# by simply calling join on the array elements
|
99
|
+
each_sequence { |k,v| k[v[:numbers_index]] = ("#" *v[:padding]) }
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
# for debugging
|
104
|
+
def inspect
|
105
|
+
pp @sequences
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
|
110
|
+
# a sequence string describes the sequence like so.
|
111
|
+
# filename [ <start> to <end> by <by_frame> ] - missing [ <missing frames> ]
|
112
|
+
# This is what gets printed out in a listing, and can also be used as input
|
113
|
+
# to other seq commands such as seq pad, seq renumber, seq substitute
|
114
|
+
# The missing frames section is optional. When a sequence_signature is used as input
|
115
|
+
# the missing frames will be determined by seq internally.
|
116
|
+
def sequence_signature(k,v,verbose=1)
|
117
|
+
range_str= "[#{v[:numbers].min} to #{v[:numbers].max} by #{v[:by]}]"
|
118
|
+
unless v[:missing].empty?
|
119
|
+
range_str = "#{range_str} - missing: [ #{v[:missing].count} frames ]" if verbose == 1
|
120
|
+
range_str = "#{range_str} - missing: [ #{v[:missing].join(",")} ]"if verbose == 2
|
121
|
+
range_str = range_str.red
|
122
|
+
else
|
123
|
+
range_str = range_str.green
|
124
|
+
end
|
125
|
+
str = "#{k.join()} #{range_str}"
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
|
130
|
+
# simple listing of single files and sequences
|
131
|
+
def list(verbose=1)
|
132
|
+
each_single {|f| puts f}
|
133
|
+
|
134
|
+
each_sequence do |k,v|
|
135
|
+
str = sequence_signature(k,v,verbose)
|
136
|
+
puts str
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
# match a sequence against a regex.
|
142
|
+
# create an array where
|
143
|
+
# each element is a sub array of the form
|
144
|
+
# ["filename", <:matched|:failed>, frame_number]
|
145
|
+
#
|
146
|
+
# The sequence is more managable as a single
|
147
|
+
# array as it can be sorted.
|
148
|
+
def self.match_matrix(regex, key , seq)
|
149
|
+
prefix, suffix = key.join().split(/#+/)
|
150
|
+
matrix = []
|
151
|
+
seq[:numbers].each do |n|
|
152
|
+
fn = [prefix , ("%0#{seq[:padding]}d" % n) , suffix].join()
|
153
|
+
unless regex.match(File.basename(fn)).nil?
|
154
|
+
matrix << [fn,:matched,n]
|
155
|
+
else
|
156
|
+
matrix << [fn,:failed,n]
|
157
|
+
end
|
158
|
+
end
|
159
|
+
matrix.sort! { |a,b| a[2] <=> b[2] }
|
160
|
+
end
|
161
|
+
|
162
|
+
|
163
|
+
|
164
|
+
|
165
|
+
|
166
|
+
# make a one line message describing the result of this
|
167
|
+
# matching a regex against all files in a sequence. Most of
|
168
|
+
# the info we need is in the fn_matrix array, but we also
|
169
|
+
# need the sequence key/value pair to create the signature.
|
170
|
+
def assessment_message(fn_matrix,k,v)
|
171
|
+
matched = fn_matrix.count { |el| el[1] == :matched }
|
172
|
+
failed = fn_matrix.count - matched
|
173
|
+
|
174
|
+
msg = "#{matched} MATCHED"
|
175
|
+
msg = "#{msg}, #{failed} FAILED" if failed > 0
|
176
|
+
msg = "(#{msg})"
|
177
|
+
msg = "#{msg} / #{fn_matrix.count} files" if failed > 0 && matched > 0
|
178
|
+
|
179
|
+
if failed == 0
|
180
|
+
msg = msg.green
|
181
|
+
elsif matched == 0
|
182
|
+
msg = msg.red
|
183
|
+
else
|
184
|
+
msg = msg.yellow
|
185
|
+
end
|
186
|
+
|
187
|
+
fn = sequence_signature(k,v,0)
|
188
|
+
message = "#{fn} #{msg}"
|
189
|
+
end
|
190
|
+
|
191
|
+
|
192
|
+
|
193
|
+
# this proc is called from the main script. Matches a regex against
|
194
|
+
# all single files and all sequences in the @sequences object and outputs
|
195
|
+
# information according to verbosity options
|
196
|
+
def match(the_regex, options ={})
|
197
|
+
re = Regexp.new(the_regex)
|
198
|
+
puts "Testing against regular expression: /#{the_regex}/".cyan
|
199
|
+
|
200
|
+
each_single do |f|
|
201
|
+
msg = re.match(File.basename(f)).nil? ? 'FAILED'.red : 'PASSED'.green
|
202
|
+
puts "#{f} #{msg}"
|
203
|
+
end
|
204
|
+
|
205
|
+
each_sequence do |k,v|
|
206
|
+
fn_matrix = Sequin::Listing.match_matrix(re, k,v)
|
207
|
+
puts "#{assessment_message(fn_matrix,k,v)}"
|
208
|
+
unless options["show_all"].nil? && options["show_matched"].nil? && options["show_failed"].nil?
|
209
|
+
fn_matrix.each do |el|
|
210
|
+
key = "show_#{el[1].to_s}"
|
211
|
+
puts el[0].send(Sequin::Listing.alert_colors[el[1]]) unless options[key].nil? && options["show_all"].nil?
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
end
|
217
|
+
|
218
|
+
|
219
|
+
# takes a block and iterates through sequence
|
220
|
+
# keys, ignoring single files
|
221
|
+
def each_sequence
|
222
|
+
@sequences.each do |k,v|
|
223
|
+
yield(k,v) unless k == :single
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# takes a block and iterates through single
|
228
|
+
# files, ignoring . and .. and sequences
|
229
|
+
def each_single
|
230
|
+
@sequences.each do |k,v|
|
231
|
+
if k == :single
|
232
|
+
v.each { |f| yield(f) unless( f =="." || f == "..") }
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
|
238
|
+
private
|
239
|
+
|
240
|
+
|
241
|
+
def all_possible_sequences(files)
|
242
|
+
h = Hash.new
|
243
|
+
files.each do |f|
|
244
|
+
|
245
|
+
# an array containing the number parts in the odd slots: [1] [3] etc.
|
246
|
+
parts = f.split(/([0-9]+)/)
|
247
|
+
|
248
|
+
# If there are an even number of parts, half of them are numbers
|
249
|
+
# If there are an odd number of parts, there are more not-numbers than numbers
|
250
|
+
# By dividing by 2 and relying on integer rounding, we get the right answer in both cases
|
251
|
+
number_count = parts.count / 2
|
252
|
+
|
253
|
+
# For every number position, make our hash key from the remaining parts
|
254
|
+
# and make the hash value another hash containing the filenames, numbers, and other info
|
255
|
+
(0...number_count).each do |i|
|
256
|
+
number_part_index = (i*2)+1
|
257
|
+
key =[]
|
258
|
+
(0...parts.count).each do |part_index|
|
259
|
+
key << (part_index == number_part_index ? "#" : parts[part_index] )
|
260
|
+
end
|
261
|
+
|
262
|
+
h[key] ||= {}
|
263
|
+
(h[key][:names] ||= []) << f
|
264
|
+
(h[key][:numbers] ||= []) << parts[number_part_index]
|
265
|
+
h[key][:numbers_index] = number_part_index
|
266
|
+
end
|
267
|
+
end
|
268
|
+
h
|
269
|
+
end
|
270
|
+
|
271
|
+
|
272
|
+
# Find the best sequence that each file belongs to.
|
273
|
+
# As mentioned above, the best sequence is the one with
|
274
|
+
# the most files out of the set of all sequences that
|
275
|
+
# this file is in.
|
276
|
+
def select_best_sequences(all_seqs, files)
|
277
|
+
files.each do |f|
|
278
|
+
most_frames = 0
|
279
|
+
best_hash = {}
|
280
|
+
best_key = nil
|
281
|
+
|
282
|
+
all_seqs.each do |k,v|
|
283
|
+
curr_frames = all_seqs[k][:numbers].count
|
284
|
+
if curr_frames > most_frames
|
285
|
+
if all_seqs[k][:names].include?(f)
|
286
|
+
best_hash = all_seqs[k]
|
287
|
+
best_key = k
|
288
|
+
most_frames = curr_frames
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
if best_key.nil? || most_frames < 2
|
293
|
+
( @sequences[:single] ||= [] ) << f
|
294
|
+
else
|
295
|
+
@sequences[best_key] = best_hash
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
# to determine the by_frame, we calculate the greatest common factor of
|
301
|
+
# all the gaps between successive frames. If the gap gets to 1, then break
|
302
|
+
# because we don't consider fractional frame numbers. 1 is the smallest gap allowed.
|
303
|
+
# Note: with this method, we can still know the by_frame even if some frames
|
304
|
+
# are missing
|
305
|
+
def calc_by_frame(frames)
|
306
|
+
gcf = frames.max - frames.min
|
307
|
+
last = frames.min
|
308
|
+
frames.each do |n|
|
309
|
+
if n != frames.min
|
310
|
+
new_gcf = greatest_common_factor(gcf, n - last)
|
311
|
+
gcf = new_gcf if new_gcf < gcf
|
312
|
+
break if gcf == 1
|
313
|
+
last = n
|
314
|
+
end
|
315
|
+
end
|
316
|
+
gcf
|
317
|
+
end
|
318
|
+
|
319
|
+
|
320
|
+
# recursive GCF
|
321
|
+
def greatest_common_factor(n1, n2)
|
322
|
+
return ( n1 % n2==0 ) ? n2 : greatest_common_factor(n2, n1 % n2 )
|
323
|
+
end
|
324
|
+
|
325
|
+
def self.alert_colors
|
326
|
+
{ :matched =>"green", :failed =>"red" }
|
327
|
+
end
|
328
|
+
|
329
|
+
|
330
|
+
|
331
|
+
end
|
332
|
+
|
333
|
+
|
334
|
+
end
|
data/lib/membership.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
|
2
|
+
|
3
|
+
module Sequin
|
4
|
+
|
5
|
+
|
6
|
+
class Membership
|
7
|
+
|
8
|
+
# Membership
|
9
|
+
# In order to give information about files in relation to
|
10
|
+
# the sequence signature, we need to collect all the filenames
|
11
|
+
# the user asks for, and all the filenames on disk that
|
12
|
+
# match the sequence mask (the filename part of the sequence signature).
|
13
|
+
# Thewn we can determine whether the file was (found), (misssing),
|
14
|
+
# or (extra) i.e. existing but not asked for
|
15
|
+
#
|
16
|
+
# Each element in the array returned by this method is a
|
17
|
+
# 3 element sub array that has the following form:
|
18
|
+
# ["filename", <:found|:missing|:extra>, frame_number]
|
19
|
+
#
|
20
|
+
# It is sorted on the frame_number, which is an integer
|
21
|
+
def initialize(fn_template, options={} )
|
22
|
+
|
23
|
+
@fn_template = fn_template
|
24
|
+
@fn_matrix = Dir.glob(fn_template.gsub(/#+/, "*")).to_a
|
25
|
+
prefix, suffix = @fn_template.split(/#+/)
|
26
|
+
|
27
|
+
@fn_matrix.collect! { |f| [f, :extra] }
|
28
|
+
|
29
|
+
range = options[:start_frame]..options[:end_frame]
|
30
|
+
range.step(options[:by_frame] || 1) do |val|
|
31
|
+
|
32
|
+
filename = fn_template.gsub(/#+/, ("%0#{options[:padding]}d" % val) )
|
33
|
+
|
34
|
+
el = @fn_matrix.find {|e| e[0] == filename }
|
35
|
+
if el.nil?
|
36
|
+
@fn_matrix << [filename , :missing ]
|
37
|
+
else
|
38
|
+
el[1] = :found
|
39
|
+
end
|
40
|
+
end
|
41
|
+
@fn_matrix.each { |el| el[2] = el[0].sub(prefix,'').sub(suffix,'').to_i }
|
42
|
+
@fn_matrix.sort! { |a,b| a[2] <=> b[2] }
|
43
|
+
end
|
44
|
+
|
45
|
+
# generate a message from the filename matrix - something like this:
|
46
|
+
# ../test/a/006_057_s3d_lf_r06.#.dpx [found: 13] [missing: 3] [extra: 17]
|
47
|
+
# where the found missing and extra sections are color coded according
|
48
|
+
# to whether there were files present with that attribute
|
49
|
+
def assessment_message
|
50
|
+
message = @fn_template
|
51
|
+
[:found, :missing, :extra].each do |t|
|
52
|
+
num_present = @fn_matrix.count { |el| el[1] == t }
|
53
|
+
m = "[#{t.to_s}: #{num_present}]"
|
54
|
+
m = (num_present > 0) ? m.send(Sequin::Membership.alert_colors[t][:present]) : m.send(Sequin::Membership.alert_colors[t][:absent])
|
55
|
+
message = "#{message} #{m}"
|
56
|
+
end
|
57
|
+
message
|
58
|
+
end
|
59
|
+
|
60
|
+
# output a brief assessment message and optionally list files
|
61
|
+
# that are found, missing, and/or extra
|
62
|
+
def output(options={})
|
63
|
+
puts "#{assessment_message}"
|
64
|
+
@fn_matrix.each do |el|
|
65
|
+
key = "show_#{el[1].to_s}"
|
66
|
+
puts el[0].send(Sequin::Membership.alert_colors[el[1]][:present]) unless options[key].nil? && options["show_all"].nil?
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def self.alert_colors
|
74
|
+
{
|
75
|
+
:found => {:present => "green", :absent => "red"} ,
|
76
|
+
:missing => {:present =>"red", :absent => "green"} ,
|
77
|
+
:extra => {:present =>"yellow", :absent => "green"}
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
data/lib/sequin.rb
ADDED
@@ -0,0 +1,258 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'pp'
|
3
|
+
require "thor"
|
4
|
+
require 'fileutils'
|
5
|
+
require 'yaml'
|
6
|
+
require File.expand_path(File.dirname(__FILE__) + '/listing')
|
7
|
+
require File.expand_path(File.dirname(__FILE__) + '/membership')
|
8
|
+
|
9
|
+
module Sequin
|
10
|
+
|
11
|
+
class Seq < Thor
|
12
|
+
|
13
|
+
|
14
|
+
no_tasks do
|
15
|
+
# Some typical regular expressions for use by seq:match.
|
16
|
+
# When seq:generate config is called, this hash is dumped to a yaml file
|
17
|
+
def regex_hash
|
18
|
+
h =
|
19
|
+
{'project1' =>
|
20
|
+
{'dpx' => '^[0-9]{3}_[0-9]{3}_s3d_(rt|lf)_r[0-9]{2}\.[0-9]{4}\.dpx$',
|
21
|
+
'jpg' => '^[0-9]{3}_[0-9]{3}_s3d_r[0-9]{2}\.[0-9]{4}\.jpg$',
|
22
|
+
'mcx' => '^[0-9]{3}_[0-9]{3}_ver[0-9]{2,3}\.[0-9]{4}\.xml$'},
|
23
|
+
'project2' =>
|
24
|
+
{ 'dpx' => '^[a-z]{2}_[0-9]{3}_[0-9]{4}_(L|R|C)\.[0-9]{4,5}\.dpx$',
|
25
|
+
'jpg' => '^[a-z]{2}_[0-9]{3}_[0-9]{4}_(L|R|C)\.[0-9]{4,5}\.jpg$',
|
26
|
+
'mov' => '^[a-z]{2}_h264_[0-9]{3,4}x[0-9]{3,4}_(film|pal|ntsc)\.mov$' }
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def regex_comments
|
31
|
+
<<-COMMENTS
|
32
|
+
#------------------------------------------------------------------------------------
|
33
|
+
#
|
34
|
+
# ~/.seq.yml
|
35
|
+
#
|
36
|
+
# The regular expressions in this file represent naming conventions for various
|
37
|
+
# projects and file_types. By matching filenames against these regexes, you can
|
38
|
+
# quickly determine if they conform to the desired naming convention.
|
39
|
+
#
|
40
|
+
# The regexes are accessed on the command line using the hash keys separated by
|
41
|
+
# ":". i.e. seq match <files> --regex project1:dpx
|
42
|
+
#
|
43
|
+
# For regex newbies, here are some disections:
|
44
|
+
# ^[a-z]+_[0-9]{3}_s3d_(rt|lf)_r[0,1][0-9]\.[0-9]{4}\.dpx$
|
45
|
+
# ^ : the next character must be at the start of the string
|
46
|
+
# [a-z]+_ : one or more lower case letters followed by an underscore
|
47
|
+
# [0-9]{3}_ : exactly 3 numbers
|
48
|
+
# _s3d_ : "_s3d_"
|
49
|
+
# (rt|lf) : "rt" or "lf"
|
50
|
+
# r[0,1][0-9] : "r" followed by a 2 digit number between 00 and 19
|
51
|
+
# \. : "." It must be escaped
|
52
|
+
# [0-9]{4} : 4 padded number
|
53
|
+
# \.dpx : ".dpx" extension.
|
54
|
+
# $ : The preceding character must be the last character. "x" in this case.
|
55
|
+
#------------------------------------------------------------------------------------
|
56
|
+
|
57
|
+
COMMENTS
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
# When the user types seq and gives no task, we want to show
|
64
|
+
# the default help with our own mods added.
|
65
|
+
default_task :usage
|
66
|
+
|
67
|
+
desc "usage", "Print the usage banner"
|
68
|
+
def usage
|
69
|
+
help # default Task descriptions
|
70
|
+
|
71
|
+
puts <<-BANNER
|
72
|
+
------------------------------------------------------------------------------------
|
73
|
+
Seq is a collection of commands to help deal with sequences on disk
|
74
|
+
|
75
|
+
More help here
|
76
|
+
|
77
|
+
BANNER
|
78
|
+
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
|
83
|
+
|
84
|
+
|
85
|
+
|
86
|
+
|
87
|
+
# map "i" => :inspect
|
88
|
+
# desc "inspect [FILES]", "inspect raw sequence hash for debugging purposes"
|
89
|
+
# def inspect(*files)
|
90
|
+
# files = Dir.new(".").entries if files.count == 0
|
91
|
+
# l = Sequin::Listing.new(files)
|
92
|
+
# l.inspect
|
93
|
+
# end
|
94
|
+
|
95
|
+
|
96
|
+
map "l" => :list
|
97
|
+
desc "list [FILES]", "list files as sequences"
|
98
|
+
method_option :verbose,:type => :numeric, :default => 1, :aliases => "-v", :desc => "0 => dont mention missing frames, 1 => show missing frame count, 2 => list missing frame numbers"
|
99
|
+
def list(*files)
|
100
|
+
unless files.count == 0
|
101
|
+
l = Sequin::Listing.new(files)
|
102
|
+
l.list(options[:verbose])
|
103
|
+
else
|
104
|
+
msg = "No files."
|
105
|
+
puts msg.red
|
106
|
+
help :list
|
107
|
+
return
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
|
112
|
+
|
113
|
+
|
114
|
+
map "g" => :generate
|
115
|
+
desc "generate [NAME]", "Generate a seq config file containing regular expression definitions used in the seq:match command. Currently, NAME must be config"
|
116
|
+
method_option :force, :type => :boolean, :aliases => "-f", :desc => "Overwrite the file if it exists.", :default => false
|
117
|
+
|
118
|
+
def generate(name)
|
119
|
+
if name.downcase == "config"
|
120
|
+
yml = File.join(ENV['HOME'],".seq.yml")
|
121
|
+
FileUtils.rm(yml) if options[:force] && File.exists?(yml)
|
122
|
+
if File.exists?(yml)
|
123
|
+
msg = "Skipping write of #{yml} because it exists. Use --force to remove it."
|
124
|
+
puts msg.red
|
125
|
+
help :generate
|
126
|
+
return
|
127
|
+
else
|
128
|
+
puts "Generating #{yml}"
|
129
|
+
File.open(yml , 'w') do |f|
|
130
|
+
f.write(regex_comments)
|
131
|
+
YAML.dump(regex_hash, f)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
else
|
135
|
+
msg = "Error: #{name} is not recognized."
|
136
|
+
puts msg.red
|
137
|
+
help :generate
|
138
|
+
return
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
map "c" => :check
|
143
|
+
desc "check [SIGNATURE]", "Check the existence of files defined by a sequence signature."
|
144
|
+
method_option :show_missing, :type => :boolean, :aliases => "-m", :desc => "List files from the signature that are missing on disk"
|
145
|
+
method_option :show_extra, :type => :boolean, :aliases => "-e", :desc => "List files that are on disk but not in the signature"
|
146
|
+
method_option :show_found, :type => :boolean, :aliases => "-f", :desc => "List files from the signature that are on disk"
|
147
|
+
method_option :show_all, :type => :boolean, :aliases => "-a", :desc => "List the union of all files on disk and in the signature."
|
148
|
+
def check(*args)
|
149
|
+
signature = args.join(" ")
|
150
|
+
|
151
|
+
chunks = signature.split(/\[|\]/)
|
152
|
+
if chunks.count < 2
|
153
|
+
display_str = (signature.length > 60) ? "#{signature[0, 60]}..." : signature
|
154
|
+
msg = "Error: #{display_str} is not a valid sequence signature."
|
155
|
+
puts msg.red
|
156
|
+
help :check
|
157
|
+
return
|
158
|
+
end
|
159
|
+
|
160
|
+
fn_template = chunks[0].strip
|
161
|
+
numbers = chunks[1].split(/([0-9]+)/)
|
162
|
+
|
163
|
+
if numbers.count < 4
|
164
|
+
display_str = (signature.length > 60) ? "#{signature[0, 60]}..." : signature
|
165
|
+
msg = "Error: #{display_str} is not a valid sequence signature. Should have [<start> to <end> [by <by_frame>]]. "
|
166
|
+
puts msg.red
|
167
|
+
help :check
|
168
|
+
return
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
match = /#+/.match(fn_template)
|
173
|
+
if match.nil?
|
174
|
+
msg = "Error: #{fn_template} has no # characters so does not represent a sequence."
|
175
|
+
puts msg.red
|
176
|
+
help :check
|
177
|
+
return
|
178
|
+
end
|
179
|
+
|
180
|
+
range_spec=Hash.new
|
181
|
+
range_spec[:padding] = match[0].length
|
182
|
+
range_spec[:start_frame] = numbers[1].to_i
|
183
|
+
range_spec[:end_frame] = numbers[3].to_i
|
184
|
+
range_spec[:by_frame] = numbers[5].to_i || 1
|
185
|
+
|
186
|
+
if range_spec[:start_frame].nil? || range_spec[:end_frame].nil? || ( range_spec[:start_frame] >= range_spec[:end_frame] )
|
187
|
+
msg = "Error: start_frame #{start_frame} must be less than end_frame #{end_frame}."
|
188
|
+
puts msg.red
|
189
|
+
help :check
|
190
|
+
return
|
191
|
+
end
|
192
|
+
|
193
|
+
# range_spec.merge!(options)
|
194
|
+
membership =Sequin::Membership.new(fn_template, range_spec)
|
195
|
+
|
196
|
+
membership.output(options)
|
197
|
+
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
|
202
|
+
|
203
|
+
map "m" => :match
|
204
|
+
desc "match [FILES]", "Match filename naming conventions using a regular expression."
|
205
|
+
method_option :regex, :type => :string, :aliases => "-r", :required => true, :desc => "Specify a regular expression literally, or by reference to one listed in ~/.seq.yml"
|
206
|
+
method_option :show_matched, :type => :boolean, :aliases => "-m", :desc => "List files that matched the regex in green"
|
207
|
+
method_option :show_failed, :type => :boolean, :aliases => "-f", :desc => "List files that failed to match the regex in red"
|
208
|
+
method_option :show_all, :type => :boolean, :aliases => "-a", :desc => "List all the files in order with color coding."
|
209
|
+
def match(*files)
|
210
|
+
keys = options[:regex].split(":")
|
211
|
+
if keys.count != 2
|
212
|
+
the_regex = options[:regex]
|
213
|
+
else
|
214
|
+
|
215
|
+
begin
|
216
|
+
yml = File.join(ENV['HOME'],".seq.yml")
|
217
|
+
regexp_hash = YAML::load_file(yml)
|
218
|
+
rescue
|
219
|
+
msg = "Error: opening file #{yml} - please check the file exists and is valid YAML.\nIf it doesn't exist, run: seq generate config."
|
220
|
+
puts msg.red
|
221
|
+
help :match
|
222
|
+
return
|
223
|
+
end
|
224
|
+
|
225
|
+
unless regexp_hash.has_key?(keys[0])
|
226
|
+
msg = "Error: #{keys[0]} not in #{yml} - please check the file is valid YAML and has a top level key: #{keys[0]}."
|
227
|
+
puts msg.red
|
228
|
+
help :match
|
229
|
+
return
|
230
|
+
end
|
231
|
+
|
232
|
+
unless regexp_hash[keys[0]].has_key?(keys[1])
|
233
|
+
msg = "Error: #{keys[1]} not in #{yml} - please check the file is valid YAML and has a second level key: #{keys[1]}."
|
234
|
+
puts msg.red
|
235
|
+
help :match
|
236
|
+
return
|
237
|
+
end
|
238
|
+
|
239
|
+
the_regex = regexp_hash[keys[0]][keys[1]]
|
240
|
+
|
241
|
+
end
|
242
|
+
|
243
|
+
unless the_regex.nil?
|
244
|
+
l = Sequin::Listing.new(files)
|
245
|
+
l.match(the_regex, options)
|
246
|
+
else
|
247
|
+
msg = "Error: No valid regular expression."
|
248
|
+
puts msg.red
|
249
|
+
help :match
|
250
|
+
return
|
251
|
+
end
|
252
|
+
|
253
|
+
end
|
254
|
+
|
255
|
+
end
|
256
|
+
|
257
|
+
|
258
|
+
end
|
data/sequin.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "sequin/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "sequin"
|
7
|
+
s.version = Sequin::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Julian Mann"]
|
10
|
+
s.email = ["julian.mann@gmail.com"]
|
11
|
+
s.homepage = ""
|
12
|
+
s.summary = %q{Tools to deal with file sequences.}
|
13
|
+
s.description = %q{Sequin is a collection of commands to list, test and modify sequences of files on disk.}
|
14
|
+
s.add_dependency('thor')
|
15
|
+
s.rubyforge_project = "sequin"
|
16
|
+
|
17
|
+
s.files = `git ls-files`.split("\n")
|
18
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
19
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
|
+
s.require_paths = ["lib"]
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sequin
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.0.1
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Julian Mann
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-04-21 00:00:00 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: thor
|
17
|
+
prerelease: false
|
18
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
19
|
+
none: false
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
type: :runtime
|
25
|
+
version_requirements: *id001
|
26
|
+
description: Sequin is a collection of commands to list, test and modify sequences of files on disk.
|
27
|
+
email:
|
28
|
+
- julian.mann@gmail.com
|
29
|
+
executables:
|
30
|
+
- seq
|
31
|
+
extensions: []
|
32
|
+
|
33
|
+
extra_rdoc_files: []
|
34
|
+
|
35
|
+
files:
|
36
|
+
- .gitignore
|
37
|
+
- CHANGELOG
|
38
|
+
- Gemfile
|
39
|
+
- LICENSE
|
40
|
+
- README.md
|
41
|
+
- Rakefile
|
42
|
+
- bin/seq
|
43
|
+
- lib/listing.rb
|
44
|
+
- lib/membership.rb
|
45
|
+
- lib/sequin.rb
|
46
|
+
- lib/sequin/version.rb
|
47
|
+
- sequin.gemspec
|
48
|
+
homepage: ""
|
49
|
+
licenses: []
|
50
|
+
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options: []
|
53
|
+
|
54
|
+
require_paths:
|
55
|
+
- lib
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: "0"
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
none: false
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
requirements: []
|
69
|
+
|
70
|
+
rubyforge_project: sequin
|
71
|
+
rubygems_version: 1.7.2
|
72
|
+
signing_key:
|
73
|
+
specification_version: 3
|
74
|
+
summary: Tools to deal with file sequences.
|
75
|
+
test_files: []
|
76
|
+
|