Skip to content
Open
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
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,31 @@ 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.

### Borders (issue #86)

Two ways to add bars, sharing one step at the **very end of both templates**
(`{{#BORDERS}}`, after `CHROMA_CONVERT`): **Add Borders** (`padEnabled`/
`padWidth`/`padHeight`) pads to a fixed canvas without rescaling, independent
of Resize; **Pad to Fill** (`padToAspect`) pads to the resize target, and its
block inside `RESIZE_STANDARD` only *records* the box (`_aspect_box_w/h`).

- **Last, on purpose.** Grain after the bars would noise them; a format
conversion after them would resample their edge. Nothing may be added after
`BORDERS` that touches pixels.
- **Never call `AddBorders` without `color=`.** Its default is luma 0 —
below video black on a limited-range clip. `_border_color()` converts the
chosen RGB into the clip's own format via `resize` with the source's matrix and
range (`ColorMetadata::zimg_matrix` / `is_full_range`), so depth, range and
matrix come out right from one path.
- **Offsets stay on the chroma grid, and on the field grid if still
interlaced** (`{{BORDER_FIELD_ALIGN}}`: deinterlacing off + a detected field
order). A picture bigger than the canvas **raises** rather than being
cropped or left short — a wrong frame size is the one thing authoring can't
take.

`integration_borders_test.dart` (heavy) checks bar levels at 8 and 10-bit, and
that the picture inside the bars is not rescaled.

### Colour metadata: read it, carry it, re-stamp it

