Skip to content

Configuration

VBC can be configured via YAML files and CLI arguments. CLI arguments always override config file settings.

Configuration File

Default location: conf/vbc.yaml

Demo Configuration

Demo mode uses a separate simulation file: conf/demo.yaml.

uv run vbc --demo --demo-config conf/demo.yaml

This file controls simulated file counts, size distribution, processing speed, and error mix.

Full Example

input_dirs:
  - path: /path/to/videos
    enabled: true
  - path: /path/to/folder with spaces
    enabled: true
  - path: /path/to/disabled-folder
    enabled: false

# When using output_dirs, set suffix_output_dirs to null.
output_dirs:
  - /path/to/videos_out
  - /path/to/folder with spaces_out

suffix_output_dirs: null

# When using errors_dirs, set suffix_errors_dirs to null.
errors_dirs:
  - /path/to/videos_err
  - /path/to/folder with spaces_err

suffix_errors_dirs: null

general:
  # === Core Settings ===
  threads: 8                    # Max concurrent compression threads (>0; executor max_workers=16)
  prefetch_factor: 1            # Submit-on-demand multiplier (>=1)
  preflight_in_worker: false    # Run metadata preparation in active worker slots
  gpu: true                     # Use GPU (NVENC) vs CPU (SVT-AV1)
  queue_sort: name              # Also supports source-mtime-desc (newest source first)
  queue_seed: null              # Optional seed for deterministic rand order
  log_path: /tmp/vbc/compression.log  # Log file location
  cpu_fallback: false           # Retry on CPU if NVENC hits HW cap error
  ffmpeg_cpu_threads: null      # Max CPU threads per ffmpeg worker (null = auto)

  # === Input/Output ===
  extensions:                   # File extensions to process
    - ".mp4"
    - ".mov"
    - ".avi"
    - ".flv"
    - ".webm"
  min_size_bytes: 1048576       # Minimum file size (1 MiB)

  # === Metadata ===
  copy_metadata: true           # Copy EXIF/XMP tags from source
  use_exif: true                # Use ExifTool for deep metadata analysis

  # === Filtering ===
  skip_av1: false               # Skip files already encoded in AV1
  filter_cameras: []            # Only process specific camera models (empty = all)
                                # Example: ["ILCE-7RM5", "DJI OsmoPocket3"]

  # === Quality Control ===
  dynamic_quality:             # Camera-specific quality rules
    "ILCE-7RM5":
      cq: 38                   # CQ mode override
      rate:                    # Optional rate override
        bps: "0.8"
        minrate: "0.7"
        maxrate: "0.9"
    "DC-GH7":
      cq: 40
    "DJI OsmoPocket3":
      cq: 45
      rate:
        bps: "180M"
  quality_mode: cq              # cq (default) or rate
  bps: null                     # e.g. 200Mbps, 200M, 200000k, 0.8
  minrate: null                 # optional; same class as bps
  maxrate: null                 # optional; same class as bps
  rate_target_max_bps: null     # optional hard cap for resolved target bitrate (absolute only)

  min_compression_ratio: 0.1    # Minimum savings required (0.0-1.0)
                                # If compression < 10%, keep original

  # === Error Handling ===
  clean_errors: false           # Remove .err markers on startup
  verify_fail_action: false     # false | log | pause | exit
  source_policy: keep           # keep | delete_after_success
  auto_repair_errors: true      # Repair current-session failures before WAITING/exit

  # === UI/Display ===
  strip_unicode_display: true   # Replace emoji/unicode with '?' in UI
  debug: false                  # Enable verbose debug logging

  # === Manual Rotation ===
  manual_rotation: null         # Global rotation (null, 0, 90, 180, 270)

gpu_config:
  enabled: true
  sample_interval_s: 5.0
  history_window_s: 300.0
  nvtop_device_index: 0

gpu_encoder:
  advanced: false
  common_args:
    - "-c:v av1_nvenc"
    - "-preset p7"
    - "-tune hq"
    - "-b:v 0"
    - "-cq 45"
    - "-f mp4"
  advanced_args:
    - "-c:v av1_nvenc"
    - "-preset p7"
    - "-tune hq"
    - "-b:v 0"
    - "-cq 45"
    - "-rc vbr"
    - "-multipass fullres"
    - "-rc-lookahead 32"
    - "-spatial-aq 1"
    - "-temporal-aq 1"
    - "-aq-strength 8"
    - "-b_ref_mode middle"
    - "-f mp4"

cpu_encoder:
  advanced: false
  common_args:
    - "-c:v libsvtav1"
    - "-preset 6"
    - "-crf 32"
    - "-svtav1-params tune=0:enable-overlays=1"
    - "-f mp4"
  advanced_enforce_input_pix_fmt: true
  advanced_args:
    - "-c:v libaom-av1"
    - "-crf 30"
    - "-b:v 0"
    - "-cpu-used 0"
    - "-tune ssim"
    - "-lag-in-frames 35"
    - "-aq-mode 1"
    - "-row-mt 1"
    - "-threads 0"
    - "-f matroska"

ui:
  activity_feed_max_items: 5
  panel_height_scale: 0.7

autorotate:
  patterns:                     # Regex -> Rotation angle
    "DJI_.*\\.MP4": 0          # DJI drones - no rotation
    "GOPR\\d+\\.MP4": 180      # GoPro pattern - 180° flip
    "IMG_\\d{4}\\.MOV": 90     # iPhone pattern - 90° rotation

Configuration Reference

General Settings

