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
100 changes: 100 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ VapourBox/
| `worker/src/script_generator.rs` | Template substitution for .vpy |
| `worker/src/pipeline_executor.rs` | vspipe \| ffmpeg execution |
| `worker/src/pixel_format.rs` | Source `pix_fmt` → pipe format (see "Source Pixel Formats") |
| `worker/templates/stabilize.py` | Stabilize pass's vertical pitch correction (see "Stabilize") |
| `worker/src/cpu.rs` | The x86 deps CPU tier (`v3`/`v2`) — the single decision; see "CPU tiers" |
| `worker/src/source_decode.rs` | Decoder ffmpeg args for encode + preview: full stored frame, size-guarded (see "Source Frame Size") |
| `worker/src/source_timing.rs` | A variable-frame-rate source's stored frames placed on the constant-rate grid (see "Variable Frame Rate Sources") |
Expand Down Expand Up @@ -146,6 +147,7 @@ VapourBox/
| `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/lib/services/pitch_factor_file.dart` | Per-video pitch factor files: sidecar lookup + summary (see "Stabilize") |
| `app/lib/services/overwrite_behavior_service.dart` | Default action for existing output files (see "Existing Output Files") |
| `app/lib/models/native_res_report.dart` | What `--detect-native` found, as the Descale panel's Detect shows and applies it |
| `app/lib/models/crop_detect_report.dart` | What `--detect-crop` found, and which suggestion (`CropApply`) the panel applies |
Expand Down Expand Up @@ -835,6 +837,38 @@ Three things decide the shape of the output, and they live in different places:
else so a typo falls back to the source's own aspect rather than reaching ffmpeg
as a broken filter argument.