Same shape as the SAR handling above, same fix site. Colour tags (`color_space`,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Twenty-one filters, each switchable independently, applied in a fixed order. Mos
| **Sharpen** | Soft sources needing edge and fine detail recovery. aWarpSharp2 sharpens by warping edges instead of raising contrast, so it adds no halos. |
| **Chroma Fixes** | Colour that sits sideways from the picture (corrected automatically or by hand), bleeding past edges, rainbowing and dot crawl — including the shimmering kind that only shows when the picture moves — and residual combing. Each repair has its own switch, and its settings appear only once it is on. |
| **Color Correction** | Brightness, contrast, saturation, hue, levels, white balance (warm/cool, green/magenta), and lifting detail out of the shadows of underexposed footage. Levels and white balance can each be measured automatically or set by hand. |
| **Crop & Resize** | Trimming overscan, scaling, and edge-directed upscaling. |
| **Crop & Resize** | Trimming overscan, scaling, and edge-directed upscaling — plus bars in a colour of your choice to bring a cropped picture back to an exact frame size (720×576 for PAL DVD, 720×480 for NTSC) without rescaling it. |
| **Frame Rate** | Converting between PAL and NTSC rates, for a tape that was already converted once and now plays at the wrong speed. |
| **Subtitles** | Whisper AI speech-to-text — written alongside the video as `.srt`, embedded as a selectable track, burnt into the picture, or a combination. |

Expand Down
141 changes: 137 additions & 4 deletions app/assets/filters/core/crop_resize.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"$schema": "https://vapourbox.app/schemas/filter-v1.json",
"id": "crop_resize",
"version": "1.2.0",
"version": "1.3.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.",
"description": "Crop borders, resize or upscale, and add bars to a target size",
"longDescription": "Trims unwanted borders, changes the output resolution, and adds bars out to a target frame size. Cropping happens first, then resizing, then the bars.\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.\n\nUse Add Borders to bring a cropped picture back to an exact frame size for authoring (720x576 for PAL DVD, 720x480 for NTSC) without rescaling it: the picture is centred and the rest filled with bars.",
"category": "transform",
"icon": "crop",
"order": 9,
Expand Down Expand Up @@ -37,6 +37,11 @@
"customSar",
"displayAspect",
"padToAspect",
"padEnabled",
"padWidth",
"padHeight",
"padColor",
"padCustomColor",
"useIntegerUpscale",
"upscaleMethod",
"upscaleFactor",
Expand Down Expand Up @@ -609,11 +614,83 @@
"default": false,
"ui": {
"label": "Pad to Fill",
"description": "Letterbox or pillarbox out to the exact target size instead of leaving the picture smaller than it in one direction",
"description": "Letterbox or pillarbox out to the exact target size instead of leaving the picture smaller than it in one direction. The bars use the Border Colour below",
"visibleWhen": {
"resizeEnabled": true
}
}
},
"padEnabled": {
"type": "boolean",
"default": false,
"sinceAppVersion": "1.2.0",
"ui": {
"label": "Add Borders",
"description": "Add bars around the picture to bring it to an exact frame size, without rescaling it. Runs after cropping and resizing, so crop off a dirty edge and border back out to the size you need"
}
},
"padWidth": {
"type": "integer",
"default": 720,
"min": 64,
"max": 7680,
"step": 2,
"sinceAppVersion": "1.2.0",
"ui": {
"label": "Canvas Width",
"description": "Output frame width in pixels. Must be at least the picture's width after cropping and resizing",
"widget": "number",
"visibleWhen": {
"padEnabled": true
}
}
},
"padHeight": {
"type": "integer",
"default": 576,
"min": 64,
"max": 4320,
"step": 2,
"sinceAppVersion": "1.2.0",
"ui": {
"label": "Canvas Height",
"description": "Output frame height in pixels. Must be at least the picture's height after cropping and resizing",
"widget": "number",
"visibleWhen": {
"padEnabled": true
}
}
},
"padColor": {
"type": "enum",
"default": "black",
"options": [
"black",
"grey",
"white",
"custom"
],
"sinceAppVersion": "1.2.0",
"ui": {
"label": "Border Colour",
"description": "Colour of the bars, for Add Borders and Pad to Fill. Black is video black, not the below-black level bars used to get",
"widget": "dropdown"
}
},
"padCustomColor": {
"type": "string",
"default": "#000000",
"sinceAppVersion": "1.2.0",
"ui": {
"label": "Custom Colour",
"description": "Bar colour as #RRGGBB (e.g. #202020 for dark grey). Anything else falls back to black",
"widget": "textfield",
"visibleWhen": {
"padColor": [
"custom"
]
}
}
}
},
"parameterPresets": {
Expand Down Expand Up @@ -681,6 +758,36 @@
"targetHeight": 2160
}
}
},
"borderPreset": {
"label": "Canvas Preset",
"description": "Add bars to bring the picture to a standard frame size, without rescaling it",
"default": "None",
"options": {
"None": {
"padEnabled": false
},
"PAL DVD (720x576)": {
"padEnabled": true,
"padWidth": 720,
"padHeight": 576
},
"NTSC DVD (720x480)": {
"padEnabled": true,
"padWidth": 720,
"padHeight": 480
},
"720p (1280x720)": {
"padEnabled": true,
"padWidth": 1280,
"padHeight": 720
},
"1080p (1920x1080)": {
"padEnabled": true,
"padWidth": 1920,
"padHeight": 1080
}
}
}
},
"ui": {
Expand Down Expand Up @@ -720,6 +827,17 @@
],
"expanded": true
},
{
"title": "Borders",
"parameters": [
"padEnabled",
"padWidth",
"padHeight",
"padColor",
"padCustomColor"
],
"expanded": true
},
{
"title": "Upscale",
"description": "Enlarge using an edge-directed interpolator rather than a plain resize. Doubles at a time, so pick the factor that reaches or exceeds your target and set a Resize below to land on it exactly.",
Expand Down Expand Up @@ -791,6 +909,21 @@
"useIntegerUpscale": true,
"upscaleMethod": "spline36"
}
},
{
"function": "core.std.AddBorders",
"role": "Pad to Fill's letterbox/pillarbox bars, in the border colour",
"activeWhen": {
"resizeEnabled": true,
"padToAspect": true
}
},
{
"function": "core.std.AddBorders",
"role": "bars out to the canvas, in the border colour (last step, after the output format conversion)",
"activeWhen": {
"padEnabled": true
}
}
]
}
50 changes: 50 additions & 0 deletions app/lib/models/crop_resize_parameters.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ enum PixelAspectMode {
custom,
}

/// Fill colour for letterbox/pillarbox bars (issue #86).
enum BorderColor {
@JsonValue('black')
black,
@JsonValue('grey')
grey,
@JsonValue('white')
white,

/// An `#RRGGBB` value from [CropResizeParameters.padCustomColor].
@JsonValue('custom')
custom,
}