threads

  • Type: Integer (>0)
  • Default: 1
  • Description: Maximum number of concurrent compression threads
  • Note: Runtime keyboard adjustment (</>) clamps to 1-8 threads
  • Implementation detail: Worker pool is created with max_workers=16, so practical upper parallelism is 16

Quality Defaults

  • Source: Encoder args (gpu_encoder/cpu_encoder) via -cq (GPU) or -crf (CPU)
  • Override: --quality CLI flag or general.dynamic_quality mappings
  • Recommendation ranges (0-63, lower = better quality):
  • 35-38: Archival quality
  • 40-45: High quality daily use
  • 48-52: Good quality, smaller files
  • 55+: Low quality, very small files

prefetch_factor

  • Type: Integer (>=1)
  • Default: 1
  • Description: Submit-on-demand queue multiplier. Higher values = more files queued.
  • Formula: max_queued = prefetch_factor × threads

preflight_in_worker

  • Type: Boolean
  • Default: false
  • Description: When enabled, disables proactive metadata probing of the pending queue. Each task performs its preflight after taking a normal worker slot and appears in ACTIVE JOBS with the animated arc icon before compression begins.
  • Trade-off: A slow ffprobe occupies only one worker slot instead of blocking preparation of the entire queue. Queue rows remain lightweight until their task starts.
  • Scope: Global; applies to regular video files and JSON metadata tasks.

queue_sort

  • Type: String
  • Default: name
  • Description: Processing order for files in the queue
  • Values: name, rand, dir, size, size-asc, size-desc, ext, source-mtime-desc
  • Notes:
  • size is an alias for size-asc
  • rand can be made deterministic with queue_seed
  • ext uses the order of extensions and requires a non-empty list
  • source-mtime-desc places the newest source recording first. Manifest jobs use producer.source_latest_mtime_ns; regular video jobs use the source file's filesystem mtime_ns. The manifest JSON file's own mtime is never used, so importing old manifests does not make archival recordings look new.

queue_seed

  • Type: Integer or null
  • Default: null
  • Description: Seed for deterministic rand queue order
  • Example: 42 (ensure same random order across runs)

log_path

  • Type: String or null
  • Default: /tmp/vbc/compression.log
  • Description: Path to log file (overrides the output directory default)
  • Note: If set to null, logs are written to <output_dir>/compression.log.
  • Rotation: At startup, a non-empty existing log is archived with a local timestamp, for example compression_2026-07-18_00-22-15.log. The new session keeps using compression.log.

cpu_fallback

  • Type: Boolean
  • Default: false
  • Description: Retry on CPU when GPU encoding fails with hardware capability errors
  • Note: Useful when NVENC runs out of sessions; pair with ffmpeg_cpu_threads
  • Behavior: HW cap .err markers are cleared on startup so files re-enter the queue

ffmpeg_cpu_threads

  • Type: Integer or null
  • Default: null
  • Description: Max CPU threads per ffmpeg worker when using CPU encoding (including fallback)
  • Note: Limits per-worker CPU usage; does not change threads (worker count)

gpu

  • Type: Boolean
  • Default: true
  • Description: Use GPU (NVENC) instead of CPU (SVT-AV1)
  • GPU (NVENC):
  • Pros: Very fast, good for 1080p/1440p
  • Cons: Quality ceiling at ~CQ35-38, session limits
  • CPU (SVT-AV1):
  • Pros: Excellent quality, no session limits
  • Cons: Much slower

gpu_refresh_rate

  • Type: Integer
  • Default: 5
  • Description: (Deprecated) Use gpu_config.sample_interval_s instead. Kept for backwards compatibility.
  • Note: See deprecation notice in GPU Monitoring section

GPU Monitoring (gpu_config)

Advanced settings for GPU monitoring sparklines.

enabled

  • Type: Boolean
  • Default: true
  • Description: Enable GPU monitoring and dashboard sparklines.
  • Note: Requires NVIDIA GPU and nvtop (VBC calls nvtop -s). nvidia-smi is only for driver checks.

sample_interval_s

  • Type: Float
  • Default: 5.0
  • Description: How often to sample GPU metrics (seconds).

history_window_s

  • Type: Float
  • Default: 300.0
  • Description: Total time window shown in sparklines (default 5 minutes).

nvtop_device_index

  • Type: Integer
  • Default: 0
  • Description: Index of the GPU to monitor when multiple GPUs are present.

nvtop_device_name

  • Type: String or null
  • Default: null
  • Description: Override device selection by name instead of index (e.g., "NVIDIA GeForce RTX 4090").
  • Note: When set, takes precedence over nvtop_device_index.

nvtop_path

  • Type: String or null
  • Default: null
  • Description: Custom path to nvtop binary (e.g., "/usr/local/bin/nvtop"). Auto-detected if not set.
  • Note: Useful when multiple nvtop versions are installed or nvtop is in a non-standard location.

refresh_rate

  • Type: Integer
  • Default: 5
  • Description: (Deprecated) Use sample_interval_s instead. Kept for backwards compatibility.

Deprecated Fields

  • gpu_config.refresh_rate is deprecated in favor of sample_interval_s
  • general.gpu_refresh_rate is deprecated in favor of gpu_config.sample_interval_s

For backwards compatibility, VBC still accepts both old fields, but new configurations should use gpu_config.sample_interval_s.

GPU Encoder (gpu_encoder)

Encoder args are full FFmpeg argument lists. VBC uses one list: common_args when advanced=false, otherwise advanced_args. Lists are not merged.

advanced

  • Type: Boolean
  • Default: false
  • Description: Use advanced_args instead of common_args.