**The preview has to show the same shape (issue #149).** The preview PNG carries
no aspect, so the app used to draw the processed frame at the **source's**
display aspect. Anything a pass reshaped (a 720x576 4:3 source resized and
padded to 1920x1080, say) came out squashed. Three invariants now hold:

- **One decision, two consumers.** `ProcessingPipeline::output_aspect(input_sar)`
(rotation-adjusted SAR → `aspect_declaration`) is used by the encode's
`setsar`/`setdar` **and** by the preview. The preview reports it on its
`PREVIEW_INFO:` stderr line as `sar=N/D` or `dar=N/D` (nothing for square
pixels). `PreviewFrameInfo.displayAspectFor` turns that plus the PNG's size into
the shape to draw.
- **Each side has its own aspect.** `BeforeAfterComparisonWidget` takes
`beforeAspectRatio` (source) and `afterAspectRatio` (processed). 1:1 is
measured against the processed one. The processed aspect is stored on the
`QueueItem` next to the image (`processedPreviewAspect`), not read from
`previewFrameInfo`, which goes null as soon as the scrubber moves.
- **The preview job must carry `inputSar`.** `{{SOURCE_SAR}}` falls back to 1
without it, so square-pixel fitting previewed a different frame from the
one encoded (1350 vs 1440 wide in the #149 setup). `PreviewGenerator` reads it
through the same `FieldOrderDetector.normalizeSar` as the encode job.

The panel reads both sides' aspects from the view model
(`sourceDisplayAspect`, `comparisonAfterAspect`), not from its own arithmetic,
so the wiring is testable without a worker (`debugAcceptProcessedPreview`).

Covered by `test_185` (Rust), `preview_frame_info_test`, `preview_aspect_test`
(including the view-model wiring: an unreshaped anamorphic source keeps its
shape, the aspect survives a scrubber move), `before_after_comparison_test`, and
the heavy `preview aspect (issue #149)` group in `integration_aspect_ratio_test.dart`,
which compares the preview with the encode for square, preserve, forced DAR,
denoise-only anamorphic and rotated anamorphic jobs.

### Borders (issue #86)

Two ways to add bars, sharing one step at the **very end of both templates**
Expand Down Expand Up @@ -1099,6 +1133,72 @@ MB, null = off). `EncodingSettings::effective_max_cache_size_mb` treats 0 as
off. `test_162` asserts both scripts. Don't reintroduce a hard-coded value —
SD never reaches the cap, so a too-low one passes every SD test.

### Stabilize: weave and pitch (issue #150)

One pass, two independent corrections, weave first. It runs **last before
framing** (after Colour Correction, before Rotate / Flip and Crop & Resize), so
a crop can remove the edges both corrections expose.

- **Weave / shake** — havsfunc `Stab(clp, dxmax, dymax, mirror)`
(`mv.DepanEstimate` / `DepanCompensate` against a 15-frame average), called
straight from both templates. On by default (`weave: true`).
- **Vertical pitch ("frame height breathing")** — pull-through film scanners
let the film slip in the gate, so each frame is a fraction of a percent
taller or shorter than the last. `worker/templates/stabilize.py`
(`correct_pitch`, imported by **both** templates so preview and encode can't
drift) resamples each frame about its centre at a **constant frame size**
(`src_top`/`src_height`), sub-pixel. Rounding the height to even pixels and
padding back, as the issue proposed, makes any correction under ~0.19% at
1080 lines a no-op — i.e. all real breathing. **Off by default.**
- `auto`: the relative scale + shift between **adjacent** frames is measured
by grid search on a narrow luma strip, then each frame is corrected to the
mean of its ±radius window. Comparing against a temporal *average* was
clearly less accurate: motion and the breathing itself blur the average.
Pairs whose best match still differs by > 3/255 are treated as cuts.
Stateless per frame, so a preview measures exactly what the encode does.
- `file`: one factor per **source** frame. The factor is the *measured*
height ratio (1.004 = 0.4% too tall); `invertFactors` accepts the other
convention.

Invariants that are easy to break:

- **A pipeline saved before pitch existed must produce the same script.** Old
JSON has only `enabled/dxmax/dymax/mirror`; it deserialises to weave on,
pitch off, and the generator emits the Stab block byte-for-byte as before
(the inner `{{#STAB_WEAVE}}`/`{{#STAB_PITCH}}` tags go with their whole
lines — `keep_block_lines`). `test_212` pins it.
- **The pass's temporal reach is declared.** `Stab` reads n-8…n+9 (measured
under vspipe: the ±7 average, its scene-change look-ahead, and Depan's
interleaved pair), auto pitch another radius+1. `StabilizeParameters::radius`
feeds the preview window and `pipe_cache_frames`; it used to be treated as
spatial, which starved a windowed preview.
- **Every per-frame lookup must add the clip's source-frame offset.** Frame `n`
in the script is not source frame `n`: the preview decodes a window starting
at `first` (`PreviewParams::source_frame_offset`), a trimmed encode starts at
`start_frame`, and double-rate deinterlacing or IVTC before the pass changes
the count (`ProcessingPipeline::rate_before_stabilize`, composed from the
`FrameMap`s of the passes ahead of it). Lose any of these and every frame
gets another frame's factor, with no error.
- **The factor file is per job, never per pipeline.** `VideoJob.pitchFactorsPath`
(Rust and Dart), set from `QueueItem.pitchFactorsPath`. Pipelines are saved
into presets shared by every video in the queue. `<video>.pitch.txt` (also
`<stem>.pitch.txt`, `<stem>_pitch.txt`) is picked up when a video is added.
- **File mode without a usable file is an error**, raised by the script
generator with the bad line's number — not a silent pass-through.
- **Pitch on a still-interlaced clip clears `_FieldBased`** (a vertical resample
would mix the fields; film scans are progressive). Weave alone leaves it.
- **`pipe_source` evicts silently.** It keeps `cache_frames` decoded frames and
returns the *last* frame read for anything already evicted. The cache is
sized from the pipeline's declared radius (`pipe_cache_frames`, never below
the old 40), so a pass with a large window must report it through its
`FrameMap` radius.

Tests: `test_211`…`test_218` (script generation, legacy block, offsets, errors,
cache size, field marking) and `app/test/integration_stabilize_test.dart`
(heavy), which injects known breathing with the pass's own file mode and checks
that file mode, auto mode and the preview each bring the picture back toward
the undistorted original.

### Temporary Files Directory

