Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ VapourBox/
| `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/lib/services/temp_directory_service.dart` | Configurable scratch-file directory (see "Temporary Files Directory") |
| `app/assets/filters/core/*.json` | Built-in filter schema definitions |

## Build Commands
Expand Down Expand Up @@ -429,6 +430,36 @@ Filters are defined as JSON schemas in `app/assets/filters/core/` (built-in) or
**`optional: true`**: Shows enable checkbox; when disabled, parameter is omitted (uses VS default).
**`visibleWhen`**: Conditional visibility, e.g. `{ "method": ["method_a"] }`.

### Temporary Files Directory

Scratch files default to the system temp directory, and the user can redirect
them in **Settings → General → Temporary Files** (✕ resets to the default). The
choice is persisted in shared_preferences under `tempDirectoryOverride` and
loaded by `TempDirectoryService.initialize()` in `main()`, before the first-run
dependency download — the earliest thing that writes a temp file.

Two mechanisms cover the two processes, and both matter:

- **Dart** call sites use `TempDirectoryService.instance` (`resolve()`,
`filePath()`, `createTemp()`). **Don't add new `Directory.systemTemp` uses** —
they would ignore the setting. Current users: worker job config
(`worker_manager`), preview frames/config (`preview_generator`), DVD
extraction (`main_viewmodel`), deps and whisper downloads.
- **Rust** needs no per-path plumbing: `ToolLocator.workerEnvironment` sets
`TMPDIR` (Unix) and `TMP`/`TEMP` (Windows) from the effective path, so every
`env::temp_dir()` in the worker — generated `.vpy` scripts, progress files,
preview raw frames, OpenCL/KNLM probes, the macOS vspipe conf — follows it,
as do the ffmpeg and vspipe children. Those vars are applied per call rather
than baked into the cached env map, so a change takes effect immediately.
**Any new worker/tool spawn must pass `workerEnvironment`** or it silently
reverts to the system temp directory.

`resolve()` recreates the directory if missing and falls back to system temp if
it can't (external drive unplugged) — a job in the wrong temp directory beats a
job that can't run. `setOverride` verifies writability by writing a probe file,
so a read-only volume is rejected at selection time rather than at job time.
Covered by `app/test/temp_directory_service_test.dart`.