common_args

  • Type: List of strings
  • Default: NVENC baseline (-c:v av1_nvenc, -preset p7, -tune hq, -b:v 0, -cq 45, -f mp4)
  • Description: Full FFmpeg arg list for standard GPU encoding.

advanced_args

  • Type: List of strings
  • Default: NVENC HQ set (adds -rc vbr, -multipass fullres, AQ, lookahead, etc.)
  • Description: Full FFmpeg arg list for advanced GPU encoding.

CPU Encoder (cpu_encoder)

advanced

  • Type: Boolean
  • Default: false
  • Description: Use advanced_args instead of common_args.

common_args

  • Type: List of strings
  • Default: SVT-AV1 baseline (-c:v libsvtav1, -preset 6, -crf 32, -f mp4)
  • Description: Full FFmpeg arg list for standard CPU encoding.

advanced_enforce_input_pix_fmt

  • Type: Boolean
  • Default: true
  • Description: When advanced=true, force output -pix_fmt to match the input (from ffprobe).

advanced_args

  • Type: List of strings
  • Default: AOM AV1 HQ set (-c:v libaom-av1, -crf 30, -cpu-used 0, -tune ssim, etc.)
  • Description: Full FFmpeg arg list for advanced CPU encoding (often outputs -f matroska, so .mkv).

UI Configuration (ui)

Dashboard display settings.

activity_feed_max_items

  • Type: Integer (1-20)
  • Default: 5
  • Description: Maximum number of events shown in the activity feed panel.

active_jobs_max_display

  • Type: Integer (1-16)
  • Default: 8
  • Description: Maximum number of concurrent jobs to display in the active panel.

panel_height_scale

  • Type: Float (0.3-1.0)
  • Default: 0.7
  • Description: Vertical scaling factor for panels (0.7 = 30% reduction in height).

Input/Output

input_dirs

  • Type: List of objects ({path: string, enabled: bool, metadata?: bool, watch?: bool, idle_interval?: int})
  • Default: [] (empty)
  • Description: Ordered input directory entries used when no CLI input is provided
  • Behavior:
  • CLI input overrides config input (no merge)
  • Only entries with enabled: true are processed
  • UI order is exactly the same as YAML order
  • Duplicate path values are rejected (startup validation error)
  • Missing or inaccessible directories are skipped
  • Startup fails if no valid directories remain
  • Limits: max 50 enabled directories, max 150 characters per path
  • metadata: true scans strict *.json compression manifests instead of video extensions
  • watch: true uses Linux inotify to trigger a refresh when a final *.json is closed after writing or atomically moved into a metadata directory. It is rejected for regular video directories and defaults to false.
  • idle_interval enables an automatic re-scan after that many idle seconds when wait_on_finish: true; omitted means manual refresh only

Manifest-driven multipart input

input_dirs:
  - path: /path/to/metadata
    enabled: true
    metadata: true
    watch: true

suffix_output_dirs: _out
suffix_errors_dirs: _err

metadata:
  audio_only: ignore
  # Maximum missing output video frames accepted for the complete logical job.
  # Default: 0 (strict).
  max_dropped_frames: 0
  # Maximum effective duration of one part and of the complete request.
  # Default: 86400 (24 hours).
  max_duration_seconds: 86400
  # Optional hot-reloaded overrides:
  # source_policy: move_all
  # move_after_success_dir: /path/to/source-archive
  # compression_profile: tiktok
  # error_policy:
  #   missing_input: fail

Each final *.json file represents one logical queue item. Initial discovery validates the JSON and input paths, then publishes a lightweight queue proxy immediately. VBC probes every path in inputs through the rolling 25-item metadata window, preserves the listed order, and splits consecutive parts whenever their orientation changes. Compatible resolutions within one orientation group are normalized to that group's largest frame, transcoded sequentially, and stream-copied into one MP4. The first group uses the exact manifest output_path; later groups use the first available numbered name (_1, _2, and so on). Existing MP4 files are never overwritten and share the same numbered namespace, so an untagged file protected as _1 makes the next orientation group use _2. The split is based only on probed orientation; VBC does not fingerprint or trim repeated content with a compatible orientation. The directory suffixes route the JSON itself:

Each unchanged physical part is probed once per VBC run. That single probe supplies stream properties, packet counts, and the normalized video duration derived from packet timestamps; refreshes reuse the cached result. Output stream verification is likewise cached, while the post-write VBC-tag check uses ExifTool without probing the video again.

With watch: true, completed JSON events are coalesced during a one-second quiet period and then only the reported JSON paths are added to discovery. This works both while jobs are active and in WAITING mode; it does not preempt jobs already running or rescan an existing metadata backlog. Hidden temporary files and all non-JSON names are ignored. If inotify reports an event-queue overflow, VBC shows a warning and falls back to a full refresh. A manually copied JSON that is observed before its final write has settled is retried after a one-second stability check before it can be routed to _err. A path that disappears before incremental discovery reaches it is treated as an already-routed task and is ignored without creating or overwriting an error marker.

  • success: /metadata_out/YYYYMMDD/request.json
  • any probe, compression, verification, or cleanup error for a valid manifest whose inputs are under /recordings/<username>/: /recordings_err/<username>/request.json, /recordings_err/<username>/request.err, and every existing source input
  • invalid manifest JSON whose producer and source list cannot be trusted: /metadata_err/request.json and /metadata_err/request.err

