Implemented white balance filter, grouped quick adjustments into their own fieldset

This commit is contained in:
Igor Zinken
2026-08-01 09:21:35 +02:00
parent b8bc421424
commit 3019934d9b
12 changed files with 244 additions and 99 deletions

View File

@@ -51,6 +51,33 @@
/>
</div>
</fieldset>
<fieldset class="fieldset">
<legend v-t="'quickAdjustments'" />
<div class="wrapper wrapper--toggle">
<label v-t="'whiteBalance'"></label>
<toggle-button
v-model="internalValue.quick.whiteBalance"
name="invert"
sync
/>
</div>
<div class="wrapper wrapper--toggle">
<label v-t="'invert'"></label>
<toggle-button
v-model="internalValue.quick.invert"
name="invert"
sync
/>
</div>
<div class="wrapper wrapper--toggle">
<label v-t="'grayscale'"></label>
<toggle-button
v-model="internalValue.quick.desaturate"
name="desaturate"
sync
/>
</div>
</fieldset>
<fieldset class="fieldset">
<legend v-t="'colorAdjustments'" />
<div class="wrapper wrapper--slider">
@@ -122,22 +149,6 @@
</fieldset>
<fieldset class="fieldset fieldset--duotone">
<legend v-t="'filters'" />
<div class="wrapper wrapper--toggle">
<label v-t="'invert'"></label>
<toggle-button
v-model="internalValue.invert"
name="invert"
sync
/>
</div>
<div class="wrapper wrapper--toggle">
<label v-t="'grayscale'"></label>
<toggle-button
v-model="internalValue.desaturate"
name="desaturate"
sync
/>
</div>
<div class="wrapper wrapper--toggle">
<label
for="duotone"

View File

@@ -32,6 +32,8 @@
"brightness": "Brightness",
"vibrance": "Vibrance",
"invert": "Invert",
"whiteBalance": "White balance",
"quickAdjustments": "Quick adjustments",
"threshold": "Threshold",
"grayscale": "Grayscale",
"duotone": "Duotone",

View File

@@ -23,7 +23,7 @@
<template>
<div class="tool-option">
<h3 v-t="'fill'"></h3>
<div class="wrapper wrapper--toggle wrapper--full">
<div class="wrapper wrapper--toggle">
<label v-t="'smartFill'"></label>
<toggle-button
v-model="smartFill"

View File

