web: import the camera's RAW, and grade it like the phone
The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own negatives never reached it. A RAW now loads the way any other file does — `isRawName` reads the extension off a 24-entry list, the file goes into OPFS under one slot (`current_image.raw`, beside `current_image.name`, so a reload finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit output, camera white balance and the camera's own 3x3 matrix, in bands of 2M pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW` (30.3MB) lands as a 3120x2084 picture, no page error. DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at 71.8% of the stage's pixels moved between 0 and 10. The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW, WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus the air A as a second child of the mask shader — so a mask's clarity is clarity and not a flat brightness lift. The column shows all nine rulers; CLARITY 9 moves 42.2% of the stage, DEHAZE 9 moves 27.9%. CLARITY stops reading the whole photo per pixel: the single pass that sampled a 15x15 box 225 times is now the three passes the same math wants — 1x15, then 15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work (77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps its mist as it was. The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or a recipe chip lays that look on the photo while it stays there and gives it back the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%, 24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO stack and the session stay on the look the click left. A hovered look brings its colour alone: the masks, the dust spots and the mosaic of the photo being edited ride along, or a pointer crossing a chip row would rub them off. A PRO sim is left out, since a hover that showed its look would hand over what the click gates. Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify, e2e-hover-preview2.
This commit is contained in:
@@ -28,6 +28,11 @@ import {
|
||||
EXPOSURE_SKSL,
|
||||
GLOW_SKSL,
|
||||
CLARITY_SKSL,
|
||||
CLARITY_BLUR_SKSL,
|
||||
CLARITY_BLEND_SKSL,
|
||||
CLARITY_GAIN,
|
||||
DEHAZE_SKSL,
|
||||
dehazeUniformArray,
|
||||
getToneUniforms,
|
||||
toneIsActive,
|
||||
toneUniformArray,
|
||||
@@ -37,7 +42,7 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/uti
|
||||
import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve';
|
||||
import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal';
|
||||
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
|
||||
import { gradientMaskSkSL, maskUniforms, readMasks } from '../../shared/utils/gradientMask';
|
||||
import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask';
|
||||
import {
|
||||
GRAIN_SKSL,
|
||||
HALATION_SKSL,
|
||||
@@ -158,6 +163,9 @@ let cinemaEffect: any = null;
|
||||
let glowEffect: any = null;
|
||||
let halationEffect: any = null;
|
||||
let curveEffect: any = null;
|
||||
let clarityBlurEffect: any = null;
|
||||
let clarityBlendEffect: any = null;
|
||||
let dehazeEffect: any = null;
|
||||
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
@@ -167,7 +175,21 @@ function effects() {
|
||||
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
|
||||
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
|
||||
return { sharpenEffect, toneEffect, exposureEffect, cinemaEffect, glowEffect, halationEffect, curveEffect };
|
||||
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
|
||||
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
|
||||
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
|
||||
return {
|
||||
sharpenEffect,
|
||||
toneEffect,
|
||||
exposureEffect,
|
||||
cinemaEffect,
|
||||
glowEffect,
|
||||
halationEffect,
|
||||
curveEffect,
|
||||
clarityBlurEffect,
|
||||
clarityBlendEffect,
|
||||
dehazeEffect,
|
||||
};
|
||||
}
|
||||
|
||||
// FX's HEAL is the one effect whose shader depends on the recipe rather than on
|
||||
@@ -199,13 +221,16 @@ function mosaicEffectFor(count: number): any {
|
||||
|
||||
// FX's gradient masks are the third shader of that kind — the shapes and the
|
||||
// knobs are the recipe's, so it is built for the count it is handed and cached
|
||||
// by count the same way (shared/utils/gradientMask.ts).
|
||||
const maskEffects = new Map<number, any>();
|
||||
function maskEffectFor(count: number): any {
|
||||
let effect = maskEffects.get(count);
|
||||
// by count the same way (shared/utils/gradientMask.ts). Two variants per count:
|
||||
// the masks that carry CLARITY or DEHAZE read a second child (the frame's own
|
||||
// blurred reference), the rest do not, and neither pays for the other.
|
||||
const maskEffects = new Map<string, any>();
|
||||
function maskEffectFor(count: number, spatial: boolean): any {
|
||||
const key = `${count}:${spatial ? 1 : 0}`;
|
||||
let effect = maskEffects.get(key);
|
||||
if (effect === undefined) {
|
||||
effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count)) ?? null;
|
||||
maskEffects.set(count, effect);
|
||||
effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count, spatial)) ?? null;
|
||||
maskEffects.set(key, effect);
|
||||
}
|
||||
return effect;
|
||||
}
|
||||
@@ -229,17 +254,6 @@ function convolvePaint(srcImage: any, amount: number): any {
|
||||
return { paint, shader, child };
|
||||
}
|
||||
|
||||
// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
|
||||
// outermost filter of the draw), so the pass reads a snapshot of the surface
|
||||
// and writes the sharpened result back over the same rect.
|
||||
function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
if (!snap) return;
|
||||
drawConvolved(canvas, snap, w, h, amount);
|
||||
snap.dispose();
|
||||
}
|
||||
|
||||
// Replaces the pixels under the rect with the convolved result.
|
||||
function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
|
||||
const built = convolvePaint(srcImage, amount);
|
||||
@@ -295,6 +309,162 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
|
||||
disposeAll([paint, filter, snap]);
|
||||
}
|
||||
|
||||
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
|
||||
//
|
||||
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
|
||||
// ki_n_tr_c_multiple_passes... entirely) compare a pixel against a blurred
|
||||
// reference of its own surroundings. The reference is built once here, the doc's
|
||||
// way: a separable pair of passes — 1x15 across, 15x1 down — writes two offscreen
|
||||
// surfaces the screen never shows, so a pixel costs 30 reads instead of the 225
|
||||
// one 15x15 kernel would take. Offscreen surfaces rather than the doc's
|
||||
// `rgba16float` textures because this renderer is 8-bit end to end (CanvasKit
|
||||
// surfaces, see skiaShim.ts); the format is the only thing the doc and this file
|
||||
// disagree about.
|
||||
|
||||
// How far out the reference looks, as a fraction of the frame's width, so the
|
||||
// same CLARITY is the same size of neighbourhood on the preview and on the file.
|
||||
const CLARITY_BLUR_SPAN = 0.06;
|
||||
|
||||
// One axis of the reference. `image` is the input, the result is a new image the
|
||||
// caller owns.
|
||||
function blurAxis(image: any, w: number, h: number, dx: number, dy: number): any | null {
|
||||
const { clarityBlurEffect: effect } = effects();
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf || !effect) {
|
||||
surf?.dispose();
|
||||
return null;
|
||||
}
|
||||
let child: any = null;
|
||||
try {
|
||||
child = image.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
const shader = child ? effect.makeShaderWithChildren([dx, dy], [child]) : null;
|
||||
if (!shader) return null;
|
||||
const paint = Skia.Paint();
|
||||
paint.setShader(shader);
|
||||
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
flush(surf);
|
||||
disposeAll([paint, shader]);
|
||||
return surf.makeImageSnapshot() ?? null;
|
||||
} finally {
|
||||
if (child) disposeAll([child]);
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// The frame's own blurred reference, B in both docs: what the pixel is compared
|
||||
// against for CLARITY, and the patch average the dark channel is read out of for
|
||||
// DEHAZE. Null only if the effect could not be built.
|
||||
function spatialReference(surface: any, w: number, h: number): any | null {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
if (!snap) return null;
|
||||
try {
|
||||
const step = Math.max(1, (w * CLARITY_BLUR_SPAN) / 15);
|
||||
const horizontal = blurAxis(snap, w, h, step, 0);
|
||||
if (!horizontal) return null;
|
||||
try {
|
||||
return blurAxis(horizontal, w, h, 0, step);
|
||||
} finally {
|
||||
horizontal.dispose();
|
||||
}
|
||||
} finally {
|
||||
snap.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// The doc's atmospheric light A, the "amount of scattered light" DEHAZE takes
|
||||
// back out. Read off the frame the pass is about to correct, through one 32x32
|
||||
// draw rather than the histogram the doc describes: the Dark Channel Prior's own
|
||||
// answer is the pixel whose DARKEST channel is brightest, so the 4096-pixel copy
|
||||
// only has to be searched for that one. Null when the readback is unavailable —
|
||||
// the caller then leaves the pass out rather than guessing a value.
|
||||
function atmosphericLight(surface: any, w: number, h: number): [number, number, number] | null {
|
||||
const n = 32;
|
||||
const small = createSurface(n, n);
|
||||
if (!small) return null;
|
||||
let snap: any = null;
|
||||
let paint: any = null;
|
||||
try {
|
||||
flush(surface);
|
||||
snap = surface.makeImageSnapshot();
|
||||
if (!snap) return null;
|
||||
paint = Skia.Paint();
|
||||
small.getCanvas().drawImageRect(
|
||||
snap,
|
||||
Skia.XYWHRect(0, 0, w, h),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
paint
|
||||
);
|
||||
flush(small);
|
||||
const px = small.getCanvas().readPixels(0, 0, {
|
||||
width: n,
|
||||
height: n,
|
||||
colorType: Skia.ColorType.RGBA_8888,
|
||||
alphaType: Skia.AlphaType.Unpremul,
|
||||
colorSpace: Skia.ColorSpace.SRGB,
|
||||
});
|
||||
if (!px) return null;
|
||||
let best = -1;
|
||||
let air: [number, number, number] = [1, 1, 1];
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
const r = px[i * 4] / 255;
|
||||
const g = px[i * 4 + 1] / 255;
|
||||
const b = px[i * 4 + 2] / 255;
|
||||
const dark = Math.min(r, g, b);
|
||||
if (dark > best) {
|
||||
best = dark;
|
||||
air = [r, g, b];
|
||||
}
|
||||
}
|
||||
return air;
|
||||
} catch {
|
||||
// No readback (a surface that refuses one, a shader that failed): DEHAZE
|
||||
// stays off for this render.
|
||||
return null;
|
||||
} finally {
|
||||
disposeAll([paint, snap].filter(Boolean) as SkDisposable[]);
|
||||
small.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// An image as a child shader — what every effect here wants instead of a paint.
|
||||
// The caller owns the result (the renderer's `own`).
|
||||
function imageShaderChild(image: any): any {
|
||||
return image.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot → clear → redraw through the shader the callback builds FROM that
|
||||
// snapshot: the shape every pass that reads the frame it is about to overwrite
|
||||
// uses (the negative blurs, the gradient masks, HEAL), in one place so none of
|
||||
// them has to remember to clear first. The callback owns whatever children it
|
||||
// puts in the shader; the snapshot is freed here.
|
||||
function replaceThrough(
|
||||
canvas: any,
|
||||
surface: any,
|
||||
w: number,
|
||||
h: number,
|
||||
shaderOf: (snap: any) => any
|
||||
): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
if (!snap) return;
|
||||
try {
|
||||
const shader = shaderOf(snap);
|
||||
if (!shader) return;
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
const paint = Skia.Paint();
|
||||
paint.setShader(shader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
disposeAll([paint, shader]);
|
||||
} finally {
|
||||
snap.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// --- fonts / artwork --------------------------------------------------------
|
||||
|
||||
let faces: { typeface: any; iconTypeface: any } | null = null;
|
||||
@@ -584,10 +754,55 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
}
|
||||
|
||||
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
|
||||
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
|
||||
if (adjustments.clarity > 0) {
|
||||
convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
|
||||
// 4b. DEHAZE and CLARITY — the frame-wide half of the two docs
|
||||
// (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both
|
||||
// compare a pixel against a blurred reference of its own surroundings, so the
|
||||
// reference is built once out of the frame the pass is about to overwrite
|
||||
// (spatialReference, the doc's separable bilateral pair) and handed in as the
|
||||
// effect's second child. DEHAZE runs first: it takes the scattered light out,
|
||||
// and sharpening haze instead would only make it read as detail.
|
||||
//
|
||||
// The masks are read here rather than at 6d because a mask's CLARITY/DEHAZE
|
||||
// asks for the same reference and the same atmospheric light, and the light is
|
||||
// cheapest to read before the masks have graded the frame.
|
||||
const masks = readMasks(adjustments.masks);
|
||||
const maskSpatial = masksHaveSpatial(masks);
|
||||
let air: [number, number, number] | null = null;
|
||||
let airRead = false;
|
||||
const airOf = () => {
|
||||
if (!airRead) {
|
||||
airRead = true;
|
||||
air = atmosphericLight(surface, width, height);
|
||||
}
|
||||
return air;
|
||||
};
|
||||
const dehazeAmount = (adjustments.dehaze ?? 0) / 10;
|
||||
|
||||
if (dehazeAmount > 0 && dehazeEffect) {
|
||||
replaceThrough(canvas, surface, width, height, (snap) => {
|
||||
const a = airOf();
|
||||
const reference = spatialReference(surface, width, height);
|
||||
if (!a || !reference) return null;
|
||||
own(reference);
|
||||
const shader = dehazeEffect.makeShaderWithChildren(dehazeUniformArray(a, dehazeAmount), [
|
||||
own(imageShaderChild(snap)),
|
||||
own(imageShaderChild(reference)),
|
||||
]);
|
||||
return shader ? own(shader) : null;
|
||||
});
|
||||
}
|
||||
|
||||
if (adjustments.clarity > 0 && clarityBlendEffect) {
|
||||
replaceThrough(canvas, surface, width, height, (snap) => {
|
||||
const reference = spatialReference(surface, width, height);
|
||||
if (!reference) return null;
|
||||
own(reference);
|
||||
const shader = clarityBlendEffect.makeShaderWithChildren(
|
||||
[(adjustments.clarity / 10) * CLARITY_GAIN],
|
||||
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
|
||||
);
|
||||
return shader ? own(shader) : null;
|
||||
});
|
||||
} else if (adjustments.clarity < 0) {
|
||||
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
||||
drawBlurred(canvas, surface, width, height, mistSigma);
|
||||
@@ -711,30 +926,22 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
// reads what the first left. It sits here, after the frame-wide grade and
|
||||
// before HEAL, because a local adjustment is part of the look and not a
|
||||
// repair: the pixels a repair borrows are meant to carry it already.
|
||||
const masks = readMasks(adjustments.masks);
|
||||
// Same shape as the passes below — the shader reads the frame it is about to
|
||||
// overwrite — and, when a mask carries CLARITY or DEHAZE, the frame's own
|
||||
// blurred reference and the atmospheric light come in exactly as they did for
|
||||
// the frame-wide pair at 4b.
|
||||
if (masks.length) {
|
||||
const effect = maskEffectFor(masks.length);
|
||||
flush(surface);
|
||||
const snap = own(surface.makeImageSnapshot());
|
||||
const child = snap
|
||||
? own(
|
||||
snap.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
)
|
||||
)
|
||||
: null;
|
||||
const shader =
|
||||
effect && child
|
||||
? own(effect.makeShaderWithChildren(maskUniforms(masks, width, height), [child]))
|
||||
: null;
|
||||
if (shader) {
|
||||
// Same shape as the two passes below: the shader reads the frame it is
|
||||
// about to overwrite, so the surface is snapshotted, cleared and redrawn.
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
const maskPaint = own(Skia.Paint());
|
||||
maskPaint.setShader(shader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), maskPaint);
|
||||
}
|
||||
const effect = maskEffectFor(masks.length, maskSpatial);
|
||||
const a = maskSpatial ? airOf() : null;
|
||||
replaceThrough(canvas, surface, width, height, (snap) => {
|
||||
const reference = maskSpatial ? spatialReference(surface, width, height) : null;
|
||||
if (!effect || (maskSpatial && !reference)) return null;
|
||||
own(reference);
|
||||
const children = [own(imageShaderChild(snap))];
|
||||
if (reference) children.push(own(imageShaderChild(reference)));
|
||||
const shader = effect.makeShaderWithChildren(maskUniforms(masks, width, height, a), children);
|
||||
return shader ? own(shader) : null;
|
||||
});
|
||||
}
|
||||
|
||||
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
|
||||
@@ -765,7 +972,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
: null;
|
||||
if (shader) {
|
||||
// Snapshot, clear, redraw — the pass reads the frame it is about to
|
||||
// overwrite (the same shape convPass uses for CLARITY).
|
||||
// overwrite (see replaceThrough).
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
const healPaint = own(Skia.Paint());
|
||||
healPaint.setShader(shader);
|
||||
|
||||
Reference in New Issue
Block a user