metadata.audio_only is fail by default. ignore removes parts without usable video packets from the effective concat list while retaining them for source_policy handling. delete removes those parts from both the concat list and the filesystem during preflight, independently of source_policy, and logs each removal as MANIFEST_SOURCE_DELETED with reason=audio_only. If no usable video remains, VBC creates no output and moves the unchanged manifest to _out as an ignored task. Optional policy overrides are reloaded before each manifest job; an invalid edit keeps the last valid metadata policy. metadata.max_dropped_frames defaults to strict 0; each generated output accepts only that many missing frames, logs a warning, and never accepts extra frames. metadata.max_duration_seconds defaults to 24 hours. A packet duration above the limit triggers a decoded-frame count only for that anomalous part. VBC accepts it and rebuilds its timestamps when frames / fps is within the limit; otherwise preflight fails. An aggregate duration above the limit also fails. The failure is shown in the activity feed and routes the JSON to _err. copy_metadata remains a video-to-video setting and uses the first effective video part from each generated orientation group.

The success date directory comes from the timestamp suffix in producer.recording_id (..._YYYYMMDD_HHMMSS). If that identifier has no valid date, VBC uses the manifest's required created_at value. The compressed output filename does not need to contain a date. Existing flat success manifests remain supported by cleanup tools.

Every generated output must pass frame, ffprobe, and VBC-tag verification before the JSON moves to _out or an output-dependent source policy runs. A request below general.min_size_bytes is also a successful no-output result: delete_after_success deletes all of its sources and move_all archives them. Ctrl+C leaves the manifest and sources in place. On restart, VBC reuses verified VBC outputs in numbered order, skips occupied untagged numbers, and continues with the first missing group.

After successful finalization, every generated output receives the manifest's exact producer.source_latest_mtime_ns. The same rule applies to regular video jobs using the source video's filesystem mtime. Output directory timestamps are not modified. Actual file changes are recorded in the application log as OUTPUT_MTIME_CHANGED entries.

Manifest schema version 1 requires operation: concat_transcode, absolute unique input paths, compression_profile: tiktok, error_policy.missing_input: fail, and one of source_policy: keep, delete_after_success, move_after_success, or move_all. Deletion and move_after_success normally run only after atomic output finalization and successful ffprobe/VBC-tag verification. The exception is a request below general.min_size_bytes: it is considered successfully handled without an output, so delete_after_success deletes all inputs. Each deletion is recorded as MANIFEST_SOURCE_DELETED with the source path, size in bytes, manifest path, and reason. move_after_success uses metadata.move_after_success_dir and preserves the producer username as the first destination directory. A missing destination setting, a missing or unwritable directory, insufficient free space for all inputs, or an existing destination file makes the policy fall back to keep for the complete request. move_all additionally archives inputs for completed ignored requests that produced no output. A terminal preflight, compression, verification, or cleanup failure instead derives the real source root from the manifest inputs and quarantines every existing input together with the JSON and .err under <source_root>_err/<producer.username>/, regardless of the success policy. For example, /mnt/1/TT/recordings/user/file.mp4 is quarantined under /mnt/1/TT/recordings_err/user/. The watched metadata directory is only a request transport. Destination collisions fail closed and leave the original request in place. Invalid JSON cannot move sources because its producer and input list are not trusted, so only that JSON and its marker use metadata_err. Ctrl+C is not terminal and never moves sources or the manifest.

Generating manifests for legacy recordings

scripts/generate_metadata_manifests.py scans a recordings tree without modifying it and writes strict manifests into a separate metadata directory. Files ending in _partNNN.mp4 are grouped and numerically ordered; a plain MP4 without a matching part group becomes a single-input request. A read-only ExifTool tag scan first excludes prior outputs carrying the VBCEncoder tag. The scan runs in bounded batches and displays progress. A file that ExifTool reports as an unreadable format is warned about separately and remains eligible for normal manifest discovery; it does not abort the remaining scan. If an untagged plain file is followed by _part002.mp4, it is treated as the legacy first part. Otherwise a complete part group wins over a same-name untagged plain file, which is reported as shadowed. Groups with unresolved missing part numbers, symlinks, VBC staging artifacts, zero total size, and duplicate manifest names are never generated. On a repeated scan, an existing manifest with the same logical content is counted and skipped. Its created_at timestamp is ignored for this comparison. An existing same-name manifest with any other difference is reported as a conflict and is never overwritten.

uv run python scripts/generate_metadata_manifests.py \
  /path/to/recordings \
  /path/to/generated_metadata \
  --compressed-dir /path/to/compressed \
  --modified-before 2026-07-19T00:00:00+02:00 \
  --dry-run

Remove --dry-run to create the JSON files. The required --modified-before value must include a timezone offset. A task is generated only when every input has an mtime strictly older than the boundary, which prevents currently written parts from entering the migration. --compressed-dir defaults to a sibling directory named compressed; --source-policy defaults to keep. A configured metadata.source_policy still overrides the value generated in each manifest. An existing output is still represented by a manifest. VBC validates ffprobe readability and required VBC tags: a valid tagged output is reused, while an untagged or invalid primary output is preserved under the next _N.mp4 name before VBC creates a verified replacement.

Repairing timestamps from filenames

scripts/repair_output_mtimes.py recursively scans a directory without using metadata manifests. For every regular file whose name contains a valid YYYYMMDD_HHMMSS value, it sets the file mtime to that local date and time. The file may have any extension or no extension. Each directory containing matching files directly receives the newest timestamp found among those files. Symlinks are ignored.

# Inspect the planned changes
uv run python scripts/repair_output_mtimes.py \
  /path/to/compressed \
  --dry-run