@@ -21,13 +21,32 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { BlendModes } from "@/definitions/blend-modes";
import { DeepPartial } from "@/model/types/_util";
import type { Filters } from "@/model/types/filters";
export type FiltersProps = Partial<Filters>;
export type FiltersProps = DeepPartial<Filters>;
export const DEFAULT_DUOTONE_1 = "#FF0000";
export const DEFAULT_DUOTONE_2 = "#0099FF";
const DEFAULT_QUICK_SETTINGS = {
desaturate : false,
invert : false,
whiteBalance : false,
};
const DEFAULT_DUOTONE = {
enabled: false,
color1: DEFAULT_DUOTONE_1,
color2: DEFAULT_DUOTONE_2,
};
const DEFAULT_HSL = {
hue: 0,
sat: 0,
lightness: 0,
};
let defaultFilters: Filters | null = null;
const FiltersFactory = {
@@ -40,18 +59,9 @@ const FiltersFactory = {
contrast = 0,
vibrance = .5,
threshold = -1, // -1 == off, working range is 0 - 255
desaturate = false,
invert = false,
duotone = {
enabled: false,
color1: DEFAULT_DUOTONE_1,
color2: DEFAULT_DUOTONE_2,
},
hsl = {
hue: 0,
sat: 0,
lightness: 0,
},
quick = DEFAULT_QUICK_SETTINGS,
duotone = DEFAULT_DUOTONE,
hsl = DEFAULT_HSL,
blur = 0,
}: FiltersProps = {}): Filters {
return {
@@ -61,12 +71,20 @@ const FiltersFactory = {
gamma,
brightness,
contrast,
desaturate,
invert,
vibrance,
threshold,
duotone,
hsl,
quick: {
...DEFAULT_QUICK_SETTINGS,
...quick
},
hsl: {
...DEFAULT_HSL,
...hsl,
},
duotone: {
...DEFAULT_DUOTONE,
...duotone,
},
blur,
};
},
@@ -76,7 +94,7 @@ const FiltersFactory = {
* for project storage
*/
serialize( filters: Filters ): any {
const { duotone, hsl } = filters;
const { duotone, hsl, quick } = filters;
return {
e: filters.enabled,
m: filters.blendMode,
@@ -84,8 +102,9 @@ const FiltersFactory = {
g: filters.gamma,
b: filters.brightness,
c: filters.contrast,
d: filters.desaturate,
i: filters.invert,
d: quick.desaturate,
i: quick.invert,
w: quick.whiteBalance,
v: filters.vibrance,
t: filters.threshold,
de: duotone.enabled,
@@ -103,30 +122,33 @@ const FiltersFactory = {
* inside a stored projects layer
*/
deserialize( filters: any = {} ): Filters {
return FiltersFactory.create({
enabled: filters.e,
blendMode: filters.m,
opacity: filters.o,
gamma: filters.g,
brightness: filters.b,
contrast: filters.c,
desaturate: filters.d,
vibrance: filters.v,
threshold: filters.t,
// these can be undefined as these were added in later app versions
invert: filters.i ?? false,
duotone: {
// nullish coalescing fallbacks as some properties were added in later app versions
return FiltersFactory.create({
enabled: filters.e,
blendMode: filters.m,
opacity: filters.o,
gamma: filters.g,
brightness: filters.b,
contrast: filters.c,
vibrance: filters.v,
threshold: filters.t,
quick: {
desaturate: filters.d,
invert: filters.i ?? false,
whiteBalance: filters.w ?? false,
},
duotone: {
enabled: filters.de ?? false,
color1: filters.d1 ?? DEFAULT_DUOTONE_1,
color2: filters.d2 ?? DEFAULT_DUOTONE_2,
},
hsl: {
},
hsl: {
hue: filters.hh ?? 0,
sat: filters.hs ?? 0,
lightness: filters.hl ?? 0,
},
blur: filters.bl,
});
},
blur: filters.bl,
});
}
};
export default FiltersFactory;
@@ -151,15 +173,20 @@ export const isEqual = ( filters: Filters, filtersToCompareTo?: Filters ): boole
filters.gamma === filtersToCompareTo.gamma &&
filters.brightness === filtersToCompareTo.brightness &&
filters.contrast === filtersToCompareTo.contrast &&
filters.desaturate === filtersToCompareTo.desaturate &&
filters.invert === filtersToCompareTo.invert &&
filters.vibrance === filtersToCompareTo.vibrance &&
filters.threshold === filtersToCompareTo.threshold &&
// quick filters
filters.quick.desaturate === filtersToCompareTo.quick.desaturate &&
filters.quick.invert === filtersToCompareTo.quick.invert &&
filters.quick.whiteBalance === filtersToCompareTo.quick.whiteBalance &&
// duotone
filters.duotone.enabled === filtersToCompareTo.duotone.enabled &&
filters.duotone.color1 === filtersToCompareTo.duotone.color1 &&
filters.duotone.color2 === filtersToCompareTo.duotone.color2 &&
// HSL
filters.hsl.hue === filtersToCompareTo.hsl.hue &&
filters.hsl.sat === filtersToCompareTo.hsl.sat &&
filters.hsl.lightness === filtersToCompareTo.hsl.lightness &&
// blur
filters.blur === filtersToCompareTo.blur;
};

13
src/model/types/_util.ts Normal file
View File

@@ -0,0 +1,13 @@
export type DeepPartial<T> = T extends Function
? T
: T extends Array<infer InferredArrayMember>
? _DeepPartialArray<InferredArrayMember>
: T extends object
? _DeepPartialObject<T>
: T | undefined;
type _DeepPartialArray<T> = Array<DeepPartial<T>>;
type _DeepPartialObject<T> = {
[Key in keyof T]?: DeepPartial<T[Key]>;
};

View File

@@ -22,6 +22,24 @@
*/
import type { BlendModes } from "@/definitions/blend-modes";
type DuoToneFilter = {
enabled: boolean;
color1?: string;
color2?: string;
};
type QuickFilter = {
desaturate: boolean;
invert: boolean;
whiteBalance: boolean;
};
type HSLFilter = {
hue: number, // -180 to +180 range (degrees)
sat: number, // -1 to +1 range
lightness: number, // -1 to +1 range
};
export type Filters = {
enabled: boolean;
blendMode: BlendModes;
@@ -31,17 +49,8 @@ export type Filters = {
contrast: number;
vibrance: number;
threshold: number;
desaturate: boolean;
invert: boolean;
duotone: {
enabled: boolean;
color1?: string;
color2?: string;
};
hsl: {
hue: number, // -180 to +180 range (degrees)
sat: number, // -1 to +1 range
lightness: number, // -1 to +1 range
};
quick: QuickFilter;
duotone: DuoToneFilter;
hsl: HSLFilter;
blur: number;
};
};

View File

@@ -33,6 +33,9 @@ const defaultFilters = FiltersFactory.create();
/**
* Apply generic adjustment filters (brightness, contrast, gamma, vibrance, threshold)
* onto provided RGBA pixel Array.
*
* These can be combined in a single function as these filters do not
* require an initial pass (for instant to calculate averages, etc.)
*/
export const applyAdjustments = ( pixels: Uint8ClampedArray, filters: Filters ): void =>
{
@@ -41,18 +44,19 @@ export const applyAdjustments = ( pixels: Uint8ClampedArray, filters: Filters ):
const gamma = filters.gamma * 2; // 0 to 2 range
const vibrance = -(( filters.vibrance * 200 ) - 100 ); // -100 to 100 range
const { desaturate, invert, threshold } = filters;
let r, g, b, a;
let grayScale, max, avg, amt;
const gammaSquared = gamma * gamma;
const doBrightness = filters.brightness !== defaultFilters.brightness;
const doContrast = filters.contrast !== defaultFilters.contrast;
const doGamma = filters.gamma !== defaultFilters.gamma;
const doVibrance = filters.vibrance !== defaultFilters.vibrance;
const doThreshold = filters.threshold !== defaultFilters.threshold;
const doInvert = filters.invert !== defaultFilters.invert;
const doInvert = filters.quick.invert;
const doDesaturate = filters.quick.desaturate;
const { threshold } = filters;
let r, g, b, a;
let grayScale, max, avg, amt;
const gammaSquared = gamma * gamma;
// loop through the pixels, note we increment the iterator by four
// as each pixel is defined by four RGBA channel values : red, green, blue and the alpha channel
@@ -84,7 +88,7 @@ export const applyAdjustments = ( pixels: Uint8ClampedArray, filters: Filters ):
}
// desaturate
if ( desaturate ) {
if ( doDesaturate ) {
grayScale = r * 0.3 + g * 0.59 + b * 0.11;
r = grayScale;
g = grayScale;

View File

@@ -22,8 +22,6 @@
*/
import { RGBtoHSL, HSLtoRGB } from "@/utils/color-util";
const HALF_PI = Math.PI / 2;
/**
* Applies HSL balancing onto provided data
*

View File

@@ -0,0 +1,33 @@
export const applyWhiteBalance = ( pixels: Uint8ClampedArray ): void => {
const totalPixels = pixels.length / 4;
let totalR = 0, totalG = 0, totalB = 0;
// Step 1: Calculate the sum of all RGB values
for ( let i = 0, len = pixels.length; i < len; i += 4) {
totalR += pixels[ i ];
totalG += pixels[ i + 1 ];
totalB += pixels[ i + 2 ];
}
// Step 2: Find the average value for each channel
const avgR = totalR / totalPixels;
const avgG = totalG / totalPixels;
const avgB = totalB / totalPixels;
// Step 3: Calculate the overall average brightness
const avgGray = ( avgR + avgG + avgB ) / 3;
// Step 4: Calculate scaling factors for each channel
const scaleR = avgGray / avgR;
const scaleG = avgGray / avgG;
const scaleB = avgGray / avgB;
// Step 5: Apply the scaling factors to every pixel
for ( let i = 0, len = pixels.length; i < len; i += 4 ) {
pixels[i] = Math.min(255, pixels[i] * scaleR); // New Red
pixels[i + 1] = Math.min(255, pixels[i + 1] * scaleG); // New Green
pixels[i + 2] = Math.min(255, pixels[i + 2] * scaleB); // New Blue
// data[i+3] is Alpha (transparency), leave it unchanged
}
};

View File

@@ -28,6 +28,14 @@ $inputWidth: 65%;
margin-bottom: variables.$spacing-small;
}
&--toggle {
justify-content: space-between;
&.wrapper--full {
justify-content: initial;
}
}
// use where there is no <label> element
&--full {
width: 200%;

View File

@@ -27,6 +27,7 @@ import type { WasmFilterInstance } from "@/utils/wasm-util";
import { applyAdjustments } from "@/rendering/filters/adjustments";
import { applyBlur } from "@/rendering/filters/blur";
import { applyDuotone } from "@/rendering/filters/duotone";
import { applyWhiteBalance } from "../rendering/filters/white-balance";
import { applyHSL } from "@/rendering/filters/hsl";
import wasmJs from "@/wasm/bin/filters.js";
@@ -86,6 +87,9 @@ function renderFilters( imageData: ImageData, filters: Filters ): Uint8ClampedAr
applyBlur( pixels, imageData.width, imageData.height, filters.blur );
}
if ( filters.quick.whiteBalance ) {
applyWhiteBalance( pixels );
}
applyAdjustments( pixels, filters );
if ( filters.duotone.enabled ) {
@@ -100,12 +104,12 @@ function renderFilters( imageData: ImageData, filters: Filters ): Uint8ClampedAr
/* internal methods */
function renderFiltersWasm( imageData: ImageData, filters: any ): Uint8ClampedArray {
function renderFiltersWasm( imageData: ImageData, filters: Filters ): Uint8ClampedArray {
const brightness = ( filters.brightness * 2 );//( filters.brightness * 2 ) - 1; // -1 to 1 range
const contrast = Math.pow((( filters.contrast * 100 ) + 100 ) / 100, 2 ); // -100 to 100 range
const gamma = ( filters.gamma * 2 ); // 0 to 2 range
const vibrance = -(( filters.vibrance * 200 ) - 100 ); // -100 to 100 range
const { desaturate, invert, threshold } = filters;
const { threshold } = filters;
const doBrightness = filters.brightness !== defaultFilters.brightness;
const doContrast = filters.contrast !== defaultFilters.contrast;
@@ -114,8 +118,10 @@ function renderFiltersWasm( imageData: ImageData, filters: any ): Uint8ClampedAr
// @todo these are not supported by the WASM variant yet
const doInvert = invert !== defaultFilters.invert;
const doInvert = filters.quick.invert;
const doThreshold = threshold !== defaultFilters.threshold;
const doDesaturate = filters.quick.desaturate;
const doWhiteBalance = filters.quick.whiteBalance;
const doDuotone = filters.duotone.enabled !== defaultFilters.duotone.enabled;
const doHSL = filters.hsl.hue !== 0 || filters.hsl.sat !== 0 || filters.hsl.lightness !== 0;
const doBlur = filters.blur > 0;
@@ -126,7 +132,7 @@ function renderFiltersWasm( imageData: ImageData, filters: any ): Uint8ClampedAr
wasmInstance._filter(
memory, length,
gamma, brightness, contrast, vibrance,/* threshold, duotone.color1, duotone.color2 */
doGamma, desaturate, doBrightness, doContrast, doVibrance/*, doThreshold, doDuotone, doHSL, doBlur*/
doGamma, doDesaturate, doBrightness, doContrast, doVibrance/*, doWhiteBalance, doThreshold, doDuotone, doHSL, doBlur*/
);
});
}

View File

@@ -15,13 +15,16 @@ describe( "Filters factory", () => {
contrast: 0,
vibrance: .5,
threshold: -1,
desaturate: false,
invert: false,
duotone: {
enabled: false,
color1: DEFAULT_DUOTONE_1,
color2: DEFAULT_DUOTONE_2,
},
quick: {
desaturate: false,
invert: false,
whiteBalance: false,
},
hsl: {
hue: 0,
sat: 0,
@@ -41,8 +44,11 @@ describe( "Filters factory", () => {
contrast: .3,
vibrance: .2,
threshold: 127,
desaturate: true,
invert: true,
quick: {
desaturate: true,
invert: true,
whiteBalance: true,
},
duotone: {
enabled: true,
color1: "#FF9900",
@@ -64,8 +70,11 @@ describe( "Filters factory", () => {
contrast: .3,
vibrance: .2,
threshold: 127,
desaturate: true,
invert: true,
quick: {
desaturate: true,
invert: true,
whiteBalance: true,
},
duotone: {
enabled: true,
color1: "#FF9900",
@@ -92,8 +101,11 @@ describe( "Filters factory", () => {
contrast: .3,
vibrance: .2,
threshold: 255,
desaturate: true,
invert: true,
quick: {
desaturate: true,
invert: true,
whiteBalance: true,
},
duotone: {
enabled: true,
color1: "#FF9900",
@@ -141,10 +153,13 @@ describe( "Filters factory", () => {
filter = FiltersFactory.create({ threshold: 0 });
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ desaturate: true });
filter = FiltersFactory.create({ quick: { desaturate: true } });
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ invert: true });
filter = FiltersFactory.create({ quick: { invert: true } });
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ quick: { whiteBalance: true } });
expect( hasFilters( filter )).toBe( true );
// we don't need to check for duotone colors as the enabled flag for the Object is sufficient
@@ -152,7 +167,7 @@ describe( "Filters factory", () => {
filter = FiltersFactory.create({ duotone: { enabled: true }});
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ hsl: { hue: 180, sat: 26, lightness: -50 }});
filter = FiltersFactory.create({ hsl: { hue: 180 }});
expect( hasFilters( filter )).toBe( true );
filter = FiltersFactory.create({ blur: 25 });
@@ -163,15 +178,34 @@ describe( "Filters factory", () => {
it( "should know when two filters instances are equal", () => {
const defaultFilter = FiltersFactory.create();
[
"enabled", "blendMode", "opacity",
"gamma", "brightness", "contrast",
"vibrance", "threshold", "desaturate",
"invert", "blur"
"enabled", "blendMode", "opacity", "gamma", "brightness", "contrast",
"vibrance", "threshold", "blur"
]
.forEach( property => {
const filters = FiltersFactory.create({ [ property ]: .88 });
expect( isEqual( filters, defaultFilter )).toBe( false );
});
expect( isEqual(
FiltersFactory.create({ quick: { desaturate: true }}), defaultFilter
)).toBe( false );
expect( isEqual(
FiltersFactory.create({ quick: { invert: true }}), defaultFilter
)).toBe( false );
expect( isEqual(
FiltersFactory.create({ quick: { whiteBalance: true }}), defaultFilter
)).toBe( false );
expect( isEqual(
FiltersFactory.create({ duotone: { enabled: true }}), defaultFilter
)).toBe( false );
expect( isEqual(
FiltersFactory.create({ hsl: { hue: 90 }}), defaultFilter
)).toBe( false );
expect( isEqual( defaultFilter, FiltersFactory.create() )).toBe( true );
});
});