/// Crop/resize preset options.
enum CropResizePreset {
@JsonValue('off')
Expand Down Expand Up @@ -135,6 +149,23 @@ class CropResizeParameters {
/// fitted image smaller than it in one axis.
final bool padToAspect;

// --- Borders (issue #86) ---

/// Add bars out to a fixed canvas size, without rescaling the picture.
final bool padEnabled;

/// Canvas width. Null leaves the width at the picture's own.
final int? padWidth;

/// Canvas height. Null leaves the height at the picture's own.
final int? padHeight;

/// Fill colour for every bar this pass adds, Pad to Fill's included.
final BorderColor padColor;

/// `#RRGGBB` used when [padColor] is [BorderColor.custom].
final String? padCustomColor;

// --- Upscale Parameters (for integer scaling) ---

/// Whether to use integer upscaling (2x, 4x) instead of arbitrary resize.
Expand Down Expand Up @@ -204,6 +235,12 @@ class CropResizeParameters {
this.customSar,
this.displayAspect,
this.padToAspect = false,
// Border defaults
this.padEnabled = false,
this.padWidth,
this.padHeight,
this.padColor = BorderColor.black,
this.padCustomColor,
// Upscale defaults
this.useIntegerUpscale = false,
this.upscaleMethod = UpscaleMethod.nnedi3Rpow2,
Expand Down Expand Up @@ -295,6 +332,11 @@ class CropResizeParameters {
String? customSar,
String? displayAspect,
bool? padToAspect,
bool? padEnabled,
int? padWidth,
int? padHeight,
BorderColor? padColor,
String? padCustomColor,
bool? useIntegerUpscale,
UpscaleMethod? upscaleMethod,
int? upscaleFactor,
Expand Down Expand Up @@ -329,6 +371,11 @@ class CropResizeParameters {
customSar: customSar ?? this.customSar,
displayAspect: displayAspect ?? this.displayAspect,
padToAspect: padToAspect ?? this.padToAspect,
padEnabled: padEnabled ?? this.padEnabled,
padWidth: padWidth ?? this.padWidth,
padHeight: padHeight ?? this.padHeight,
padColor: padColor ?? this.padColor,
padCustomColor: padCustomColor ?? this.padCustomColor,
useIntegerUpscale: useIntegerUpscale ?? this.useIntegerUpscale,
upscaleMethod: upscaleMethod ?? this.upscaleMethod,
upscaleFactor: upscaleFactor ?? this.upscaleFactor,
Expand Down Expand Up @@ -378,6 +425,9 @@ class CropResizeParameters {
if (useIntegerUpscale) {
parts.add('${upscaleFactor}x');
}
if (padEnabled && (padWidth != null || padHeight != null)) {
parts.add('Border ${padWidth ?? "?"}x${padHeight ?? "?"}');
}
if (pixelAspect == PixelAspectMode.square) parts.add('Square px');
if (displayAspect != null && displayAspect!.isNotEmpty) {
parts.add('DAR $displayAspect');
Expand Down
15 changes: 15 additions & 0 deletions app/lib/models/parameter_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,13 @@ class ParameterConverter {
'maintainAspect': params.maintainAspect,
'pixelAspect': params.pixelAspect.name,
'padToAspect': params.padToAspect,
'padEnabled': params.padEnabled,
// A canvas that was never set shows the PAL DVD size, as the resize
// target shows 1080p — so ticking Add Borders does something at once.
'padWidth': params.padWidth ?? 720,
'padHeight': params.padHeight ?? 576,
'padColor': params.padColor.name,
'padCustomColor': params.padCustomColor ?? '#000000',
'useIntegerUpscale': params.useIntegerUpscale,
'upscaleMethod': params.upscaleMethod.name,
'upscaleFactor': params.upscaleFactor,
Expand Down Expand Up @@ -1443,6 +1450,14 @@ class ParameterConverter {
customSar: v['customSar'] as String?,
displayAspect: v['displayAspect'] as String?,
padToAspect: v['padToAspect'] as bool? ?? false,
padEnabled: v['padEnabled'] as bool? ?? false,
padWidth: _asInt(v['padWidth']) ?? 720,
padHeight: _asInt(v['padHeight']) ?? 576,
padColor: BorderColor.values.firstWhere(
(c) => c.name == (v['padColor'] as String? ?? 'black'),
orElse: () => BorderColor.black,
),
padCustomColor: v['padCustomColor'] as String?,
useIntegerUpscale: v['useIntegerUpscale'] as bool? ?? false,
upscaleMethod: UpscaleMethod.values.firstWhere(
(m) => m.name.toLowerCase() == (v['upscaleMethod'] as String? ?? 'nnedi3Rpow2').toLowerCase(),
Expand Down
Loading