# Apply the changes
uv run python scripts/repair_output_mtimes.py \
  /path/to/compressed

Use --files-only to modify matching files without touching directories. Use --user-dirs-only to modify only user directories that are direct children of the supplied directory, without touching files. These two switches are mutually exclusive; without either switch, the script updates both files and their direct parent directories.

Because filenames do not contain a timezone offset, timestamps are interpreted in the machine's local timezone. Rich output reports scanned and changed files and directories, invalid date-like names, ignored symlinks, and filesystem errors.

Restoring a failed manifest and moved sources

scripts/restore_failed_manifest.py restores one JSON from a source error package or the legacy configured metadata error directory. It reads input_dirs, errors_dirs or suffix_errors_dirs, and metadata.move_after_success_dir from the VBC configuration. Every source listed in the manifest must either already exist at its original path, be next to the failed manifest in its producer error package, or be present under the legacy archived producer directory. The script validates the complete plan before moving anything, refuses all destination collisions, restores source files first, and moves the JSON into the metadata input directory last so inotify cannot enqueue an incomplete request.

uv run python scripts/restore_failed_manifest.py \
  /path/to/recordings_err/username/request.json \
  --dry-run

The default configuration is conf/vbc.yaml; use --config /path/to/vbc.yaml to select another file. Remove --dry-run to perform the restore. Missing sources are printed with both their original and expected archive paths, and no files are moved when any source is missing. Sources that have already been manually restored are accepted when no duplicate archive copy exists. The existing .err marker remains in the error directory as failure history.

Repairing supported failed manifests

scripts/repair_failed_manifests.py performs a controlled recompression for known failure classes while leaving the original .err marker in place. It accepts one .json/.err file or recursively scans *.err files in a manifest error directory. Unsupported errors are reported and left unchanged. Repairs run sequentially and reuse the normal VBC preflight, output verification, VBC tagging, manifest routing, and source policy.

The first supported handler is FFmpeg exit code 244 / error -12. It restores archived sources when needed and recompresses video without its problematic audio stream. This is a runtime-only override: the manifest JSON is not modified. Every FFmpeg child started by the repair tool has a fixed 50 GiB address-space limit.

# Inspect one task without changing files
uv run python scripts/repair_failed_manifests.py \
  /path/to/recordings_err/username/request.err \
  --dry-run

# Repair every supported task in one configured error directory
uv run python scripts/repair_failed_manifests.py \
  /path/to/recordings_err

The default configuration is conf/vbc.yaml; use --config /path/to/vbc.yaml when needed. For a successful repair, the JSON moves to its configured metadata output directory, source files follow the effective source_policy, and the old .err remains in the error directory. A failed or interrupted repair does not publish the JSON back into the watched input queue.

Analyzing and clearing a manifest error directory

scripts/video_error_analyzer.py recursively classifies every .err file in a manifest error directory such as recordings_err. A plain invocation is read-only and does not require the corresponding JSON to exist:

uv run python scripts/video_error_analyzer.py /path/to/recordings_err

Known categories have independent action flags. --repair-ffmpeg-244 delegates to the bounded repair described above. Deletion flags include --delete-orphans, --delete-moov-missing, --delete-missing-input, --delete-invalid-dimensions, --delete-no-video, --delete-invalid-bitstream, --delete-hardware-capability, --delete-ffmpeg-abort, --delete-ffmpeg-segfault, --delete-ffmpeg-234, and --delete-unknown. Multiple category flags may be combined. Add --dry-run to inspect the selected actions without changing files.

Deletion is deliberately limited to the selected .json and .err metadata pair. It never deletes or moves source videos or compressed outputs. For an orphan marker, only the .err file is removed. There is no catch-all deletion switch.

Verifying and cleaning the moved source archive

scripts/cleanup_source_archive.py independently compares files in a moved source archive with files in the compressed output tree. Manifest contents are not trusted for output verification; completed manifest filenames are used only to recognize requests that VBC already routed successfully. Multipart names such as recording_part001.mp4 are normalized to recording.mp4 in the matching relative user directory. The archive scan uses general.extensions from the same configuration as VBC. Files with other extensions are outside its scope, remain untouched, and are not reported as errors.

# Read-only inventory
uv run python scripts/cleanup_source_archive.py \
  /path/to/sources_compressed \
  /path/to/compressed

# The paths and size floor may be omitted. The source archive and min_size_bytes
# come from conf/vbc.yaml; the compressed root is inferred from active, completed,
# or legacy error manifests in the configured metadata directories.
uv run python scripts/cleanup_source_archive.py

# Preview safe deletion
uv run python scripts/cleanup_source_archive.py \
  /path/to/sources_compressed \
  /path/to/compressed \
  --verify-vbc-tags \
  --delete-verified \
  --dry-run

# Delete at most 10 eligible sources and print every deleted path
uv run python scripts/cleanup_source_archive.py \
  --verify-vbc-tags \
  --delete-verified \
  --delete-limit 10

New outputs contain VBCSourceParts, for example 1,2,4,5. Each output created from an orientation group lists only the physical parts that actually contributed to that output. The cleaner deletes listed parts. For an omitted part, it additionally runs a bounded ffprobe without a packet timeline. A part independently confirmed to have no usable video packets is reported as IGNORED_NO_VIDEO and is deletion-eligible. An omitted part with video, or one that cannot be probed, remains UNMAPPED_SOURCE. Older outputs without VBCSourceParts are reported as LEGACY_MATCH and use filename matching. With --verify-vbc-tags, both precise and legacy matches also require VBCEncoder.