### Preset System

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`.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ Unencrypted DVDs (home recordings, some independent releases) work without libdv
- **Built-in**: Fast, Balanced, High Quality, VHS Cleanup
- **Save** your current settings for reuse across sessions

### Temporary Files

VapourBox writes its scratch files — generated VapourSynth scripts, preview
frames, job files and extracted DVD titles — to the system temp directory.
DVD extraction in particular can need several GB, so if your system temp lives
on a small or slow volume you can redirect it:

- **Settings → General → Temporary Files** → choose a directory
- Click the **✕** next to the path to reset it back to the system default

The setting is remembered between sessions and applies to the next job.

## Building from Source

See [docs/BUILDING.md](docs/BUILDING.md) for build instructions, project structure, and development workflow.
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/chroma_fixes.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Chroma Fixes",
"description": "Fix chroma bleeding, rainbows, and dot crawl",
"longDescription": "Repairs colour-specific damage from analog and composite video: chroma shifted sideways from the luma it belongs to, colour bleeding past edges, rainbow shimmer over fine patterns, and dots crawling along edges.\n\nUse it for VHS, Video8 and other composite captures. Each fix is enabled separately, so turn on only the ones matching what you can actually see in the preview — every one of them costs some colour detail.",
"category": "cleanup",
"icon": "palette",
"order": 8,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/color_correction.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Color Correction",
"description": "Adjust brightness, contrast, saturation and levels",
"longDescription": "Adjusts brightness, contrast, saturation and the black and white levels.\n\nUse it to rescue washed-out or crushed transfers, to fix a capture made at the wrong levels (limited 16-235 read as full 0-255, or the reverse), or to lift colour from faded film. Small moves go a long way — check the before/after preview rather than judging by numbers.",
"category": "color",
"icon": "tune",
"order": 7,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/crop_resize.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Crop & Resize",
"description": "Crop borders and resize or upscale video",
"longDescription": "Trims unwanted borders and changes the output resolution. Cropping happens first, then resizing.\n\nUse crop to cut the head-switching noise along the bottom of VHS captures and the black overscan edges of broadcast material — otherwise the encoder spends bitrate on them. Use resize for a target resolution, or the NNEDI3 upscaler for a much better 2x/4x enlargement than a plain kernel. Keep crop values even so they stay aligned with chroma subsampling.",
"category": "transform",
"icon": "crop",
"order": 9,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/deband.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Deband",
"description": "Remove banding artifacts using f3kdb",
"longDescription": "Replaces the visible stair-steps in smooth gradients — skies, fades, studio backdrops — with a smooth ramp, adding a little grain to disguise the transition.\n\nUse it when an 8-bit source shows distinct bands, which often becomes obvious only after denoising has removed the grain that was hiding them. Too high a threshold blurs genuine low-contrast detail, so raise it gradually.",
"category": "cleanup",
"icon": "gradient",
"order": 5,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/deblock.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Deblock",
"description": "Remove blocking artifacts from compressed video",
"longDescription": "Smooths the square 8x8 block edges that low-bitrate compression leaves behind, without flattening the detail inside each block.\n\nUse it on heavily compressed MPEG sources — old DVDs, VCD, downloaded or streamed footage — especially in dark or flat areas where blocking shows most. Run it before sharpening, which would otherwise make the block edges crisper rather than softer.",
"category": "cleanup",
"icon": "grid_off",
"order": 3,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/dehalo.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Dehalo",
"description": "Remove halo artifacts around edges",
"longDescription": "Removes the bright outline that sits alongside high-contrast edges — the ringing left by over-sharpening, upscaling or heavy compression.\n\nUse it when edges look traced with a light pen: common on VHS run through a sharpening time-base corrector, and on upscaled or hard-compressed material. Apply it after deinterlacing and denoising. Too much strength eats the fine detail right next to the edge.",
"category": "cleanup",
"icon": "blur_off",
"order": 4,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/deinterlace.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Deinterlace",
"description": "QTGMC deinterlacing or IVTC inverse telecine",
"longDescription": "Turns interlaced video — where each frame holds two half-height fields captured at different moments — into whole progressive frames, or removes the pulldown pattern from film that was telecined to video. QTGMC rebuilds every field into a full frame for the smoothest motion; IVTC instead recovers the original film frames and drops the duplicates.\n\nUse it on anything from tape or broadcast — VHS, Video8, DV, DVD — where moving edges show comb teeth. Leave it off for footage that is already progressive. Run it first: every other filter works better on whole frames.",
"category": "deinterlace",
"icon": "layers",
"order": 1,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/descratch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "DeScratch",
"description": "Remove vertical scratches from scanned film",
"longDescription": "Finds narrow vertical lines that run down the picture — the bright or dark scratches left by grit in a projector or scanner gate — and fills them in from the pixels either side.\n\nUse it on scanned film and telecine transfers. It has nothing to fix on tape sources, and genuine thin vertical detail (railings, masts, poles) can be mistaken for a scratch, so check the preview before committing.",
"category": "cleanup",
"icon": "healing",
"order": 2,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/noise_reduction.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Noise Reduction",
"description": "Remove video noise and grain",
"longDescription": "Reduces grain, analog noise and colour speckle while trying to keep real detail. Temporal denoisers average across neighbouring frames and are best for steady grain; spatial ones smooth within a single frame and cope better with motion.\n\nUse it on VHS, Hi8 and other noisy captures, and before sharpening or encoding — clean footage compresses far better at the same bitrate. Too much strength smears motion and flattens texture into plastic.",
"category": "cleanup",
"icon": "grain",
"order": 2,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/sharpen.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Sharpen",
"description": "Sharpen video edges and detail",
"longDescription": "Raises contrast right at edges so a soft picture reads as crisper. It cannot add detail that was never captured — it only makes what is there more visible.\n\nUse it sparingly on soft tape and DVD sources, and always after deinterlacing and denoising: sharpening first amplifies noise and creates halos. If edges start to glow, back off or pair it with Dehalo.",
"category": "enhancement",
"icon": "auto_fix_high",
"order": 6,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/spotless.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "SpotLess",
"description": "Remove dust, dirt, and temporal spots from film",
"longDescription": "Removes single-frame blemishes: dust specks, hairs and emulsion flecks that flash up for one frame and vanish. It compares each frame against its neighbours and replaces anything that is present in only one of them.\n\nUse it on scanned or telecined film, after deinterlacing. Because the test is temporal, fast or erratic motion can be read as a spot — if you see smearing or ghosting on movement, ease off the strength.",
"category": "cleanup",
"icon": "auto_fix_high",
"order": 3,
Expand Down
1 change: 1 addition & 0 deletions app/assets/filters/core/subtitles.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "1.0.0",
"name": "Subtitles",
"description": "Generate subtitles from speech using Whisper AI",
"longDescription": "Transcribes the spoken audio with the Whisper speech-recognition model and writes it out as a subtitle track alongside the video.\n\nUse it to caption footage that has no subtitles of its own — home video, interviews, lectures. Larger models are more accurate but considerably slower, and accuracy falls away with heavy background noise or overlapping speakers. This pass runs after the encode and never alters the picture.",
"category": "enhancement",
"icon": "subtitles",
"order": 100,
Expand Down
10 changes: 9 additions & 1 deletion app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'models/filter_registry.dart';
import 'services/dependency_manager.dart';
import 'services/hardware_encoder_detector.dart';
import 'services/preset_service.dart';
import 'services/temp_directory_service.dart';
import 'services/tool_locator.dart';
import 'services/update_checker.dart';
import 'viewmodels/main_viewmodel.dart';
Expand All @@ -20,14 +21,21 @@ void main() async {
// Initialize rhttp (required for Rust FFI on Windows)
await Rhttp.init();

// Load the temp directory override before anything writes a scratch file
// (the dependency download on first run is the earliest of them).
await TempDirectoryService.instance.initialize();

// Initialize window manager for desktop
await windowManager.ensureInitialized();

// No backgroundColor: a transparent one makes window_manager set the native
// window non-opaque, and since the Flutter view only covers the content area
// that leaves the macOS title bar see-through — a floating title and traffic
// lights over the desktop. The platform default follows light/dark mode.
const windowOptions = WindowOptions(
size: Size(900, 700),
minimumSize: Size(700, 550),
center: true,
backgroundColor: Colors.transparent,
skipTaskbar: false,
titleBarStyle: TitleBarStyle.normal,
title: 'VapourBox',
Expand Down
8 changes: 7 additions & 1 deletion app/lib/models/filter_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,14 @@ class FilterSchema {
/// Display name.
final String name;

/// Description of what this filter does.
/// Short one-line description of what this filter does.
final String? description;

/// Longer prose shown above the filter's options: what the filter does and
/// when you'd reach for it. Paragraphs are separated by a blank line.
/// Falls back to [description] in the UI when absent.
final String? longDescription;

/// Category for grouping (e.g., "cleanup", "enhancement").
final String? category;

Expand Down Expand Up @@ -394,6 +399,7 @@ class FilterSchema {
required this.version,
required this.name,
this.description,
this.longDescription,
this.category,
this.icon,
this.order = 0,
Expand Down
5 changes: 4 additions & 1 deletion app/lib/services/dependency_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import 'package:flutter/services.dart';
import 'package:rhttp/rhttp.dart';
import 'package:path/path.dart' as path;

import 'temp_directory_service.dart';

/// Status of the dependency installation.
enum DependencyStatus {
/// Dependencies are installed and up-to-date
Expand Down Expand Up @@ -418,7 +420,8 @@ class DependencyManager {
await _fetchExpectedSha256(expected.getManifestUrl(platformId));

// Create temp file for download
final tempDir = await Directory.systemTemp.createTemp('vapourbox_deps_');
final tempDir =
await TempDirectoryService.instance.createTemp('vapourbox_deps_');
final tempFile = File(path.join(tempDir.path, filename));

try {
Expand Down
6 changes: 4 additions & 2 deletions app/lib/services/preview_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '../models/encoding_settings.dart';
import '../models/processing_pipeline.dart';
import '../models/video_job.dart';
import 'field_order_detector.dart';
import 'temp_directory_service.dart';
import 'tool_locator.dart';

/// Service for generating video thumbnails and processed previews.
Expand Down Expand Up @@ -71,8 +72,9 @@ class PreviewGenerator {
_workerPath = toolLocator.workerPath;

// Create temp directory for thumbnails and previews
final systemTemp = Directory.systemTemp;
_tempDir = '${systemTemp.path}/vapourbox_preview_${DateTime.now().millisecondsSinceEpoch}';
final tempRoot = await TempDirectoryService.instance.resolve();
_tempDir =
'${tempRoot.path}/vapourbox_preview_${DateTime.now().millisecondsSinceEpoch}';
await Directory(_tempDir!).create(recursive: true);
}

Expand Down
122 changes: 122 additions & 0 deletions app/lib/services/temp_directory_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import 'dart:io';

import 'package:shared_preferences/shared_preferences.dart';

/// Where VapourBox puts its scratch files — generated `.vpy` scripts, job
/// config JSON, preview frames, progress files and extracted DVD titles.
///
/// The system temp directory is the default. A user whose system temp lives on
/// a small or slow volume (or a RAM disk that can't hold a DVD rip) can point
/// this elsewhere; [setOverride] with null goes back to the system default.
///
/// The worker and the tools it spawns pick this up through `TMPDIR`/`TMP`/
/// `TEMP`, which [ToolLocator.workerEnvironment] sets from [effectivePath] —
/// that's what redirects the Rust side's `env::temp_dir()` calls, so there is
/// no separate path to keep in sync.
class TempDirectoryService {
static final TempDirectoryService instance = TempDirectoryService._();
TempDirectoryService._();

static const String _prefsKey = 'tempDirectoryOverride';

String? _override;
bool _loaded = false;

/// The user's chosen directory, or null when using the system default.
String? get override => _override;

/// The system temp directory, shown in the UI as the default.
String get systemDefault => Directory.systemTemp.path;

/// Directory scratch files should go in: the override when set, otherwise
/// the system temp directory.
String get effectivePath => _override ?? systemDefault;

/// Load the saved override. Call once at startup, before anything writes a
/// temp file. Safe to call again; only the first call reads storage.
Future<void> initialize() async {
if (_loaded) return;
try {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(_prefsKey);
_override = (stored != null && stored.isNotEmpty) ? stored : null;
} catch (_) {
// Unreadable preferences shouldn't stop the app starting — the system
// default is a working fallback.
_override = null;
}
_loaded = true;
}

/// Set the override directory, or pass null to go back to the system
/// default. Throws if the directory can't be created or written to, so the
/// caller can report it rather than storing a path that fails at job time.
Future<void> setOverride(String? directory) async {
final trimmed = directory?.trim();
final value = (trimmed == null || trimmed.isEmpty) ? null : trimmed;

if (value != null) {
await _verifyWritable(Directory(value));
}

_override = value;
_loaded = true;

try {
final prefs = await SharedPreferences.getInstance();
if (value == null) {
await prefs.remove(_prefsKey);
} else {
await prefs.setString(_prefsKey, value);
}
} catch (_) {
// Keep the in-memory choice for this session even if it can't be saved.
}
}

/// The directory to write into, created if missing.
///
/// Falls back to the system temp directory if the override has become
/// unusable — an external drive unplugged since it was chosen, say. A job
/// running in the wrong temp directory beats a job that can't run at all.
Future<Directory> resolve() async {
final chosen = Directory(effectivePath);
try {
if (!await chosen.exists()) {
await chosen.create(recursive: true);
}
return chosen;
} catch (_) {
return Directory.systemTemp;
}
}

/// Creates a uniquely-named directory under [resolve], like
/// `Directory.systemTemp.createTemp`.
Future<Directory> createTemp(String prefix) async {
final parent = await resolve();
return parent.createTemp(prefix);
}

/// Builds a path for a scratch file inside the temp directory, creating the
/// directory if needed.
Future<String> filePath(String filename) async {
final dir = await resolve();
return '${dir.path}${Platform.pathSeparator}$filename';
}

/// Throws if [dir] can't be created or written to.
Future<void> _verifyWritable(Directory dir) async {
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Existence isn't enough — a read-only volume passes that and then fails
// at job time, which is far harder to diagnose.
final probe = File(
'${dir.path}${Platform.pathSeparator}.vapourbox_write_test_'
'${DateTime.now().microsecondsSinceEpoch}',
);
await probe.writeAsString('');
await probe.delete();
}
}
Loading
Loading