Always keep both README.md and CLAUDE.md up to date when making changes to:
- Build instructions or dependencies
- Project structure
- Development workflow
- Configuration or setup steps
Both files should stay synchronized - README.md is for humans, CLAUDE.md is for AI assistants.
VapourBox is a cross-platform (macOS + Windows) video processing application using VapourSynth. It provides a simple drag-and-drop interface for deinterlacing, denoising, sharpening, and other video processing tasks as an alternative to more complex tools like Hybrid.
Technology Stack:
- UI: Flutter (Dart) - cross-platform desktop app
- Worker: Rust - CLI that runs vspipe | ffmpeg pipeline
- Processing: VapourSynth + QTGMC (havsfunc)
┌─────────────────────────────────────────────────────────────┐
│ VapourBox │
├─────────────────────────────────────────────────────────────┤
│ Flutter App (Dart) │ Rust Worker (CLI) │
│ - Cross-platform GUI │ - Receives job config JSON │
│ - Settings management │ - Generates .vpy script │
│ - Process coordination │ - Runs: vspipe | ffmpeg │
│ - Progress display │ - Reports progress (stdout) │
└─────────────────────────────────────────────────────────────┘
- Config: JSON file path passed as CLI argument to worker (
--config path/to/job.json) - Progress: JSON lines from worker stdout (
{"type":"progress","frame":1234,...}) - Cancel: SIGTERM (Unix) or TerminateProcess (Windows)
VapourBox/
├── app/ # Flutter application
│ ├── lib/
│ │ ├── models/ # VideoJob, FilterSchema, ProcessingPreset
│ │ ├── viewmodels/ # MainViewModel, SettingsViewModel
│ │ ├── views/ # MainWindow, DropZone, PreviewPanel
│ │ │ └── settings/ # QTGMC parameter UI sections
│ │ ├── services/ # WorkerManager, PresetService, FilterLoader
│ │ └── widgets/ # Reusable UI widgets
│ ├── assets/filters/ # Built-in filter schemas (JSON)
│ │ └── core/ # Core filters (deinterlace, denoise, etc.)
│ ├── macos/ # macOS platform config
│ └── windows/ # Windows platform config
│
├── worker/ # Rust worker crate
│ ├── src/
│ │ ├── models/ # Matching data models (serde)
│ │ ├── script_generator.rs # VapourSynth .vpy generation
│ │ ├── pipeline_executor.rs# vspipe | ffmpeg execution
│ │ ├── progress_reporter.rs# JSON stdout output
│ │ ├── dependency_locator.rs# Find bundled deps
│ │ └── platform/ # Platform-specific code
│ ├── templates/ # VapourSynth script templates
│ └── tests/ # Integration tests
│
├── deps/ # Platform-specific dependencies
│ ├── macos-arm64/ # Python 3.12, VS, FFmpeg, plugins
│ ├── macos-x64/
│ └── windows-x64/ # VSPipe, Python 3.8, FFmpeg, plugins
│
├── licenses/ # GPL, LGPL, NOTICES
├── Scripts/ # Build, package, and release scripts
└── .github/workflows/ # CI build workflows
| File | Purpose |
|---|---|
worker/src/models/video_job.rs |
Job config, EncodingSettings, processing passes |
worker/src/models/qtgmc_parameters.rs |
All 70+ QTGMC parameters (serde) |
worker/src/script_generator.rs |
Template substitution for .vpy |
worker/src/pipeline_executor.rs |
vspipe | ffmpeg execution |
worker/templates/pipeline_template.vpy |
VapourSynth script template |
worker/tests/filter_integration_test.rs |
Filter integration tests |
| File | Purpose |
|---|---|
app/lib/models/video_job.dart |
Job config (json_serializable) |
app/lib/models/filter_schema.dart |
FilterSchema, ParameterDefinition models |
app/lib/models/parameter_converter.dart |
Bidirectional typed ↔ dynamic conversion |
app/lib/services/worker_manager.dart |
Process spawning, IPC |
app/lib/services/filter_loader.dart |
Load filter schemas from JSON |
app/lib/services/preset_service.dart |
Save/load user presets |
app/assets/filters/core/*.json |
Built-in filter schema definitions |
- Flutter SDK 3.16+
- Rust 1.70+
- Windows: Visual Studio Build Tools with C++ workload
- macOS: Xcode Command Line Tools
# macOS
./scripts/download-deps-macos.sh
# Windows (PowerShell)
.\scripts\download-deps-windows.ps1cd worker
cargo build --releaseWindows:
cd app && flutter pub get && flutter build windows --releasemacOS:
Important: The macOS build requires arm64-only targeting. Standard
flutter build macosmay fail with "Unable to find module dependency" errors — use the manual xcodebuild process if so.
cd app && flutter pub get && flutter build macos --releaseManual xcodebuild (if flutter build fails):
cd app/macos
rm -rf Pods Podfile.lock && pod install
xcodebuild -workspace Runner.xcworkspace -scheme Pods-Runner -configuration Release build ONLY_ACTIVE_ARCH=NO
xcodebuild -workspace Runner.xcworkspace -scheme Runner -configuration Release build ARCHS=arm64 ONLY_ACTIVE_ARCH=YES
mkdir -p ../build/macos/Build/Products/Release
cp -R ~/Library/Developer/Xcode/DerivedData/Runner-*/Build/Products/Release/vapourbox.app ../build/macos/Build/Products/Release/cd app
dart run build_runner build| Build | Command | Dependency Search | Use Case |
|---|---|---|---|
| Debug | cargo build |
Searches upward from executable for deps/ |
Development |
| Release | cargo build --release |
Only checks known production paths | Production |
IMPORTANT: During development, always use debug builds of the worker. The upward search is restricted to debug builds for security — a malicious deps/ folder in a parent directory could inject compromised binaries in release mode.
macOS: Always use the debug script:
./Scripts/run-debug-macos.sh # Full build (worker + app) and launch
./Scripts/run-debug-macos.sh --skip-worker # Rebuild app only
./Scripts/run-debug-macos.sh --skip-app # Rebuild worker only
./Scripts/run-debug-macos.sh --run-only # Just copy and launchWindows (manual):
- Build worker:
cd worker && cargo build - Copy worker:
cp worker/target/debug/vapourbox-worker.exe app/build/windows/x64/runner/Debug/ - Run app:
cd app && flutter run
IMPORTANT: Never manually assemble a release build. Always use packaging scripts — the app will silently crash on video drop if the bundle is incomplete.
# macOS
./Scripts/package-macos.sh --version X.Y.Z [--skip-build]
# Windows
.\Scripts\package-windows.ps1 -Version "X.Y.Z" [-SkipBuild]
# Test packaged app from terminal to see errors:
dist/VapourBox.app/Contents/MacOS/vapourboxDo NOT run app/build/macos/Build/Products/Release/vapourbox.app directly — it lacks templates and proper bundle structure.
Adding a filter touches many files. Missing any step causes silent failures (filter not appearing, parameters not saved, preview errors). Follow this checklist completely:
1. Filter Schema & Registration
- Create JSON file in
app/assets/filters/core/<filter_id>.json - Add to
app/assets/filters/manifest.json— filters won't load without this - See existing filters in
app/assets/filters/core/for schema reference
2. Rust Model & Pipeline
- Create
worker/src/models/<filter>_parameters.rswith serderename_all = "camelCase" - Register in
worker/src/models/mod.rs(bothmodandpub use) - Add field to
ProcessingPipelinestruct inworker/src/models/processing_pipeline.rs - Add to
PassTypeenum +display_name()+description()+Default+from_legacy()+enabled_passes()+enabled_pass_count()+is_pass_enabled()
3. VapourSynth Templates & Script Generator
- Add template block to
worker/templates/pipeline_template.vpy - Add template block to
worker/templates/preview_template.vpy - Add pass handling in
worker/src/script_generator.rs
4. Dart Model & Pipeline
- Create
app/lib/models/<filter>_parameters.dartwith@JsonSerializable() - Run
dart run build_runner buildto generate.g.dart - Add to
app/lib/models/processing_pipeline.dart: import,PassTypeenum,displayName,description, field, constructor,fromLegacy,enabledPasses,enabledPassCount,videoPassCount,isPassEnabled,getPassSummary,copyWith,togglePass
5. Parameter Converter (ALL THREE locations)
- Add
fromX()andtoX()inapp/lib/models/parameter_converter.dart - Add to
fromPipeline()map - Add to
toPipeline()construction
6. UI Wiring (ALL FOUR locations — missing any causes silent failures)
app/lib/views/pass_list/pass_list_panel.dart— addPassListItementryapp/lib/views/pass_list/pass_list_item.dart— add icon in_getIconForPass()app/lib/views/pass_settings/pass_settings_container.dart— add case in_getFilterId()app/lib/viewmodels/main_viewmodel.dart— add case in BOTH_convertToParams()AND_updatePipelineFromDynamic()
7. Optional Parameter Defaults
- If parameters should default to OFF (not passed to VapourSynth), the
fromX()converter must put values inlastOptionalValuesinstead ofvalues. Otherwise the UI shows all optional params as enabled. - Example:
DynamicParameters(filterId: 'x', enabled: true, values: {}, lastOptionalValues: {'param1': 5})
8. VapourSynth Compatibility Guards
- If the plugin only supports 8-bit, add bit-depth conversion guard in templates
- If the plugin fails on field-based clips, note this — the preview template conditionally sets field-based via
{{#SET_FIELD_BASED}}(only when deinterlacing is enabled)
9. Integration Test — required (see below)
10. Plugin Binaries
- Windows: add to
deps/windows-x64/vapoursynth/vs-plugins/ - macOS: compile for arm64, codesign, add to
deps/macos-arm64/vapoursynth/plugins/ - Also copy to installed deps at
~/Library/Application Support/VapourBox/deps/for testing
- Edit
app/lib/services/preset_service.dart - Add new
ProcessingPresetin_createBuiltInPresets() - Configure
pipelinewith desired filter settings, setisBuiltIn: true
- Add to
worker/src/models/qtgmc_parameters.rs(with serde attributes) - Add to
app/lib/models/qtgmc_parameters.dart(with json_annotation) - Add to
worker/templates/pipeline_template.vpyusing{{#PARAM}}...{{/PARAM}}syntax - Add to
worker/src/script_generator.rssubstitution logic - Add UI control in Flutter settings view
- Add integration test in
worker/tests/filter_integration_test.rs(see below)
Every filter addition or parameter change must include an integration test in worker/tests/filter_integration_test.rs. Tests are numbered sequentially (e.g., test_50_chroma_shift).
Two test patterns are used:
-
run_job— verifies the full pipeline compiles and generates a valid script:#[test] fn test_50_chroma_shift() { create_output_dir(); let mut job = create_base_job("test_50_chroma_shift"); job.qtgmc_parameters.enabled = true; job.qtgmc_parameters.preset = QTGMCPreset::Fast; job.qtgmc_parameters.tff = Some(true); job.processing_pipeline = Some(ProcessingPipeline { deinterlace: job.qtgmc_parameters.clone(), chroma_fixes: ChromaFixParameters { enabled: true, apply_chroma_shift: true, chroma_shift_h: 2.5, ..ChromaFixParameters::default() }, ..ProcessingPipeline::default() }); run_job(&job, "Chroma Shift").unwrap(); }
-
run_job_and_verify— additionally checks the generated.vpyscript contains expected strings:run_job_and_verify(&job, "Chroma Shift", &[ "ShufflePlanes", "resize.Spline36", "2.5", ]).unwrap();
Use run_job_and_verify when you want to confirm specific VapourSynth function calls or parameter values appear in the generated script.
- Update
WorkerMessageenum in bothworker/src/models/progress_info.rsandapp/lib/models/progress_info.dart - Update JSON serialization to match
- Update
ProgressReporter(Rust) andWorkerManager(Dart)
Filters are defined as JSON schemas in app/assets/filters/core/ (built-in) or ~/.vapourbox/filters/ (user). See existing filters for full examples. Key structure:
{
"$schema": "https://vapourbox.app/schemas/filter-v1.json",
"id": "my_filter",
"version": "1.0.0",
"name": "My Filter",
"description": "What this filter does",
"category": "cleanup|enhancement|color|custom",
"methods": [{ "id": "...", "name": "...", "function": "...", "parameters": [...] }],
"parameters": {
"enabled": { "type": "boolean", "default": false, "ui": { "hidden": true } },
"param1": {
"type": "number", "default": 1.0, "min": 0.0, "max": 10.0, "step": 0.1,
"optional": true,
"vapoursynth": { "name": "vs_param_name" },
"ui": { "label": "...", "description": "...", "widget": "slider", "precision": 1 }
}
},
"ui": { "sections": [{ "title": "Settings", "parameters": ["param1"], "expanded": true }] }
}Parameter types: boolean, integer, number, string, enum (with options array).
Widgets: slider, dropdown, checkbox, textfield, number.
optional: true: Shows enable checkbox; when disabled, parameter is omitted (uses VS default).
visibleWhen: Conditional visibility, e.g. { "method": ["method_a"] }.
Presets save complete filter pipeline + encoding settings. Built-in presets (Fast, Balanced, High Quality, VHS Cleanup) are in PresetService._createBuiltInPresets(). User presets save to ~/.vapourbox/presets/*.json.
The most important parameters:
- Preset: Master setting (Placebo → Draft) that sets defaults
- TFF: Top-field-first (true) or bottom-field-first (false)
- TR0/TR1/TR2: Temporal radius settings controlling smoothing
- EdiMode: Interpolation method (NNEDI3, EEDI3+NNEDI3, etc.)
- SourceMatch: Higher fidelity mode (0=off, 1-3=increasingly accurate)
- FPSDivisor: 1=double-rate (50i→50p), 2=single-rate (50i→25p)
# Rust tests
cd worker && cargo test
# Flutter tests
cd app && flutter test
# Test worker standalone
cd worker && cargo run --release -- --config test_job.json- Use
anyhowfor error handling - Use
serdewithrename_all = "camelCase"for JSON compatibility - Platform-specific code in
platform/module with#[cfg]
- Provider for state management
json_annotation+json_serializablefor models- MVVM pattern (models, viewmodels, views, services)
The download-deps-windows.ps1 and download-deps-macos.sh scripts apply these automatically. Three patches are needed for modern VapourSynth:
- mvtools API: Renamed
_lambda→lambda,_global→globalparameters - DFTTest API:
sstringparameter removed, replaced withsigma=10.0 - VapourSynth YCOCG removal:
vs.YCOCGremoved, simplify to!= vs.YUV
- Worker crashes: Run worker standalone with
--configto isolate - JSON mismatch: Compare Rust and Dart model serialization
- Plugin load failures: Check environment variables in
DependencyLocator - Encoding fails: Run generated .vpy script manually with vspipe
- Template not found: Check search paths in
script_generator.rs - Filter not appearing: Check JSON syntax, verify
idis unique - macOS build fails with "Unable to find module dependency": Build Pods-Runner scheme first, then Runner for arm64 only (see Build Commands)
- App crashes silently on video drop (release build): Bundle is incomplete. Use packaging scripts. Run from terminal to see errors.
- VapourSynth R73 portable uses Python 3.8 (not 3.11+)
- Worker sets env vars via
DependencyLocator:PYTHONHOME,PYTHONPATH,VAPOURSYNTH_PLUGIN_PATH,PATH - Plugins in
deps/windows-x64/vapoursynth/vs-plugins/, Python packages inLib/site-packages/ - Show in Folder:
cmd /c explorer /select, <path>
- Fully self-contained deps (no Homebrew): Python 3.12 (python-build-standalone), VS built from source
- Worker sets:
PYTHONHOME,PYTHONPATH,VAPOURSYNTH_CONF_PATH,DYLD_LIBRARY_PATH vspipeis a wrapper script that generates config dynamically (needed becauseVAPOURSYNTH_PLUGIN_PATHis additive, not a replacement)- Code signing: After
install_name_toolmodifications, binaries must be re-signed:codesign -s - -f <binary>(exit code 137 = SIGKILL means invalid signature) - Quarantine removal:
xattr -cron deps after download - Show in Folder:
open -R <path>
Plugin lists for both platforms: see deps/ directories or download scripts.
Dependencies are versioned separately from the app via app/assets/deps-version.json and distributed as separate GitHub releases (tag: deps-vX.Y.Z). The app auto-downloads deps on launch if missing or outdated.
Key files: app/assets/deps-version.json (expected version), app/lib/services/dependency_manager.dart (check/download/extract), app/lib/views/dependency_download_dialog.dart (progress UI).
App and deps use separate release tags so unchanged deps aren't re-uploaded on every app release. Download URLs are constructed from releaseTag in deps-version.json.
./Scripts/release.sh [--skip-deps-check] [--skip-build] [--dry-run]This prompts for version, checks deps changes, builds, packages, and creates draft GitHub releases.
# Full flow: trigger CI builds, wait, download artifacts, upload to draft release
./Scripts/ci-build-and-release.sh --version 0.7.0 [--deps-tag deps-v1.1.0] [--arch both]
# Just download latest artifacts (skip triggering new builds)
./Scripts/ci-build-and-release.sh --version 0.7.0 --skip-triggerPrerequisites: draft release must exist for the tag, gh CLI authenticated.
| Script | Purpose |
|---|---|
Scripts/release.sh |
Main orchestrator |
Scripts/ci-build-and-release.sh |
Trigger CI, download artifacts, upload to draft |
Scripts/get-github-version.sh |
Fetch versions from GitHub (--app, --deps, --next-app, --next-deps) |
Scripts/update-version.sh |
Update version across all files (--app X.Y.Z --deps X.Y.Z --deps-tag deps-vX.Y.Z) |
Scripts/check-deps-changed.sh |
Detect if deps changed since last release (exit 0=changed, 1=unchanged) |
Scripts/package-macos.sh |
Package macOS app |
Scripts/package-windows.ps1 |
Package Windows app |
Scripts/package-deps-macos.sh |
Package macOS deps |
Scripts/package-deps-windows.ps1 |
Package Windows deps |
- Confirm version — ask user, update
pubspec.yaml - Check deps — run
check-deps-changed.sh; if changed, bump version indeps-version.json - Build & package — use packaging scripts (or
release.shfor full automation) - Update
app/assets/deps-version.json— after packaging each platform's deps zip, update itssha256andsizefields with the values printed by the packaging script. Both platforms must have valid (non-null) sha256 and size before release. The app uses these values to verify downloaded deps at runtime. - Test — fresh install + upgrade test
- Create GitHub releases — deps release first (if changed, tag
deps-vX.Y.Z), then app release (tagvX.Y.Z)
- Flutter Windows can't build on macOS — use GitHub Actions
- Windows deps can be zipped on macOS if
deps/windows-x64/exists - CI builds are ad-hoc signed; sign locally for distribution
| Deps Version | Date | Changes |
|---|---|---|
| 1.0.0 | 2025-01-15 | Initial release |