If an output is missing but the request is present in the configured metadata success directory, the cleaner runs bounded ffprobe -count_packets checks without a packet timeline. A group where every source has zero usable video packets is reported as DONE_NO_VIDEO and is deletion-eligible. The cleaner also repeats VBC's effective size calculation after excluding audio-only parts. If the remaining video-bearing sources are below general.min_size_bytes, the group is reported as DONE_BELOW_MIN_SIZE and is also deletion-eligible. A completed group with enough usable video keeps the conservative OUTPUT_MISSING status.

Known terminal failures are quarantined instead of deleted during --delete-verified: moov atom not found (CORRUPT_MOOV), FFmpeg exit -6/SIGABRT, FFmpeg exit -11/SIGSEGV, unsupported hardware capabilities, invalid video dimensions, and invalid input data. Classification prefers the matching .err marker; the missing-moov case can also be recognized directly by the bounded source probe. The cleaner reads the matching manifest and derives <original source root>_err/<producer.username> from its input paths. It moves the archived source together with any matching ttracker-<recording_id>.json and .err found in the configured metadata input, success, or error directories. A missing, invalid, or inconsistent manifest disables quarantine for that source. Destination collisions fail closed, and a partial move is rolled back.

For a multipart failure, the cleaner can isolate a part that independently fails bounded probing with End of file, invalid input data, or a missing moov atom. If the sum of valid video parts and the complete sizes of those corrupt parts is still below general.min_size_bytes, only the corrupt parts are quarantined. Confirmed audio-only parts are excluded from that upper bound, and the remaining sources are deletion-eligible as ignored no-video or below-minimum data. An unresolved probe failure, or an upper bound at or above the configured floor, keeps the complete group as OUTPUT_MISSING.

Unknown failures remain in the source archive as OUTPUT_MISSING. This is intentional: for example, FFmpeg exit code 234 is not assigned a meaning merely from the numeric code. After a non-dry-run cleanup, remaining source files therefore identify recordings whose missing compression output still needs explanation.

Logical source groups below general.min_size_bytes are reported as BELOW_MIN_SIZE. Their individual and combined sizes are shown, and --delete-verified also deletes these sources even when no compressed output exists. Override the configured floor with --min-size-bytes. Remove --dry-run to apply deletion. Outputs, sources at or above the floor without a verified match, unmapped sources, invalid tags, symlinks, and files outside general.extensions are never deleted.

The detailed table shows only sources requiring attention by default, so verified legacy matches and below-minimum entries do not hide unresolved cases. Their counts remain in the inventory summary. Use --show-all to print every source.

--delete-limit N (alias --limit-delete N) limits a --delete-verified run to at most N actual deletions. Without the option, deletion remains unlimited. When the limit is present, the report includes a separate DELETED table; with --dry-run, it instead lists the limited WOULD_DELETE paths.

Scanning and deletion have separate progress bars. The deletion stage reports Deleting sources N/N, or Previewing deletions N/N with --dry-run.

When either positional path is omitted, the script loads conf/vbc.yaml (or --config). The source archive comes from metadata.move_after_success_dir. Because the compressed video root is stored in manifests rather than directly in VBC configuration, the script infers it from up to 100 newest manifests in enabled metadata directories and stops if the root is missing or ambiguous.

output_dirs

  • Type: List of strings
  • Default: [] (empty)
  • Description: Explicit output directories (one per enabled input directory, in order)
  • Rules:
  • Must exist and be writable
  • Count must match enabled input directories
  • Cannot be used with suffix_output_dirs (set it to null)

suffix_output_dirs

  • Type: String or null
  • Default: _out
  • Description: Output directory suffix appended to each input directory name
  • Notes:
  • Set to null when using output_dirs
  • Example: /videos/videos_out

errors_dirs

  • Type: List of strings
  • Default: [] (empty)
  • Description: Explicit directories for failed files (one per enabled input directory, in order)
  • Rules:
  • Must exist and be writable
  • Count must match enabled input directories
  • Cannot be used with suffix_errors_dirs (set it to null)

suffix_errors_dirs

  • Type: String or null
  • Default: _err
  • Description: Suffix appended to each input directory name for failed files
  • Behavior: After processing, failed source files and their .err markers are moved here
  • Safety: If more than 100 .err files are found, VBC asks before moving them
  • Notes:
  • Set to null when using errors_dirs
  • Example: /videos/videos_err

extensions

  • Type: List of strings
  • Default: [".mp4", ".mov", ".avi", ".flv", ".webm"]
  • Description: File extensions to scan and process
  • Note: Case-insensitive, can include or omit leading dot

min_size_bytes

  • Type: Integer
  • Default: 1048576 (1 MiB)
  • Description: Minimum input file size to process
  • Use case: Skip corrupted/incomplete files
  • Manifest behavior: Compared with the sum of effective video-part sizes after audio-only filtering. A smaller request creates no video and moves its JSON to _out as a completed ignored task. delete_after_success deletes every remaining source (audio_only: delete has already removed filtered inputs), move_all archives the remaining sources, and the remaining policies keep them.

Metadata

copy_metadata

  • Type: Boolean
  • Default: true
  • Description: Copy EXIF/XMP/GPS tags from source to output
  • Method: Uses ExifTool to preserve all metadata including GPS
  • Manifest jobs: Copies from the first effective video part; JSON metadata is never copied to the video

use_exif

  • Type: Boolean
  • Default: true
  • Description: Enable deep metadata analysis with ExifTool
  • Required for:
  • dynamic_quality (camera-specific quality)
  • filter_cameras (camera filtering)
  • GPS and camera model extraction