Scratch files default to the system temp directory; the user can redirect them
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Most sources need only a few of these, and the presets pick them for you. Filter
| 14 | **Chroma Fixes** | Colour shifted off the picture, bleeding, rainbowing, dot crawl and leftover combing |
| 15 | **Colourisation** | Black-and-white footage, coloured by AI (DDColor or DeOldify Video, optional downloads); the brightness of every pixel is kept exactly |
| 16 | **Color Correction** | Brightness, contrast, saturation, levels, white balance, shadow detail and local contrast, set by hand or measured |
| 17 | **Stabilize** | Shake and weave from telecine, film scans or a handheld camera |
| 17 | **Stabilize** | Shake and weave from telecine, film scans or a handheld camera, and the frame-height "breathing" of pull-through film scanners (8mm, Super 8): measured from the footage, or read per frame from a factor file (`reel.pitch.txt` next to `reel.mp4` is picked up automatically) |
| 18 | **Rotate / Flip** | Sideways, upside-down or mirrored footage |
| 19 | **Crop & Resize** | Black borders, tape blanking and head-switching noise (measured for you by **Detect borders**), scaling and upscaling (edge-directed or AI) with SD colour converted to HD's BT.709, and borders to reach an exact frame size |
| 20 | **Face Restoration** | Blurry, low-resolution faces, redrawn by AI at the output resolution (optional download) |
Expand Down
130 changes: 123 additions & 7 deletions app/assets/filters/core/stabilize.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"$schema": "https://vapourbox.app/schemas/filter-v1.json",
"id": "stabilize",
"version": "1.0.0",
"version": "1.1.0",
"name": "Stabilize",
"description": "Remove shake and weave from the picture",
"longDescription": "Measures how much the whole frame moves between shots and cancels it, so a shaky capture holds still. Deliberate camera movement survives: only the difference from the recent trend is removed.\n\nUse it on telecine weave, film scans that wobble in the gate, and handheld camcorder footage. It runs last before cropping, because it shifts the picture within the frame and leaves thin empty edges — a small crop afterwards removes them.\n\nThe shift leaves thin empty edges; set Fill Exposed Edges to mirror them back in, or crop them away afterwards.",
"description": "Remove shake, gate weave and frame-height breathing",
"longDescription": "Two independent corrections.\n\nRemove Shake / Weave measures how much the whole frame moves and cancels it, so a shaky capture holds still. Deliberate camera movement survives: only the difference from the recent trend is removed. Use it on telecine weave, film scans that wobble in the gate, and handheld camcorder footage.\n\nPitch Correction removes \"breathing\" in scanned 8mm, Super 8 and 16mm film: on pull-through scanners the film slips in the gate, so each frame comes out a fraction of a percent taller or shorter than the last, which a position stabiliser can't fix. Auto measures it from the footage, comparing each frame with its neighbours, so slow intentional zooms and tilts are kept. From File uses one factor per frame from a text file (reel.pitch.txt next to the video is picked up automatically).\n\nThe pass runs last before cropping, because both corrections move picture within the frame and leave thin edges: fill them below, or crop them away afterwards.",
"category": "cleanup",
"icon": "stay_current_landscape",
"order": 11,
Expand Down Expand Up @@ -47,6 +47,14 @@
"hidden": true
}
},
"weave": {
"type": "boolean",
"default": true,
"ui": {
"label": "Remove Shake / Weave",
"description": "Steady frame-to-frame bounce, side-to-side drift and handheld shake"
}
},
"dxmax": {
"type": "integer",
"default": 4,
Expand All @@ -56,7 +64,10 @@
"ui": {
"label": "Max Horizontal Shift",
"description": "Largest sideways correction, in pixels. 0 leaves the horizontal axis alone",
"widget": "slider"
"widget": "slider",
"visibleWhen": {
"weave": true
}
}
},
"dymax": {
Expand All @@ -68,7 +79,10 @@
"ui": {
"label": "Max Vertical Shift",
"description": "Largest up/down correction, in pixels. 0 leaves the vertical axis alone",
"widget": "slider"
"widget": "slider",
"visibleWhen": {
"weave": true
}
}
},
"mirror": {
Expand All @@ -83,20 +97,122 @@
"ui": {
"label": "Fill Exposed Edges",
"description": "Shifting the picture leaves thin empty edges. 0 leaves them black, 1 fills top and bottom, 2 left and right, 3 all four — which saves cropping afterwards",
"widget": "dropdown"
"widget": "dropdown",
"visibleWhen": {
"weave": true
}
}
},
"pitchMode": {
"type": "enum",
"default": "off",
"options": [
"off",
"auto",
"file"
],
"ui": {
"label": "Pitch Correction",
"description": "Even out frame-height breathing from film slipping in a scanner's gate",
"widget": "dropdown",
"optionLabels": {
"off": "Off",
"auto": "Auto (measure from video)",
"file": "From File (per-frame factors)"
}
}
},
"maxPitchDeviation": {
"type": "number",
"default": 1.0,
"min": 0.1,
"max": 5.0,
"step": 0.1,
"ui": {
"label": "Max Pitch Correction (%)",
"description": "Largest height change applied to a frame; also limits values from a file. Auto measures more coarsely above about 1%, so keep it near the breathing you see",
"widget": "slider",
"precision": 1,
"visibleWhen": {
"pitchMode": [
"auto",
"file"
]
}
}
},
"smoothingRadius": {
"type": "integer",
"default": 10,
"min": 2,
"max": 30,
"step": 1,
"ui": {
"label": "Comparison Window",
"description": "Frames each side a frame is compared with. Larger smooths more but follows quick zooms less closely",
"widget": "slider",
"visibleWhen": {
"pitchMode": "auto"
}
}
},
"invertFactors": {
"type": "boolean",
"default": false,
"ui": {
"label": "Factors Are Corrections",
"description": "Tick if the file lists the stretch to apply, not the measured frame height",
"visibleWhen": {
"pitchMode": "file"
}
}
},
"pitchEdgeFill": {
"type": "enum",
"default": "extend",
"options": [
"extend",
"black"
],
"ui": {
"label": "Pitch Edge Fill",
"description": "What fills the rows a pitch correction moves in from outside the frame",
"widget": "dropdown",
"optionLabels": {
"extend": "Extend picture",
"black": "Black"
},
"visibleWhen": {
"pitchMode": [
"auto",
"file"
]
}
}
}
},
"ui": {
"sections": [
{
"title": "Settings",
"title": "Shake / Weave",
"parameters": [
"weave",
"dxmax",
"dymax",
"mirror"
],
"expanded": true
},
{
"title": "Pitch (Frame Height)",
"parameters": [
"pitchMode",
"maxPitchDeviation",
"smoothingRadius",
"invertFactors",
"pitchEdgeFill"
],
"expanded": true
}
]
},
Expand Down
12 changes: 12 additions & 0 deletions app/lib/models/parameter_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,15 @@ class ParameterConverter {
enabled: params.enabled,
values: {
'method': 'stab',
'weave': params.weave,
'dxmax': params.dxmax,
'dymax': params.dymax,
'mirror': params.mirror,
'pitchMode': params.pitchMode.value,
'maxPitchDeviation': params.maxPitchDeviation,
'smoothingRadius': params.smoothingRadius,
'invertFactors': params.invertFactors,
'pitchEdgeFill': params.pitchEdgeFill.value,
},
);
}
Expand All @@ -432,9 +438,15 @@ class ParameterConverter {
final v = params.values;
return StabilizeParameters(
enabled: params.enabled,
weave: v['weave'] as bool? ?? true,
dxmax: _asInt(v['dxmax']) ?? 4,
dymax: _asInt(v['dymax']) ?? 4,
mirror: _asInt(v['mirror']) ?? 0,
pitchMode: PitchMode.fromValue(v['pitchMode'] as String?),
maxPitchDeviation: (v['maxPitchDeviation'] as num?)?.toDouble() ?? 1.0,
smoothingRadius: _asInt(v['smoothingRadius']) ?? 10,
invertFactors: v['invertFactors'] as bool? ?? false,
pitchEdgeFill: EdgeFill.fromValue(v['pitchEdgeFill'] as String?),
);
}

Expand Down
Loading
Loading