Name:
file-ferryRepo location:dspury/file-ferryVersion: 0.3.0 Status: Released — stable
A zero-cost CLI for post-production media ops: probe, organize, generate proxies, build DaVinci Resolve projects, and verify backups — all logged to a local SQLite audit trail.
The killer demo: drop a folder of raw media in, run ferry run, and walk away with organized folders, ready-to-edit proxies, a Resolve project file pre-wired with bins and a timeline, and a queryable SQLite audit log proving exactly what happened.
Target user: Solo creative operators, small post-production teams, anyone running DaVinci Resolve or other NLEs who needs reliable media infrastructure underneath their edit.
Problem it solves: Most post-production tooling is either (a) expensive SaaS, (b) manual point-and-click workflows that don't scale, or (c) one-off scripts glued together. There is no widely-adopted open-source tool that handles the boring-but-critical media-ops layer (probe → organize → proxy → Resolve project → verify) in one composable, logged, reproducible package.
What it demonstrates: Production-engineering instincts applied to creative media. Codec literacy, FFmpeg fluency, Resolve's Python scripting API, schema thinking, audit discipline.
- Zero cost to run. Every dependency is open-source or free. No API keys, no cloud accounts, no paid SaaS.
- Single-operator CLI + TUI. Sharp CLI for scripting and automation; interactive Textual TUI (
ferry tui) for ad-hoc use. No web UI. - Composable pipeline. Each capability (probe / organize / proxy / resolve / verify) is independent and can be run standalone or chained.
- Auditable by default. Every operation writes to a local SQLite log. The log is the system of record for "what happened to my media."
- Safe to open-source. No hardcoded paths, hostnames, NAS shares, IPs, or proprietary references anywhere.
- Industry-tool native. DaVinci Resolve integration where it adds value, FFmpeg fallback where it doesn't.
- Reproducible. Re-running a
ferry runon the same input produces the same output and the same log row, modulo timestamps.
| Excluded | Why |
|---|---|
| Transcription (Whisper) | Out of media-management scope; product is infrastructure, not creative AI |
| LLM-based content description | Same reason |
| Auto-selects / scoring | Creative-AI territory, not infrastructure |
| Web UI | CLI + TUI in v1; browser UI is a v2+ concern |
| Cloud APIs (any vendor) | Zero-cost mandate; local-first mandate |
| Team collaboration features | Single-operator scope |
| Auto-tagging / ML classification | Out of scope for v1; v2 candidate |
| Scene detection | v2 candidate (PySceneDetect is a natural fit, but defer) |
| Audio loudness / color analysis | v2 candidate |
| Editing features (cuts, transitions) | Not the tool's job; Resolve does this |
Shared scanning rule — system artifacts are invisible to every capability. Directory scans (probe, organize, proxy, verify) skip OS/index junk relative to the scan root: any dot-prefixed component (.DS_Store, .Trashes, .Spotlight-V100, AppleDouble ._clip.MP4 sidecars that macOS writes on exFAT/FAT camera cards) plus $RECYCLE.BIN, System Volume Information, LOST.DIR, Thumbs.db, and desktop.ini. Without this, every run against a mounted camera card or backup drive failed or reported noise: ffprobe/ffmpeg choke on AppleDouble sidecars (which carry real video extensions), organize reports them as un-probed, and verify raises false added/modified alarms whenever Finder touches the folder. A scan explicitly rooted inside a hidden directory still works — only components below the root count.
Extract structured metadata from any media file (video / audio / image) using ffprobe. Output is a pydantic model capturing:
codec,container,width,height,frame_rate,avg_frame_rate,r_frame_ratecolor_space,color_transfer,color_primariesbit_depth(frombits_per_raw_sample; falls back to parsingpix_fmtfor formats that don't report it — e.g., ProRes)is_vfr—Truewhenr_frame_ratediffers fromavg_frame_rateby more than 1%; indicates variable-frame-rate sourcesample_aspect_ratio(SAR) — e.g.,"2:1"for anamorphic sourcestimecode— extracted fromformat.tags.timecodeor video streamdisposition.timecodeaudio_codec,audio_channels,audio_sample_rate,audio_bit_depthduration,file_size, modification time
JSON-serializable, queryable in SQLite.
Auto-organize a folder of media into a structured layout based on configurable rules. Default rule: <root>/<source_relpath>/<filename><ext> — the source's subfolder structure is preserved under the destination root (mirrors how DITs think about cards/scenes/takes). Rules live in a config file (ferry.toml) and can be overridden per-project (e.g. {root}/{codec_family}/{resolution_bucket}/{filename}{ext}). Sources are copied by default so raw camera media stays untouched; --move (or mode = "move" in config) relocates instead.
Note: --dry-run is supported — preview the organization plan before touching any files.
Each operation is logged; the manifest is reversible.
Generate edit-friendly proxies (default: ProRes 422 Proxy at 1080p, aspect-preserving) from raw camera formats. Output is always a .mov QuickTime file regardless of source container; non-video files are excluded by extension.
Proxy generation is probe-informed. Before generating, the source is probed and the following metadata is used to build the correct ffmpeg command:
- Timecode — passed via
-timecodeflag when source carries timecode - Color metadata —
-color_primaries,-color_trc,-colorspacepassed through from source - SAR / anamorphic —
setsarapplied after scale to restore correct display aspect ratio - Audio codec — PCM bit depth matched to source audio (
pcm_s16lefor 8–15-bit audio,pcm_s32lefor 16+ bit) - All audio tracks —
-map 0:acaptures every audio track, not just the first
On same-device organize operations, hardlinks are used instead of full copies to avoid wasted I/O.
Supports MOV, MXF, MP4, and any ffmpeg-readable format. RAW codecs (R3D/BRAW/ARI) are recognized by container but require vendor SDKs for decode — stock ffmpeg cannot decode them.
Programmatically create a Resolve project (.drp) from a manifest + a config. Sets project resolution / frame rate / color space; creates a bin structure mirroring the source folder; imports media into the appropriate bins; creates a timeline pre-populated with proxy references. Graceful degradation: if Resolve isn't running/installed, emits a "ready to import" manifest instead and logs a warning.
Compute fast checksums (xxhash by default; sha256 optional) of all files in a folder; store in SQLite. ferry verify compares current state vs recorded state and reports missing / modified / added files with structured exit codes (0 = clean, 1 = missing, 2 = modified, 3 = added). Designed for shell scripting and cron.
Baseline mutability: Verification does NOT automatically update the stored baseline on mismatch. A mismatch is always reported as an error until explicitly acknowledged. This prevents silent bit-rot: a corrupted file that was missed once does not suppress future detections by overwriting the baseline.
Every operation writes to a local SQLite database. Schema covers: runs, files, probes, proxies, projects, verifications, errors. Queryable via ferry log subcommand (e.g., ferry log --since 1d --missing).
The default location is the platform application-data directory (~/Library/Application Support/ferry/ferry.db on macOS, ~/AppData/Local/ferry/ferry.db on Windows, ~/.local/share/ferry/ferry.db otherwise), resolved by file_ferry.paths.default_db_path so the CLI, the TUI, and the desktop sidecar all address one store. A pre-0.3.1 log at ~/.ferry/ferry.db continues to be used when no application-data database exists. Override with --db or FERRY_DB.
┌──────────────────────────────────────────────────────────────┐
│ ferry CLI + TUI │
│ (Click for CLI; Textual for TUI) │
│ │
│ CLI: ferry probe / organize / proxy / verify ... │
│ TUI: ferry tui (interactive full-screen app) │
└──────┬──────────┬──────────┬──────────┬───────────┬──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌─────────┐
│ Probe │ │Organiz│ │Proxy │ │Resolve│ │ Verify │
│ │ │ e │ │ Gen │ │ Create│ │ │
└───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └────┬────┘
│ │ │ │ │
│ ffprobe ffmpeg Resolve.py xxhash
│ │ │ │ (or │
│ │ │ │ ffmpeg │
│ │ │ │ fallback) │
│ │ │ │ │
└──────────┴────┬─────┴──────────┴───────────┘
│
▼
┌─────────────────┐
│ SQLite Audit │
│ Log │
│ (system of │
│ record) │
└─────────────────┘
Each box is a Python module with its own tests. The CLI and TUI both compose them.
The schema lives in src/file_ferry/log.py (SCHEMA_SQL +
LogStore._migrate). The version is recorded in schema_meta
and bumped via additive migrations when columns are added — never
drop or rename, only ALTER TABLE ADD COLUMN. See SCHEMA_VERSION
for the current value.
The code is the source of truth. This block is not hand-maintained
against it: tests/test_spec_schema.py executes the SQL below into a
scratch database and diffs it against a freshly initialized one, so any
drift fails the suite. Note that a few columns (projects.manifest_path,
the v7 probes fields) exist only in _migrate, not in SCHEMA_SQL —
the sketch reflects the effective schema, which is what a fresh
database actually gets. Column order is not compared, because
ALTER TABLE ADD COLUMN appends while the sketch groups fields by
meaning.
-- Schema version marker. `LogStore.initialize` writes SCHEMA_VERSION here
-- and `_migrate` reads it to decide which additive migrations to apply.
CREATE TABLE schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- One row per ferry run
CREATE TABLE runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
command TEXT NOT NULL, -- e.g., "ferry run /path/to/folder"
config_hash TEXT, -- hash of the ferry.toml used
status TEXT NOT NULL, -- running | success | failed | partial
error TEXT
);
-- One row per file the system has ever seen
CREATE TABLE files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE, -- absolute path
size INTEGER,
mtime REAL,
first_seen_run INTEGER REFERENCES runs(id),
last_seen_run INTEGER REFERENCES runs(id)
);
-- Probe results — populated by `ferry probe`.
-- Every column beyond `id`/`file_id`/`run_id`/`probed_at` is nullable
-- (or default-safe for `is_vfr`) so historical rows survive schema
-- additions unchanged.
CREATE TABLE probes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER REFERENCES files(id),
run_id INTEGER REFERENCES runs(id),
codec TEXT, -- video_codec or audio_codec fallback
container TEXT,
width INTEGER,
height INTEGER,
frame_rate REAL,
r_frame_rate REAL, -- real frame rate (VFR detection, #8)
is_vfr INTEGER NOT NULL DEFAULT 0,
color_space TEXT,
color_transfer TEXT,
color_primaries TEXT,
bit_depth INTEGER,
sample_aspect_ratio TEXT, -- e.g. "2:1"
timecode TEXT, -- e.g. "01:23:45:12"
audio_codec TEXT,
audio_channels INTEGER,
audio_sample_rate INTEGER,
audio_bit_depth INTEGER,
duration REAL,
modification_time TEXT,
probed_at TEXT NOT NULL
);
-- Proxy generation records
CREATE TABLE proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_file_id INTEGER REFERENCES files(id),
proxy_path TEXT NOT NULL,
run_id INTEGER REFERENCES runs(id),
codec TEXT,
width INTEGER,
height INTEGER,
file_size INTEGER,
generated_at TEXT NOT NULL
);
-- Resolve project creation records
-- `path` is the requested .drp path (may not exist if Resolve was
-- unavailable). `manifest_path` records what was actually written,
-- so the audit log reflects ground truth.
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
manifest_path TEXT,
run_id INTEGER REFERENCES runs(id),
resolution TEXT,
frame_rate TEXT,
color_space TEXT,
bin_count INTEGER,
timeline_count INTEGER,
resolve_version TEXT, -- e.g., "20.0"; null if FFmpeg fallback used
created_at TEXT NOT NULL
);
-- Backup verification records
CREATE TABLE verifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder TEXT NOT NULL,
run_id INTEGER REFERENCES runs(id),
files_checked INTEGER,
files_missing INTEGER,
files_modified INTEGER,
files_added INTEGER,
checksum_algo TEXT, -- xxhash | sha256
verified_at TEXT NOT NULL
);
-- Organize operations — one row per file copied or moved.
-- `link` and `skip` are reserved for future operations; current
-- code only emits `copy` and `move`.
CREATE TABLE organize_ops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER REFERENCES runs(id),
source_path TEXT NOT NULL,
destination_path TEXT NOT NULL,
operation TEXT NOT NULL, -- copy | move (reserved: link | skip)
codec_family TEXT,
resolution_bucket TEXT,
file_size INTEGER,
moved_at TEXT NOT NULL
);
-- Verification baseline snapshots — immutable; never mutated on mismatch.
-- `size` and `mtime` are nullable because the snapshot row may exist
-- for a file that has since been removed (so file-level mtime can be
-- reported even when the row is detached from the live folder state).
CREATE TABLE verification_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
folder TEXT NOT NULL,
path TEXT NOT NULL, -- relative path within folder
checksum TEXT NOT NULL,
size INTEGER,
mtime REAL,
algo TEXT NOT NULL,
recorded_at TEXT NOT NULL,
UNIQUE(folder, path)
);
-- Stores one row per verified folder, recording the baseline state.
-- `is_empty=1` means the folder was empty at baseline time (no
-- files). This distinguishes "folder has never had files" from
-- "folder has files" so a file added later is reported as "added",
-- not silently absorbed by an old snapshot row.
CREATE TABLE verification_baselines (
folder TEXT PRIMARY KEY,
algo TEXT NOT NULL,
is_empty INTEGER NOT NULL DEFAULT 0,
recorded_at TEXT NOT NULL
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_files_path ON files(path);
CREATE INDEX IF NOT EXISTS idx_probes_file_id ON probes(file_id);
CREATE INDEX IF NOT EXISTS idx_probes_run_id ON probes(run_id);
CREATE INDEX IF NOT EXISTS idx_proxies_run_id ON proxies(run_id);
CREATE INDEX IF NOT EXISTS idx_projects_run_id ON projects(run_id);
CREATE INDEX IF NOT EXISTS idx_verifications_run_id ON verifications(run_id);
CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at);
CREATE INDEX IF NOT EXISTS idx_organize_ops_run_id ON organize_ops(run_id);
CREATE INDEX IF NOT EXISTS idx_organize_ops_source ON organize_ops(source_path);
CREATE INDEX IF NOT EXISTS idx_verif_snap_folder ON verification_snapshots(folder);# Probe a single file
ferry probe clip.mov
# Probe a folder, write to log
ferry probe ./raw/
# Organize a folder using rules in ferry.toml
ferry organize ./raw/ --root ./organized/
# Preview organize without touching files
ferry organize ./raw/ --root ./organized/ --dry-run
# Generate proxies for everything in a folder
ferry proxy ./organized/ --out ./proxies/
# Create a Resolve project from a folder
ferry resolve create ./organized/ --project "Episode-12" --resolution 1080 --fps 24
# Verify a backup
ferry verify ./raw/
# Query the audit log
ferry log --since 1d
ferry log --missing
ferry log --proxies --format json
# Full pipeline
ferry run ./raw/ --organize --proxy --resolve-project --verify| Component | Choice | License | Cost |
|---|---|---|---|
| Language | Python 3.11+ | PSF | Free |
| CLI framework | Click + Rich (TTY output) | BSD / MIT | Free |
| TUI framework | Textual (full-screen TUI, ferry tui) |
MIT | Free |
| Media probe | ffprobe (via ffmpeg) | LGPL/GPL | Free |
| Transcode / proxy | ffmpeg | LGPL/GPL | Free |
| Checksum | xxhash (python-xxhash) | BSD | Free |
| DB | SQLite (stdlib) | Public domain | Free |
| Models | pydantic v2 | MIT | Free |
| Terminal output | rich | MIT | Free |
| Resolve API | DaVinci Resolve scripting | Free with Resolve | Free (Resolve Studio free version supports scripting) |
| Tests | pytest | MIT | Free |
| Lint / format | ruff | MIT | Free |
Total cost to run: $0.
file-ferry/
├── README.md
├── LICENSE (MIT)
├── pyproject.toml
├── ferry.toml.example
├── SPEC.md (this document)
├── src/file_ferry/
│ ├── __init__.py
│ ├── cli.py (Click entrypoint)
│ ├── config.py (ferry.toml loader)
│ ├── probe.py (ffprobe wrapper)
│ ├── organize.py (file organizer)
│ ├── proxy.py (proxy generation)
│ ├── resolve.py (DaVinci project creation + ffmpeg fallback)
│ ├── verify.py (checksum verification)
│ ├── log.py (SQLite audit log)
│ ├── models.py (pydantic schemas)
│ ├── errors.py (custom exceptions with exit codes)
│ └── tui.py (Textual TUI)
├── tests/
│ ├── test_probe.py
│ ├── test_organize.py
│ ├── test_proxy.py
│ ├── test_resolve.py
│ ├── test_verify.py
│ └── test_log.py
├── examples/
│ └── sample-run.md
└── docs/
└── architecture.md
- No hardcoded paths. Everything configurable via CLI args, env vars, or
ferry.toml. - No hostnames, IPs, NAS shares, or cloud account refs. Anywhere in the code, docs, or tests.
- No proprietary branding or team references. Anywhere.
- Public tools only. FFmpeg, ffprobe, pydantic, Click, DaVinci Resolve (free), Python ecosystem.
- No paid SaaS fallbacks. Graceful degradation if a piece isn't installed; never "send to API."
- No telemetry, no analytics, no remote calls. Truly local.
- Test fixtures use synthetic data only. No real media, no real names.
ferry with no arguments launches the Textual workstation. Automation and
the existing command surface remain available as subcommands; ferry --no-tui explicitly stays in CLI mode and prints command help. ferry tui
remains as a compatibility alias. Global --db and --config options are passed
through to the TUI.
| Screen | Purpose |
|---|---|
| Home | Studio-style launch surface, ffmpeg health, database run totals |
| Pipelines | Browse mounted directories, queue multiple folders, select all five capability steps, and monitor sequential execution. The MEDIA BROWSER pane auto-detects connected external drives (/Volumes/* on macOS minus the system volume, /media/$USER/* and /run/media/$USER/* on Linux, every drive letter except %SYSTEMDRIVE% on Windows) and surfaces them as a clickable list above the directory tree, each entry annotated with free / total disk space. |
| Audit log | Browse up to 500 runs with colored status and incremental command/status search |
| Settings | Edit proxy codec/height, checksum algorithm, ffmpeg path, and Resolve path; save the existing TOML schema |
The pipeline executes probe → organize → proxy → resolve → verify. Each queued
folder has an independent state (queued, running, done, failed, or
cancelled). Capability modules remain the source of truth and are called
unchanged. Cancellation therefore occurs safely between capability calls; an
in-flight ffmpeg or checksum batch is allowed to finish rather than being killed
mid-write. The activity panel reports elapsed time, queue/step progress, and
color-coded per-capability result totals. Frame/fps/bitrate telemetry is deferred
until the proxy capability exposes a progress callback.
| Key | Action |
|---|---|
R, L, S |
Open Pipelines, Audit Log, or Settings from Home |
Arrow keys / Tab / Shift+Tab |
Move through trees, tables, fields, toggles, and buttons |
Enter |
Expand/select a directory or activate the focused control |
A |
Add the selected browser folder to the pipeline queue |
Delete |
Remove the selected queued folder |
Ctrl+R |
Run the queue sequentially |
Ctrl+C |
Request safe cancellation after the active capability returns |
/ |
Focus audit-log search |
Ctrl+S |
Save settings |
Escape |
Return to the previous screen |
Q |
Quit |
The visual system uses a near-black edit-bay background, Resolve-inspired orange for primary actions, purple for stage transitions, cyan for telemetry, and green/yellow/red outcome semantics. Terminal applications cannot select proportional fonts, so hierarchy is expressed with weight, spacing, borders, and color while metadata remains naturally monospace.
file-ferry ships as a beta indefinitely. The version scheme is MAJOR.MINOR.PATCH:
- MAJOR stays at 0 indefinitely. Never bump to 1.0.0 without D's explicit approval.
- PATCH bumps (0.1.0 → 0.1.1) are fine for bug fixes — autonomous.
- MINOR bumps (0.1.0 → 0.2.0) require D's approval — they're feature-signal events.
- First tagged release: 0.1.0.
The project is file-ferry; the command you type is ferry.
pip install file-ferry
ferry --help- PyPI distribution name:
file-ferry - Python package import name:
file_ferry - GitHub repo:
dspury/file-ferry - CLI command:
ferry - Service entry point:
ferry-service
The split is deliberate, and the rule is: the project is file-ferry, the
thing you type and see is ferry. Packaging, the import name, the repo and
document titles use the project name. The command, its config file
(ferry.toml), its data locations (~/.ferry/, Application Support/ferry/),
its environment overrides (FERRY_*), the desktop product name and the TUI
banner all use the short one, because that is the name a person actually types.
ferry alone was not available on PyPI — it is held by an unrelated,
long-abandoned package (OpenCore's Docker tooling, last released 2014-10-19) and
PyPI does not reclaim names on request. file-ferry also describes the tool
better than a bare ferry does.
Build via python -m build, publish via twine upload (or pyproject.toml-driven trusted publishing on GH Actions).
Workflow at .github/workflows/ci.yml. Matrix:
- Python 3.10, 3.11, 3.12
- Each matrix entry: install deps →
pytest→ruff check→ruff format --check
Plus a smoke test that runs ferry --help and a small end-to-end probe of a synthetic fixture.
- Click vs Typer. → Click. Chosen for its maturity, stable API, and rich output ecosystem (Rich). Typer rejected.
- Checksum algo default. → xxhash. Implemented as default; sha256 available as opt-in. Speed difference is real and meaningful for large folders.
- Default organize rule. → codec_family + resolution_bucket. Date rejected — it creates messy paths for multi-day shoots. The two-tier structure is clean and professional.
- Sample demo media. → FFmpeg testsrc synthetic clips. Five 2-second clips (h264 + ProRes, mixed resolutions) generated with
ffmpeg -f lavfi -i testsrc. No real media needed.
- Scene detection (PySceneDetect)
- Audio loudness analysis (LUFS / true peak)
- Color analysis (histogram, dominant color extraction)
- Resolve round-trip render (export EDL → render → verify)
- Watch-folder mode (run on file appearance)
- Web UI (FastAPI + simple HTML)
- Cloud-storage adapters (S3, GCS) — careful with the "zero cost" mandate
- Full spanned/multi-file clip model (logical clip abstraction across organize/proxy/resolve)
All items shipped in v0.1.0:
- ✅ Scaffold
file-ferry/(pyproject.toml, src layout, tests/, ruff config) - ✅ CI workflow with paths-filter
- ✅
models.py+log.py(the data layer everything else depends on) - ✅
probe.py+ tests (simplest capability, validates the pipeline) - ✅
organize.py+ tests (depends on probe) - ✅
proxy.py+ tests (depends on probe) - ✅
verify.py+ tests (independent) - ✅
resolve.py+ tests (most complex, integrate last) - ✅
cli.py(wires it all together) - ✅ README + examples + docs
- ✅ GitHub release only; PyPI publish deferred
Status: Released.
- Proxy command was probe-ignorant.
generate_proxy()never passedrequest.probeto_ffmpeg_cmd(). Every proxy was generated with safe defaults regardless of source metadata. Timecode, color passthrough, SAR correction, and source-matched PCM bit depth were all unreachable from the public API. Fixed: probe is now passed through. - Batch proxy probing always silently failed.
ffprobe_path = find_ffmpeg(cfg)was used instead offind_ffprobe(cfg). ffmpeg rejects ffprobe-only arguments and exits with an error, which was silently swallowed. All batch proxy generation was running without probe data. Fixed: correctfind_ffprobenow used. - Skip-existing proxies logged as failures. When a proxy already existed and
--skip-existingwas set, the result was recorded as aProxyFailure. Now recorded asalready_existed— a distinct outcome, not a failure. - Sidecar files created probe noise.
probe_path()recorded every file matching known extensions, including.pek,.pbf,.CTox, and other non-media sidecar formats that ffprobe cannot parse. Now only files that ffprobe can actually parse are recorded. organize --dry-runnot exposed on CLI. The underlyingorganize_path()supporteddry_run, but the CLI never exposed it. Fixed:--dry-runis now a CLI flag on the organize command.
- Bit depth from
pix_fmt:MediaProbe.bit_depthnow falls back to parsing thepix_fmtfield whenbits_per_raw_sampleis absent (common with ProRes and other intermediate codecs). Mapsyuv420p10le→ 10-bit,yuv422p8→ 8-bit, etc. - VFR detection:
MediaProbenow capturesr_frame_rate(real frame rate) alongsideavg_frame_rate(nominal).is_vfrisTruewhen they diverge by more than 1%. VFR sources (phone recordings, screen captures, action cams) are now flagged in probe output. - Timecode extraction:
MediaProbe.timecodeis extracted fromformat.tags.timecodeor video streamdisposition.timecode. Proxies now carry timecode when the source has it. - SAR / anamorphic support:
MediaProbe.sample_aspect_ratiois captured from the video stream. Proxy generation now appliessetsarto restore correct display aspect ratio after scaling, preventing anamorphic sources from producing wrong-shaped proxies. - Audio bit depth from probe: Source audio bit depth is extracted from ffprobe stream data and used to select
pcm_s16le(8–15-bit audio) orpcm_s32le(16+ bit audio) in the proxy command. --dry-runon organize: CLI preview mode. Files are planned but not moved or copied.
MediaProbe: new fields —is_vfr,avg_frame_rate,r_frame_rate,sample_aspect_ratio,timecode,audio_codec,audio_channels,audio_sample_rate,audio_bit_depthProxyBatchResult: new fieldalready_existed: list[ProxySkip]— proxies skipped because they already existed, distinct from failures- New model:
ProxySkip— records a source path and proxy path for each skipped file
Closed in v0.2.2: #7 (SAR), #17 (--dry-run), #19 (proxy drops TC/audio), #25 (editorial fields), #26 (bit depth), #27 (VFR), #28 (skip-existing), #29 (sidecar noise).
Status: Released. Open-source release tag.
Bundles three workstreams: a full external code review (verdict: NEEDS CHANGES), a TUI visual-layer redesign, and open-source release hygiene. v0.2.2 landed most code fixes; this release finishes them, fixes two regressions v0.2.2 introduced, corrects invalid shipped config/docs, adds the regression tests the review flagged as missing, gives the Textual workstation a cohesive branded look (see TUI redesign below), and preps the repo for public release (see Release hygiene below).
- Silent-video proxy generation hardened.
_ffmpeg_cmd()now emits ffmpeg optional-audio mapping (-map 0:v:0 -map 0:a?) instead of a probe-conditional-an/-map 0:a. This both fixes the silent-video failure (screen recordings, action cams) and the inverse bug where a failedffprobestripped audio from a source that actually had it. create_resolve_project()crash on default config. v0.2.2's provenance wiring calledconfig.config_hash()instead ofcfg.config_hash(), crashing wheneverconfig=None(the default). Fixed; restores the 5 resolve tests.- Invalid shipped config examples.
ferry.toml.exampleand the README config block placedproxy_codec/proxy_height/checksum_algounder[organize](silently dropped) or a[proxy]table (hardValidationError, because the loader left the table forextra="forbid"to reject). The loader now pops the[proxy]table cleanly; both examples use valid top-level keys. - TUI type errors. Three pre-existing
Path | None → Pathmypy errors in_run_queue()(proxy_source/resolve_source/verify_source) fixed viais Nonenarrowing.
- TUI output isolation (
compute_output_tree()): each queued source gets its own subtree under a shared output root, so same-named clips from separate cards no longer collide. - TUI dry-run safety: when organize is a dry-run, proxy/resolve/verify are skipped instead of operating on an unpopulated output tree.
- Audit provenance: all capabilities now pass
config_hashtostart_run(), so every run is reproducible against its config.
- README & SPEC default organize layout corrected to
{root}/{source_relpath}/{filename}{ext}(source-structure-preserving). - SPEC dropped nonexistent proxy
--codec/--heightflags. - README test count corrected to 292.
Added 20 regression tests covering the review's false-confidence gaps:
real-ffmpeg silent-video proxy generation (@requires_ffmpeg), organize
immutability (distinct inode; editing the copy leaves raw untouched),
empty-baseline verify (verify empty → add file → reports added),
config_hash provenance, and TUI multi-folder output isolation +
dry-run-not-poisoning.
The Textual workstation got a full visual-layer overhaul — zero logic
change, all existing widget IDs and pipeline behavior preserved (292 tests
still pass; a verify-only smoke run completes done).
- Branded home dashboard. Refined slant wordmark (replaces the misaligned figlet), strap listing all five capabilities, tagline, and four bordered stat tiles (TOTAL / SUCCEEDED / FAILED / LIVE) with an ffmpeg + db-path line below.
- Titled panels throughout. Every screen is now grouped into bordered, titled panels: Pipeline → MEDIA BROWSER / QUEUE / CONFIGURE / ACTIVITY with action buttons docked at the bottom; Settings → PROXY / ORGANIZE / PATHS inside a scrollable panel.
- Logs polish. Bordered AUDIT LOG panel with a live subtitle
(
N runs, orN shown · M totalwhen filtering); friendlier timestamps (YYYY-MM-DD HH:MM:SS); status rendered as a colored dot + label. - Comprehensive CSS rewrite. Round borders, panel backgrounds, design-token spacing, consistent stat-tile / field-label / button styling; the orange primary anchors the brand across all screens.
Open-source release prep. No code or behavior changes; the package, CLI, TUI, and audit log are unchanged from the sections above. All 292 tests, ruff, and mypy strict still clean.
- Archive relocated. The frozen
SPEC_v0.2.2.md(which still references the old internal monorepo layout, a sibling-products catalog, and an internal author line) moved from the repo root intodocs/archive/. It now sits next to the other archived docs and is covered by the.gitignorerule that already excluded that directory. LiveSPEC.mdandREADME.mdare unchanged and were already clean. .gitignoretightened. Added.venv/,.mypy_cache/,.ruff_cache/,.hypothesis/,*.egg-info/,dist/,build/so a fresh clone +pip install -e ".[dev]"+git add .can't accidentally commit a virtualenv or tool cache. The existing.pytest_cache/and__pycache__/rules were already in place.- Attribution pseudonymized. The author's real email was dropped from
pyproject.toml; the author field now readsname = "Lunar Park". TheLICENSEcopyright line was updated to match. The GitHub handle (dspury) and repo URL (github.com/dspury/file-ferry) are unchanged because those are the actual repo location.
Status: Released.
Refinement pass driven by a real failure: running a folder from an external camera card appeared to "not probe or execute" from the TUI.
- Activity log was one invisible line. The
Logwidget'swrite()appends raw text with no newline and no markup rendering, so every message after the first was concatenated onto a single truncated line showing literal[cyan]tags. Replaced withRichLog(markup, wrapping, real lines). This alone made pipeline failures visible. - System artifacts poisoned every capability on removable media.
Probe fed AppleDouble
._*sidecars and.DS_Storeto ffprobe; proxy sent._clip.MP4(video suffix, not video) to ffmpeg; organize reported junk as "no probe data" noise; verify raised false added/modified alarms when Finder touched a folder. A sharedis_system_artifactfilter (scan-root-relative) now excludes dot-prefixed components,$RECYCLE.BIN,System Volume Information,LOST.DIR,Thumbs.db,desktop.inifrom all directory scans. - Step checkboxes were invisible. Checkboxes default to 3 rows tall
(tall border) but sat in
height: 1containers — the CONFIGURE panel showed no steps at all. Compact one-row styling applied. - Resolve option selects were pushed off-screen by a
width: 100%Input sharing their row; widths now split the row. - Queue State column could be pushed out of view by long paths. State now renders before Folder and paths ellipsize from the left.
- Bracketed names crashed markup rendering. Paths, filenames, and
error text are now markup-escaped everywhere (
Addtl [B-cam]etc.).
- Probe gained an optional per-file
on_fileprogress callback; the TUI streams each probed file and lists per-file failure reasons for probe, organize, and proxy (bounded to 5 + count). A probe that yields zero media files now fails the queue item loudly instead of reporting silent success. - Drive detection (mount resolution +
disk_usage) moved off the UI thread with a 10-second refresh — hot-plugged cards appear without a restart, and a spun-down disk can't freeze the interface. - The ffmpeg version check (subprocess, up to 5 s) no longer blocks Home-screen startup; Home stats and the Audit Log refresh on screen resume instead of staying stale for the session.
- Run-state guards: Remove and re-run are blocked while a queue runs; Esc/Q show a "cancel first" dialog instead of abandoning a half-written copy; the run worker snapshots the queue, honors worker cancellation, and always restores the Run button. Corrupt/foreign audit databases degrade gracefully instead of crashing screens.
- The MEDIA BROWSER directory tree hides dotfiles and system folders.
The project was renamed from media-mate to file-ferry. No features were
removed and no behavior changed; only identity and packaging moved.
Two names, by design (§14): file-ferry is the project — PyPI, the import
name, the repo, document titles — and ferry is what you type and see —
the command, its config and data locations, its environment overrides, the
desktop product name, the TUI banner.
Identity
| What | Old | New |
|---|---|---|
| GitHub repo | dspury/media-mate |
dspury/file-ferry |
| PyPI distribution | media-mate |
file-ferry (see §14) |
| Python package | media_mate |
file_ferry |
| CLI command | media-mate |
ferry |
| Service entry point | media-mate-service |
ferry-service |
| Config model | MediaMateConfig |
FerryConfig |
| Preload bridge | window.mediaMate |
window.ferry |
| Desktop product name | media-mate |
ferry |
Paths and environment
| What | Old | New |
|---|---|---|
| Project config file | ./media-mate.toml |
./ferry.toml |
| Home config | ~/.media-mate/config.toml |
~/.ferry/config.toml |
| Legacy audit db | ~/.media-mate/media-mate.db |
~/.ferry/ferry.db |
| Desktop app data (macOS) | ~/Library/Application Support/media-mate/ |
~/Library/Application Support/ferry/ |
| Db backup prefix | media-mate-{ISO8601}-pre-{NNN}.db |
ferry-{ISO8601}-pre-{NNN}.db |
| Env overrides | MEDIA_MATE_CONFIG, MEDIA_MATE_DB, MEDIA_MATE_PROTOCOL_VERSION |
FERRY_CONFIG, FERRY_DB, FERRY_PROTOCOL_VERSION |
There is no automatic migration: the new binary reads the new paths, so a v0.2.x
user keeps their old data at the old paths until they move it. The README
documents the two mv commands that carry it over. ferry.toml is read from
the working directory exactly as media-mate.toml was.
DaVinci Resolve is now explicitly optional. The Resolve API adapter is
imported lazily by both the CLI and the TUI, so nothing in the import graph
touches DaVinciResolveScript unless a Resolve command actually runs. Resolve
has no pip dependency — it is detected at runtime — and the manifest-first
fallback (§5) already covers the "Resolve not installed" case. [resolve] and
[tui] extras exist to record intent; neither installs anything.
docs/archive/ was removed. The frozen SPEC_v0.2.2.md, architecture.md
and sample-run.md described the tool under its old name and were superseded by
this document and the README.
The following issues are acknowledged and targeted for v0.3. Each requires a spec change or design decision before implementation.
Severity: Real bug.
Recommendation: Add -fps_mode cfr to all proxy generation (forcing constant frame rate from variable-frame-rate sources). This normalizes all proxies to CFR regardless of source — safe because proxies are throwaway edit media. Keep is_vfr in the probe for visibility; the proxy command just normalizes.
Versioning impact: None (behavior change is a strict improvement).
Status: Worth doing now.
Severity: Partial. Recommendation: Two parts:
- Hardlink on same device — when source and dest are on the same volume, use
os.link()instead ofshutil.copy2(). Zero I/O overhead, originals stay immutable. Implemented in organize. This is the 80% solution. - Device-aware parallelism — closed as won't-fix for v0.3. Adds significant complexity for marginal gain on a single-operator CLI. Versioning impact: None (hardlink is an optimization, not a behavior change). Status: Hardlink: worth doing now. Parallelism: wont-fix.
Severity: Spec violation (promised in §5.4).
Recommendation: Implement media_pool.ImportMedia() and CreateTimelineFromClips() via the live Resolve API, or demote the capability to "manifest-first" and make the manifest the primary deliverable. The manifest layer is solid; the live-API path produces hollow projects.
Versioning impact: Yes — this is the headline Resolve feature.
Status: Worth doing now, but requires either Resolve API access for testing or a clear decision to demote to manifest-only.
Severity: Serious — undermines the core integrity promise.
Recommendation: Split "snapshot" (write baseline) from "verify" (compare, never mutate on mismatch). Currently replace_verification_snapshot() is called unconditionally, so a corrupted file's checksum overwrites the good baseline and future runs report clean. With this fix, corruption stays flagged until explicitly acknowledged with --accept-changes.
Versioning impact: None — this is fixing a silent correctness bug, not changing advertised behavior.
Status: Worth doing now (one-line change in verify.py).
Severity: Real UX problem.
Recommendation: Change the default organize template from by_codec to source-structure-preserving (e.g., {root}/{date}/{filename}{ext} or simply mirror source folder under dest). The template system already supports this — it's a default-value change. Codec/resolution remain available as opt-in templates.
Versioning impact: Yes — changes the default output layout. Existing configs using explicit templates are unaffected.
Status: Worth doing now (default change, minimal code).
Severity: Real. Recommendation: Two parts:
- Keep groups together — when a multi-file clip is detected (by naming convention), never split the group across organize destinations. Emit a warning so the user knows. This is largely free if #22's source-preserving default is implemented.
- Full spanned-clip model — v1.0 candidate. Requires a logical clip abstraction that tracks group membership through organize/proxy/resolve. Retrofitting this into the current per-file model is a significant architecture change. Versioning impact: Partial (warning is none; full model is breaking). Status: Warning: worth doing now. Full model: v1.0.
Severity: Truth-in-advertising bug.
Recommendation: Add a pre-check that detects .r3d, .braw, and .ari extensions and emits a clear error: "R3D decode requires the RED SDK; not supported by stock ffmpeg." Correct the README and SPEC.md §5.3 to say "container recognized; decode requires vendor plugins." Tiny fix, high credibility value.
Versioning impact: None (spec correction + error message).
Status: Worth doing now.
Severity: Real inconsistency.
Recommendation: Fold into #20. When implementing the live-API import path, fix _build_bin_tree to produce nested folder structures that match the manifest's resolve_bin_structure. If recursive bins are not yet supported, deliberately flatten both paths to first-level bins for consistency.
Versioning impact: Folded into #20.
Status: Worth doing, folded into #20.
All items below were approved at spec time and shipped in v0.1.0:
- Scope as defined in §5
- Non-goals as defined in §4
- Architecture as sketched in §6
- Data model as sketched in §7
- Tech stack as defined in §9
- Safety constraints in §11
- Versioning rule in §13 (MAJOR=0 until D approves)
- License: MIT
- Repo location:
dspury/file-ferry - Name:
file-ferry - First tagged version: 0.1.0
All items below were approved and shipped in v0.2.2:
- Bug fixes in §19
- New capabilities in §19
- Data model additions in §19
- v0.3 candidates as documented in §20
Shipped as approved ✓