Filtering

skip_av1

  • Type: Boolean
  • Default: false
  • Description: Skip files already encoded in AV1 codec
  • Use case: Mixed libraries with some AV1 files already compressed

filter_cameras

  • Type: List of strings
  • Default: [] (empty = process all cameras)
  • Description: Only process files from specific camera models
  • Example: ["ILCE-7RM5", "DJI OsmoPocket3"]
  • Matching: Case-insensitive substring against extracted camera metadata; exact model strings are most reliable.

Quality Control

dynamic_quality

  • Type: Dictionary (string -> object)
  • Default: {} (empty)
  • Description: Camera model -> quality rule mapping.
  • Matching: Full-text search in all EXIF metadata
  • Example:
    dynamic_quality:
      "ILCE-7RM5":
        cq: 38
        rate:
          bps: "0.8"
          minrate: "0.7"
          maxrate: "0.9"
          rate_target_max_bps: "95M"
      "Sony":
        cq: 40
      "DJI OsmoPocket3":
        cq: 45
        rate:
          bps: "180M"
    
  • Priority: First match wins (order matters in YAML)
  • Required schema: each entry must be an object with cq; legacy scalar format ("Sony": 40) is rejected.

quality_mode

  • Type: String
  • Default: cq
  • Values: cq, rate
  • Description:
  • cq: use encoder quality args (-cq on GPU, -crf on CPU)
  • rate: use bitrate target (bps) with optional minrate/maxrate
  • CLI validation rules:
  • --quality is valid only for quality_mode=cq
  • --bps / --minrate / --maxrate require quality_mode=rate

bps

  • Type: String or null
  • Default: null
  • Description: Target bitrate for quality_mode=rate.
  • Accepted formats:
  • Absolute: 200000000, 200000k, 200M, 200Mbps
  • Relative: 0.8 (input bitrate × 0.8)

minrate

  • Type: String or null
  • Default: null
  • Description: Optional lower bitrate bound in rate mode.
  • Rule: Must use the same numeric class as bps (all absolute or all relative).

maxrate

  • Type: String or null
  • Default: null
  • Description: Optional upper bitrate bound in rate mode.
  • Rule: Must use the same numeric class as bps and minrate.

rate_target_max_bps

  • Type: String or null
  • Default: null
  • Description: Optional hard cap applied after bps/minrate/maxrate resolution in rate mode.
  • Accepted formats: Absolute only (95M, 100Mbps, 100000k, 100000000).
  • Scope:
  • Global: general.rate_target_max_bps
  • Per camera override: general.dynamic_quality.<pattern>.rate.rate_target_max_bps
  • Note: This caps target bitrate; it does not guarantee peak bitrate.

min_compression_ratio

  • Type: Float (0.0-1.0)
  • Default: 0.1 (10%)
  • Description: Minimum compression savings required
  • Behavior: If (1 - output_size/input_size) < threshold, keep original file instead of compressed version
  • Use case: Prevent "compression" that makes files larger

Error Handling

clean_errors

  • Type: Boolean
  • Default: false
  • Description: Remove existing .err markers on startup and retry those files
  • Behavior:
  • false: Skip files with .err markers
  • true: Delete .err files and retry compression

verify_fail_action

  • Type: String
  • Default: false
  • CLI: --verify-fail-action
  • Values:
  • false: disable output verification
  • log: mark job as failed and continue
  • pause: mark job as failed and pause queue with ERROR status (wait for R/S)
  • exit: mark job as failed and terminate processing with error
  • Verification checks (when mode is not false):
  • ffprobe can read output without errors
  • VBC tags exist: VBCOriginalName, VBCOriginalSize, VBCQuality, VBCOriginalBitrate, VBCEncoder, VBCFinishedAt

source_policy

  • Type: String
  • Default: keep
  • Values:
  • keep: retain the original source file
  • delete_after_success: delete the source only after a successful compression and verification
  • Safety: This option forces verification even when verify_fail_action: false. VBC checks that ffprobe can read the output, that it contains video packets, and that all required VBC tags are present. If compression did not meet the minimum savings ratio, VBC keeps the original source.

auto_repair_errors

  • Type: Boolean
  • Default: true
  • Description: Automatically attempt one in-TUI repair pass for files that failed during the current session
  • Behavior:
  • true: After the queue is empty, VBC enters REPAIR, moves current-session failures to the errors folder, repairs them once, then queues repaired files for compression
  • false: VBC skips the automatic repair pass
  • Repaired .mkv files are processed directly after repair even when .mkv is not listed in general.extensions
  • After graceful shutdown (S), VBC performs the repair pass and exits without compressing repaired files

repair_corrupted_flv

  • Type: Boolean
  • Default: false
  • Description: Legacy/special FLV repair setting. Automatic current-session repair is controlled by auto_repair_errors.

wait_on_finish

  • Type: Boolean
  • Default: false
  • CLI: --wait / --no-wait
  • Description: After all tasks complete, wait for user input instead of auto-exiting
  • Behavior:
  • true: Displays WAITING status; press R to restart scan or S/Ctrl+C to exit
  • With per-directory idle_interval, VBC automatically re-scans due directories while WAITING
  • A metadata directory with watch: true triggers refreshes in both ACTIVE and WAITING states. wait_on_finish: true is still required to keep an empty VBC process alive for future events.
  • false: VBC exits automatically when processing finishes

