6 releases
| 0.1.5 | Dec 18, 2025 |
|---|---|
| 0.1.4 | Dec 9, 2025 |
| 0.1.0 | Nov 29, 2025 |
#390 in Filesystem
Used in playa
155KB
2K
SLoC
ScanSeq - High-Performance File Sequence Detection
Fast, Rust-powered library and Python extension for detecting numbered file sequences in VFX, animation, and media production pipelines.
Features
- Parallel Scanning: Uses jwalk for fast directory traversal
- Memory Efficient: Pre-computed digit groups, mask-based grouping
- Smart Detection: Automatically picks longest sequence when files have multiple number groups
- Missing Frame Tracking: Identifies gaps in sequences automatically
- Single File Lookup: Find sequence from any file path in O(n) time
- Builder Pattern: Fluent API for scanner configuration
- Frame Path Resolution: Get file paths for any frame number
- File Scanner: Scan files by extensions with glob patterns (
jp*,tif?)
Quick Start
Rust API
use scanseq::core::{Scanner, Seq, get_seqs, scan_files};
fn main() {
// Builder pattern (recommended)
let scanner = Scanner::path("/renders")
.recursive(true)
.extensions(&["exr", "png", "jpg"])
.min_len(2)
.scan();
// Or use VFX presets
let scanner = Scanner::path("/renders")
.vfx_images() // exr, dpx, tif, png, jpg, tga, hdr
.scan();
println!("Found {} sequences in {:.1}ms",
scanner.len(), scanner.result.elapsed_ms);
for seq in scanner.iter() {
println!("{} [{}-{}]", seq.pattern(), seq.start, seq.end);
// Get specific frame path
if let Some(path) = seq.get_file(seq.start) {
println!(" First: {}", path);
}
// Check for gaps
if !seq.is_complete() {
println!(" Missing {} frames", seq.missed.len());
}
}
// Classic constructor (also valid)
let scanner = Scanner::new(
vec!["/renders", "/comp"],
true, // recursive
Some("*.exr"), // mask
2 // min_len
);
// Static methods (return ScanResult)
let result = Scanner::get_seqs(&["/renders"], true, Some("*.exr"), 2);
let result = Scanner::get_seq("/renders", true, Some("*.exr"), 2);
// Find sequence from a single file
if let Some(seq) = Scanner::from_file("/renders/shot_0001.exr") {
println!("Found: {} [{}-{}]", seq.pattern(), seq.start, seq.end);
}
// Low-level function (returns Result<Vec<Seq>>)
let seqs = get_seqs("/renders", true, Some("*.exr"), 2).unwrap();
// Scan files by extensions (not sequences, just file list)
let videos = scan_files(&["/media"], true, &["mp4", "mov", "avi"]).unwrap();
let images = scan_files(&["/renders"], true, &["exr", "jp*", "tif*"]).unwrap(); // glob patterns
}
Add to Cargo.toml:
[dependencies]
scanseq = "0.1"
Python API
import scanseq
# Create scanner (runs automatically on construction)
scanner = scanseq.Scanner(
roots=["/renders", "/comp"],
recursive=True,
mask="*.exr",
min_len=2
)
# Access results via scanner.result
print(f"Found {len(scanner.result.seqs)} in {scanner.result.elapsed_ms:.1f}ms")
for seq in scanner.result.seqs:
print(f"{seq.pattern} [{seq.start}-{seq.end}]")
# Get specific frame path
path = seq.get_file(seq.start)
if path:
print(f" First: {path}")
# Check completeness
if not seq.is_complete():
print(f" Missing: {seq.missed}")
# Static methods
result = scanseq.Scanner.get_seqs(["/renders"], recursive=True)
result = scanseq.Scanner.get_seq("/renders", mask="*.exr")
# Find sequence from a single file
seq = scanseq.Scanner.from_file("/renders/shot_0001.exr")
if seq:
print(f"{seq.pattern} [{seq.start}-{seq.end}]")
# Convert Seq to dict
data = dict(seq) # or seq.to_dict()
# Expand to all frame paths
all_paths = seq.expand() # ["/renders/shot_0001.exr", ...]
# Rescan with same settings
scanner.rescan()
CLI
# Show help
scanseq-cli
# Scan paths and print results
scanseq-cli -p /renders -p /comp -o
# Recursive scan
scanseq-cli -p /renders -r -o
# With mask filter
scanseq-cli -p /renders -m "*.exr" -o
# JSON output
scanseq-cli -p /renders -oj
# Scan files by extensions (not sequences)
scanseq-cli -p /media -s mp4 mov avi -r -o
# With glob patterns
scanseq-cli -p /renders -s exr jp* tif* -r -o
# JSON file list
scanseq-cli -p /media -s mp4 -r -oj
API Reference
Rust
Scanner
Stateful scanner with configuration and results:
pub struct Scanner {
pub roots: Vec<String>,
pub recursive: bool,
pub mask: Option<String>,
pub min_len: usize,
pub result: ScanResult,
}
impl Scanner {
// Builder pattern (recommended)
pub fn path(root: P) -> ScannerBuilder // Single path
pub fn paths(roots: &[P]) -> ScannerBuilder // Multiple paths
// Classic constructor - scans immediately
pub fn new(roots: Vec<S>, recursive: bool, mask: Option<&str>, min_len: usize) -> Self
// Static methods - return ScanResult
pub fn get_seq(root: P, recursive: bool, mask: Option<&str>, min_len: usize) -> ScanResult
pub fn get_seqs(roots: &[P], recursive: bool, mask: Option<&str>, min_len: usize) -> ScanResult
// Find sequence containing a file (scans parent directory)
pub fn from_file(path: P) -> Option<Seq>
// Instance methods
pub fn rescan(&mut self)
pub fn len(&self) -> usize
pub fn is_empty(&self) -> bool
pub fn iter(&self) -> impl Iterator<Item = &Seq>
}
ScannerBuilder
Fluent builder for scanner configuration:
pub struct ScannerBuilder { ... }
impl ScannerBuilder {
pub fn recursive(self, recursive: bool) -> Self
pub fn mask(self, mask: &str) -> Self
pub fn extensions(self, exts: &[&str]) -> Self // ["exr", "png"] -> "*.{exr,png}"
pub fn vfx_images(self) -> Self // Preset: exr, dpx, tif, png, jpg, tga, hdr
pub fn min_len(self, min_len: usize) -> Self
pub fn scan(self) -> Scanner // Execute scan
pub fn into_seqs(self) -> Vec<Seq> // Scan and return sequences only
}
ScanResult
pub struct ScanResult {
pub seqs: Vec<Seq>,
pub elapsed_ms: f64,
pub errors: Vec<String>,
}
get_seqs
Low-level sequence scanning function:
pub fn get_seqs<P: AsRef<Path>>(
root: P, // Directory to scan
recursive: bool, // Scan subdirectories
mask: Option<&str>, // Glob pattern filter
min_len: usize // Minimum sequence length
) -> Result<Vec<Seq>, String>
scan_files
Scan files by extensions (returns file paths, not sequences):
pub fn scan_files<P: AsRef<Path>>(
roots: &[P], // Directories to scan
recursive: bool, // Scan subdirectories
exts: &[&str] // Extensions or glob patterns
) -> Result<Vec<PathBuf>, String>
Examples:
// Exact extensions
let videos = scan_files(&["/media"], true, &["mp4", "mov", "avi"])?;
// Glob patterns
let images = scan_files(&["/renders"], true, &["jp*", "tif?"])?; // jpg, jpeg, jp2, tiff
// All files (empty extensions)
let all = scan_files(&["/data"], true, &[])?;
Seq
Sequence struct with frame operations:
pub struct Seq {
pub indices: Vec<i64>, // Frame numbers present
pub missed: Vec<i64>, // Missing frame numbers
pub start: i64, // First frame
pub end: i64, // Last frame
pub padding: usize, // 0 = variable, >=2 = fixed width
}
impl Seq {
// Basic info
pub fn pattern(&self) -> &str // Pattern string ("img_####.exr")
pub fn len(&self) -> usize // Number of files
pub fn is_empty(&self) -> bool // Check if empty
// Frame operations
pub fn get_file(&self, frame: i64) -> Option<String> // Get path for frame
pub fn first_file(&self) -> String // First frame path
pub fn last_file(&self) -> String // Last frame path
pub fn is_complete(&self) -> bool // No missing frames?
pub fn frame_count(&self) -> usize // Number of existing frames
pub fn range_count(&self) -> i64 // Total range size
// Expansion
pub fn expand(&self) -> Result<Vec<String>, String> // All paths in range
pub fn expand_existing(&self) -> Vec<String> // Only existing frame paths
// Serialization
pub fn to_json(&self) -> String // JSON string
pub fn to_json_pretty(&self) -> String // Pretty JSON
pub fn to_map(&self) -> HashMap<&str, serde_json::Value>
}
// Implements Display: "Seq("img_####.exr", range: 1-100)"
// Implements Serialize (serde)
Constants
pub const VFX_IMAGE_EXTS: &[&str]; // ["exr", "dpx", "tif", "tiff", "png", "jpg", "jpeg", "tga", "hdr"]
pub const VIDEO_EXTS: &[&str]; // ["mp4", "mov", "avi", "mkv", "webm", "m4v", "mxf"]
Python
Scanner
Stateful scanner class that runs on construction:
scanner = scanseq.Scanner(
roots: list[str], # Directories to scan
recursive: bool = True, # Scan subdirectories
mask: str | None = None, # Glob pattern (e.g., "*.exr")
min_len: int = 2 # Minimum sequence length
)
Attributes:
scanner.roots # list[str] - directories scanned
scanner.recursive # bool
scanner.mask # str | None
scanner.min_len # int
scanner.result # ScanResult - scan results
Static Methods:
Scanner.get_seq(root, recursive=True, mask=None, min_len=2) # Single path
Scanner.get_seqs(roots, recursive=True, mask=None, min_len=2) # Multiple paths
Scanner.from_file(path) # Find seq from file
Instance Methods:
scanner.rescan() # Re-scan with current settings
len(scanner) # Number of sequences
for seq in scanner: # Iterate over sequences
...
ScanResult
result.seqs # list[Seq] - detected sequences
result.elapsed_ms # float - scan duration in ms
result.errors # list[str] - errors encountered
len(result) # Number of sequences
for seq in result: # Iterate over sequences
...
Seq
Sequence object with frame information:
# Attributes
seq.pattern # "shot_####.exr" (#### = padded, @ = unpadded)
seq.start # First frame number
seq.end # Last frame number
seq.padding # Padding width (4 for 0001)
seq.indices # list[int] - actual frames present
seq.missed # list[int] - missing frames
# Frame operations
seq.get_file(frame) # Get path for specific frame (None if missing)
seq.is_complete() # True if no missing frames
seq.expand() # All frame paths in range (including missing)
# Conversion
seq.to_dict() # Convert to dictionary
dict(seq) # Also works via Mapping protocol
seq["pattern"] # Item access via Mapping protocol
# Magic methods
len(seq) # Number of files
str(seq) # String representation
repr(seq) # Detailed representation
CLI
scanseq-cli [OPTIONS]
Options:
-p, --path <PATH> Directory to scan (can specify multiple)
-r, --recursive Scan subdirectories recursively
-m, --mask <MASK> File mask/glob pattern for sequences
-s, --scan-files <EXT>... Scan files by extensions (e.g., -s mp4 mov jp*)
-n, --min <N> Minimum sequence length (default: 2)
-o, --out Print results to stdout (default: off)
-j, --json Use JSON format (with -o)
-h, --help Print help
Installation
From crates.io
[dependencies]
scanseq = "0.1"
From Source
# Build CLI
cargo build --release
# Install Python module
pip install maturin
maturin develop --features python
Architecture
Algorithm
- Scan: Parallel directory traversal with jwalk
- Parse: Extract digit groups from filenames, create masks
- Group: Hash by mask (e.g.,
render_@.exr), sub-group by anchors - Detect: Find frame numbers, compute padding, identify gaps
Pattern Notation
####- Padded sequences (e.g.,0001,0002)@- Unpadded sequences (e.g.,1,2,100)
Examples:
render_####.exr->render_0001.exr,render_0002.exrshot_@.png->shot_1.png,shot_2.png
Examples
Find Missing Frames
let scanner = Scanner::path("/renders").vfx_images().scan();
for seq in scanner.iter() {
if !seq.is_complete() {
println!("{}: missing frames {:?}", seq.pattern(), seq.missed);
}
}
Generate Contact Sheet
let seq = Scanner::from_file("/renders/shot_0001.exr").unwrap();
// Get evenly spaced frames for thumbnail generation
let step = seq.frame_count() / 10;
for (i, frame) in seq.indices.iter().step_by(step.max(1)).enumerate() {
if let Some(path) = seq.get_file(*frame) {
println!("Thumbnail {}: {}", i, path);
}
}
Validate Sequence Completeness
import scanseq
scanner = scanseq.Scanner(["/renders"], mask="*.exr")
incomplete = [s for s in scanner.result.seqs if not s.is_complete()]
for seq in incomplete:
print(f"INCOMPLETE: {seq.pattern}")
print(f" Range: {seq.start}-{seq.end}")
print(f" Missing: {len(seq.missed)} frames")
print(f" First missing: {seq.missed[:5]}")
Development
# Run tests
cargo test
# Build with Python
cargo build --features python
# Python module dev install
maturin develop --features python
License
MIT
Dependencies
~5.5–10MB
~179K SLoC