bell_on_finish

  • Type: Boolean
  • Default: false
  • CLI: --bell / --no-bell
  • Description: Play a terminal bell (BEL character) once the full cycle finishes (compression → automatic repair → compression of repaired files)
  • Triggers:
  • When entering wait state (if wait_on_finish=true)
  • Just before exit (if wait_on_finish=false)
  • The automatic repair pass itself does not emit a bell, because repaired files are queued and compressed immediately afterwards

UI/Display

strip_unicode_display

  • Type: Boolean
  • Default: true
  • Description: Replace non-ASCII characters (emoji, special Unicode) with '?' in UI
  • Reason: Prevents table alignment issues with emoji in filenames

debug

  • Type: Boolean
  • Default: false
  • Description: Enable verbose debug logging
  • Logs: FFmpeg timing, ExifTool calls, compression stages

manual_rotation

  • Type: Integer or null
  • Default: null
  • Values: null, 0, 90, 180, 270
  • Description: Global rotation override (takes precedence over autorotate)

Auto-Rotation

sidecar

  • Type: Boolean
  • Default: false
  • Description: Read a per-video rotation from a same-stem .rot file
  • Format: One directive: --video-rotate=-1, 0, 90, 180, or 270
  • Example: clip.part.mp4 uses clip.part.rot
  • Priority: Manual CLI rotation → .rot sidecar → filename patterns
autorotate:
  sidecar: true
  patterns: {}

When disabled, VBC does not inspect .rot files. A missing .rot file falls through to patterns; an invalid or unreadable sidecar fails that video job. The value -1 means that rotation could not be determined: VBC applies no custom rotation and does not fall through to filename patterns. When a sidecar supplies a rotation, VBC ignores rotation metadata embedded in the input and applies only the sidecar value.

patterns

  • Type: Dictionary (regex -> angle)
  • Default: {}
  • Description: Filename regex patterns mapped to rotation angles
  • Example:
    autorotate:
      patterns:
        "DJI_.*\\.MP4": 0        # No rotation for DJI drones
        "GOPR\\d+\\.MP4": 180    # 180° for GoPro pattern
        "IMG_\\d{4}\\.MOV": 90   # 90° for iPhone videos
    
  • Note: First match wins (order matters)

CLI Overrides

Only a subset of config keys can be overridden via CLI flags:

  • general.threads--threads
  • encoder CQ/CRF value → --quality (only when quality_mode=cq)
  • general.quality_mode--quality-mode
  • general.bps, general.minrate, general.maxrate--bps, --minrate, --maxrate (only when quality_mode=rate)
  • general.gpu--gpu/--cpu
  • general.queue_sort, general.queue_seed--queue-sort, --queue-seed
  • general.log_path--log-path
  • general.clean_errors--clean-errors
  • general.verify_fail_action--verify-fail-action
  • general.skip_av1--skip-av1
  • general.min_size_bytes--min-size
  • general.min_compression_ratio--min-ratio
  • general.filter_cameras--camera
  • general.manual_rotation--rotate-180
  • general.debug--debug
  • general.wait_on_finish--wait / --no-wait
  • general.bell_on_finish--bell / --no-bell
  • general.auto_repair_errors → config only
  • general.repair_corrupted_flv → config only

Other settings (for example prefetch_factor, dynamic_quality, encoder arg lists, directory mappings, and gpu_config) must be set in YAML.

# Override threads and quality
uv run vbc /videos --threads 8 --quality 38

# Override GPU setting
uv run vbc /videos --cpu  # Force CPU mode

# Override camera filtering
uv run vbc /videos --camera "ILCE-7RM5,DJI"

# Override multiple settings
uv run vbc /videos \
  --config custom.yaml \
  --threads 8 \
  --quality 40 \
  --gpu \
  --skip-av1 \
  --clean-errors \
  --min-size 5242880 \
  --rotate-180 \
  --debug

Local Overrides (VBC.YAML)

VBC scans input directories for VBC.YAML and applies the nearest ancestor file per job.

Priority: Global config → Local VBC.YAML → CLI.

Allowed root keys: general, gpu_encoder, cpu_encoder, autorotate, cq.

For example, this local config enables .rot sidecars only for videos below its directory:

autorotate:
  sidecar: true

Allowed general keys: gpu, cpu_fallback, ffmpeg_cpu_threads, copy_metadata, use_exif, filter_cameras, dynamic_quality, quality_mode, bps, minrate, maxrate, rate_target_max_bps, extensions, min_size_bytes, clean_errors, verify_fail_action, skip_av1, manual_rotation, min_compression_ratio, source_policy, debug.

Special key: cq (int 0–63) overrides quality for both GPU and CPU encoder args.

Environment-Specific Configs

You can maintain multiple config files:

# Production (high quality, slow)
uv run vbc /videos --config conf/production.yaml

# Fast preview (low quality, fast)
uv run vbc /videos --config conf/preview.yaml

# Archival (maximum quality)
uv run vbc /videos --config conf/archive.yaml

Example conf/archive.yaml:

general:
  threads: 4
  gpu: false       # CPU for best quality
  copy_metadata: true
  use_exif: true
  min_compression_ratio: 0.05  # Must save at least 5%

cpu_encoder:
  common_args:
    - "-crf 30"    # Very high quality

Validation

VBC uses Pydantic for config validation. Invalid model values raise ValidationError; CLI startup catches validation and helper ValueErrors and prints them as Typer error messages:

# Invalid threads (must be > 0)
Error: threads must be greater than 0

# Invalid rotation angle
Error: Invalid rotation angle 45. Must be 0, 90, 180, or 270.

Next Steps