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:
Generated
+7
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"libraw-wasm": "^1.6.0",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -1605,6 +1606,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/libraw-wasm": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/libraw-wasm/-/libraw-wasm-1.6.0.tgz",
|
||||
"integrity": "sha512-VfvWq2XGGkc07wexymRLn1H82j6smmC1LAbVl41vnW2Z5RrEPLN+YHi1Hmga2TbxxrY4rOjmTwHwSyTZmypE1g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"libraw-wasm": "^1.6.0",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -153,6 +153,18 @@ export interface GradientMask {
|
||||
exposure: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
// The spec's section 4 knobs, the frame-wide ones the mask can now restrict to
|
||||
// its own area: HIGHLIGHT and SHADOW through its smoothstep soft masks, WHITE
|
||||
// and BLACK at the two ends of the histogram, and the two spatial moves —
|
||||
// CLARITY against the frame's own blurred reference and DEHAZE through the dark
|
||||
// channel. Same -10..+10 as the app's own rulers; absent on a mask stored
|
||||
// before they existed, and then the shader reads 0 for it.
|
||||
highlights?: number;
|
||||
shadows?: number;
|
||||
whites?: number;
|
||||
blacks?: number;
|
||||
clarity?: number;
|
||||
dehaze?: number;
|
||||
}
|
||||
|
||||
export interface ColorAdjustments {
|
||||
@@ -169,6 +181,10 @@ export interface ColorAdjustments {
|
||||
blacks?: number; // -10 to +10 (WB black point: per channel, the toe)
|
||||
denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back)
|
||||
clarity: number; // -10 to +10 (mapped to matrix convolution / bloom)
|
||||
// FX tab > DEHAZE (raw_parameter_processing_gradient_mask_algorithms.md §3.2):
|
||||
// 0 to 10, the Dark Channel Prior pass — the scattered light the haze puts in
|
||||
// front of the subject, taken back out. Absent on an older recipe = 0.
|
||||
dehaze?: number;
|
||||
grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale)
|
||||
grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own)
|
||||
colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GradientMask } from '../types';
|
||||
import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA } from './toneShader';
|
||||
|
||||
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
|
||||
// adjustments that are a SHAPE rather than a whole-frame knob.
|
||||
@@ -21,11 +22,14 @@ import type { GradientMask } from '../types';
|
||||
// skiaShim.ts), stage for stage: the one pass the spec asks for per mask, in
|
||||
// the order the user drew them, each reading what the one before it left.
|
||||
//
|
||||
// ponytail: the spec's next rung — Highlights/Shadows isolated with pow(luma, 3)
|
||||
// weight masks — is not here. Its own section calls it an upgrade, and the three
|
||||
// knobs are what "gradient mask" means until a photo shows a sky that needs
|
||||
// rescuing apart from the grass under it. The same rung holds Lightroom's
|
||||
// per-mask invert and colour/tone ranges.
|
||||
// The spec's section 4 — "the system needs to restrict all the above effects to
|
||||
// operate only within the mask's area" — is the rest of the block below: the
|
||||
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
|
||||
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
|
||||
// DEHAZE through the dark channel) that the caller hands in the blurred
|
||||
// reference and the atmospheric light for. Every one of them rides the same
|
||||
// alpha the shape produces and lands through the same `mix`, so a mask at half
|
||||
// strength is half of the move.
|
||||
export const MASK_KIND = { linear: 0, radial: 1 } as const;
|
||||
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
|
||||
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
|
||||
@@ -54,7 +58,20 @@ const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v
|
||||
// cannot produce a shape the overlay and the renderer disagree about. A mask
|
||||
// with no shape (a linear drag of no length, an ellipse of no radius) is
|
||||
// dropped: it would paint nothing and could never be taken hold of on the photo.
|
||||
export function readMasks(masks: GradientMask[] | undefined): GradientMask[] {
|
||||
// What readMasks hands back: the stored mask with every knob resolved to a
|
||||
// number. The two spatial ones and the four tonal ones are optional in the
|
||||
// stored type (a mask saved before they existed has none), so the reader's
|
||||
// return type is the one that says they are there.
|
||||
export type ReadMask = GradientMask & {
|
||||
highlights: number;
|
||||
shadows: number;
|
||||
whites: number;
|
||||
blacks: number;
|
||||
clarity: number;
|
||||
dehaze: number;
|
||||
};
|
||||
|
||||
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
if (!Array.isArray(masks)) return [];
|
||||
return masks
|
||||
.filter((m) => m?.kind === 'linear' || m?.kind === 'radial')
|
||||
@@ -71,20 +88,44 @@ export function readMasks(masks: GradientMask[] | undefined): GradientMask[] {
|
||||
exposure: clampEV(num(m.exposure, 0)),
|
||||
contrast: clampA(num(m.contrast, 0)),
|
||||
saturation: clampA(num(m.saturation, 0)),
|
||||
// The spec's section 4 knobs: the two tone soft masks, the two ends, and
|
||||
// the two spatial moves. All -10..+10 like every other knob here, all 0 on
|
||||
// a mask stored before they existed.
|
||||
highlights: clampA(num(m.highlights, 0)),
|
||||
shadows: clampA(num(m.shadows, 0)),
|
||||
whites: clampA(num(m.whites, 0)),
|
||||
blacks: clampA(num(m.blacks, 0)),
|
||||
clarity: clampA(num(m.clarity, 0)),
|
||||
dehaze: clampA(num(m.dehaze, 0)),
|
||||
}))
|
||||
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
|
||||
}
|
||||
|
||||
// The two spatial knobs need the frame's own blurred reference (and DEHAZE the
|
||||
// atmospheric light) handed to the shader as a second child; the four tonal ones
|
||||
// need nothing. One question, asked in one place, so the renderer builds the
|
||||
// blur for exactly the masks that read it and the effect is cached for the same
|
||||
// answer.
|
||||
export function masksHaveSpatial(masks: GradientMask[]): boolean {
|
||||
return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0);
|
||||
}
|
||||
|
||||
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
|
||||
// parameters, the knobs with the kind, then the frame the fractions are of.
|
||||
// Declaration order, arrays expanded — one buffer is one upload per render, the
|
||||
// same shape healUniforms and mosaicUniforms use. Its length is a function of
|
||||
// the list, not a fixed capacity, because the shader carries exactly the masks
|
||||
// the recipe holds.
|
||||
export function maskUniforms(masks: GradientMask[], width: number, height: number): Float32Array {
|
||||
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
|
||||
// then the frame the fractions are of — and, when the caller has one, the
|
||||
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
|
||||
// buffer is one upload per render, the same shape healUniforms and
|
||||
// mosaicUniforms use. Its length is a function of the list, not a fixed
|
||||
// capacity, because the shader carries exactly the masks the recipe holds.
|
||||
export function maskUniforms(
|
||||
masks: GradientMask[],
|
||||
width: number,
|
||||
height: number,
|
||||
air?: [number, number, number] | null
|
||||
): Float32Array {
|
||||
const list = readMasks(masks);
|
||||
const n = list.length;
|
||||
const u = new Float32Array((3 * n + 1) * 4);
|
||||
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = list[i];
|
||||
u.set([m.x, m.y, m.ex, m.ey], i * 4);
|
||||
@@ -95,24 +136,61 @@ export function maskUniforms(masks: GradientMask[], width: number, height: numbe
|
||||
[m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]],
|
||||
(2 * n + i) * 4
|
||||
);
|
||||
// The tone soft masks and the two ends, then the spatial pair.
|
||||
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
|
||||
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
|
||||
}
|
||||
u.set([width, height, 0, 0], 3 * n * 4);
|
||||
u.set([width, height, 0, 0], 5 * n * 4);
|
||||
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
|
||||
return u;
|
||||
}
|
||||
|
||||
// The colour inside a mask, in the spec's own order and its own formulas:
|
||||
// exposure first (a power of two, so a stop is a stop), then contrast about the
|
||||
// middle, then saturation as a mix away from the pixel's own REC-709 luma. The
|
||||
// result is clamped to the range a file can hold — the spec's own guard, and it
|
||||
// is `mix`ed back over the base by the mask's alpha, so a mask at half strength
|
||||
// is half of the move rather than the whole of it.
|
||||
const adjustFn = `
|
||||
half3 maskAdjust(half3 c, float3 a) {
|
||||
// middle, then saturation as a mix away from the pixel's own REC-709 luma. Then
|
||||
// the spec's section 2 and 3 on top — HIGHLIGHT and SHADOW through the two
|
||||
// smoothstep soft masks its own formula names (`M_shadow = 1 - smoothstep(0,
|
||||
// 0.5, L)`, `M_highlight = smoothstep(0.5, 1, L)`), WHITE and BLACK as a gain on
|
||||
// the end each one owns, and the two spatial ones against the blurred reference
|
||||
// the caller hands in. The result is clamped to the range a file can hold — the
|
||||
// spec's own guard, and it is `mix`ed back over the base by the mask's alpha, so
|
||||
// a mask at half strength is half of the move rather than the whole of it.
|
||||
//
|
||||
// `blur` is the frame's bilateral reference (the same one CLARITY and DEHAZE use
|
||||
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
|
||||
// shader was built without the second child, and then the two spatial knobs are
|
||||
// left out of the pass — the caller only builds that child for masks that ask
|
||||
// for them (masksHaveSpatial).
|
||||
const adjustFn = (spatial: boolean) => `
|
||||
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||
c = c * half(pow(2.0, a.x));
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
half l = dot(c, half3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(half3(l), c, half(1.0 + a.z));
|
||||
return clamp(c, half3(0.0), half3(1.0));
|
||||
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
// The spec's soft masks, computed in float and narrowed: smoothstep on half
|
||||
// is one more type the shader does not have to guess at.
|
||||
float lf = clamp(float(l), 0.0, 1.0);
|
||||
float ms = 1.0 - smoothstep(0.0, 0.5, lf);
|
||||
float mh = smoothstep(0.5, 1.0, lf);
|
||||
// Half a stop of tone at the knob's own ceiling, the weight the frame-wide
|
||||
// HIGHLIGHT/SHADOW pass uses; + lifts, - pulls back, and the two masks cannot
|
||||
// both be 1 at the same pixel.
|
||||
c = clamp(c + half3(half(0.5 * (tone.y * ms - tone.x * mh))), half3(0.0), half3(1.0));
|
||||
c = c * half(1.0 + 0.5 * tone.z * mh);
|
||||
c = c * half(1.0 + 0.5 * tone.w * ms);
|
||||
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(half3(nl), c, half(1.0 + a.z));
|
||||
${spatial ? ` if (fx.x != 0.0) {
|
||||
// CLARITY (doc section 3.1): the pixel against its own blurred surroundings.
|
||||
c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0));
|
||||
}
|
||||
if (fx.y != 0.0) {
|
||||
// DEHAZE (doc section 3.2): the dark channel of the patch is the haze.
|
||||
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
|
||||
half dark = min(min(blur.r / aa.r, blur.g / aa.g), blur.b / aa.b);
|
||||
half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(1.0));
|
||||
c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0));
|
||||
}
|
||||
` : ''} return clamp(c, half3(0.0), half3(1.0));
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -123,7 +201,7 @@ half3 maskAdjust(half3 c, float3 a) {
|
||||
// product, and its zero-length guard is the filter in readMasks rather than a
|
||||
// branch here: a mask with no length is not a mask. A radial mask's alpha is
|
||||
// 1 inside the feather and 0 at the rim.
|
||||
const maskBlock = (i: number) => `
|
||||
const maskBlock = (i: number, spatial: boolean) => `
|
||||
{
|
||||
float a = 0.0;
|
||||
if (adj[${i}].w < 0.5) {
|
||||
@@ -140,7 +218,7 @@ const maskBlock = (i: number) => `
|
||||
a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d);
|
||||
}
|
||||
if (a > 0.0) {
|
||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}].xyz), half(a));
|
||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}]${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -152,17 +230,22 @@ const maskBlock = (i: number) => `
|
||||
// a repair laid inside a mask then borrows pixels that already carry the mask's
|
||||
// light, which is what makes it match its surroundings, and a MOSAIC over a mask
|
||||
// hides what the mask left. The frame the masks are of comes in as `size`, the
|
||||
// same uniform HEAL and MOSAIC take.
|
||||
export function gradientMaskSkSL(count: number): string {
|
||||
// same uniform HEAL and MOSAIC take. `spatial` adds the two things only CLARITY
|
||||
// and DEHAZE read: the frame's own blurred reference as a second child, whose
|
||||
// image is in the same coordinates the pass runs in, and the atmospheric light.
|
||||
export function gradientMaskSkSL(count: number, spatial = false): string {
|
||||
return `
|
||||
uniform shader img;
|
||||
uniform float4 rects[${count}];
|
||||
uniform float4 rads[${count}];
|
||||
uniform float4 adj[${count}];
|
||||
uniform float4 tone[${count}];
|
||||
uniform float4 fx[${count}];
|
||||
uniform float4 size;
|
||||
${adjustFn}
|
||||
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
||||
${adjustFn(spatial)}
|
||||
half4 main(float2 pos) {
|
||||
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i)).join('')}
|
||||
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
|
||||
return c;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -179,6 +179,21 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.clarity,
|
||||
set: (v) => ({ clarity: v }),
|
||||
},
|
||||
{
|
||||
// The haze the frame's own pixels carry (dark channel prior, toneShader's
|
||||
// DEHAZE_SKSL): no negative side, because there is no way to put scattered
|
||||
// light back that is honest about where it went. Its chip sits with the
|
||||
// other spatial knob it shares a blurred reference with.
|
||||
key: 'dehaze',
|
||||
label: 'DEHAZE',
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: String,
|
||||
get: (a) => a.dehaze ?? 0,
|
||||
set: (v) => ({ dehaze: v }),
|
||||
},
|
||||
{
|
||||
key: 'sharpening',
|
||||
label: 'SHARPENING',
|
||||
|
||||
@@ -330,6 +330,95 @@ export function clarityUniforms(a: number, pxX: number, pxY: number) {
|
||||
return { a, px: [pxX, pxY] };
|
||||
}
|
||||
|
||||
// CLARITY's reference image B, one axis at a time — the multiple-pass
|
||||
// architecture of ki_n_tr_c_multiple_passes_cho_webgpu.md: a single 15x15 kernel
|
||||
// reads 225 pixels per pixel, a separable pair (1x15 then 15x1) reads 30. The
|
||||
// kernel is the doc's bilateral filter: the gaussian weight falls off along the
|
||||
// axis, and a range weight kills a tap whose colour is nothing like the centre's,
|
||||
// so an edge is not blurred across and the reference does not ghost it.
|
||||
// `dir` is one tap's step in the caller's units (px along ONE axis, the other
|
||||
// component 0), so a preview and the file blur the same fraction of the frame.
|
||||
// SkSL has no dynamic loop bound here, so the 15 taps are the doc's own count.
|
||||
export const CLARITY_BLUR_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float2 dir;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 sum = c.rgb;
|
||||
float total = 1.0;
|
||||
for (int i = 1; i <= 15; i++) {
|
||||
float fi = float(i);
|
||||
float g = exp(-0.5 * (fi / 5.0) * (fi / 5.0));
|
||||
vec4 a1 = src.eval(xy + dir * fi);
|
||||
vec4 a2 = src.eval(xy - dir * fi);
|
||||
vec3 d1 = a1.rgb - c.rgb;
|
||||
vec3 d2 = a2.rgb - c.rgb;
|
||||
float r1 = exp(-dot(d1, d1) * 24.0);
|
||||
float r2 = exp(-dot(d2, d2) * 24.0);
|
||||
sum += g * (r1 * a1.rgb + r2 * a2.rgb);
|
||||
total += g * (r1 + r2);
|
||||
}
|
||||
return vec4(sum / total, c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// CLARITY (positive), pass 3 of the doc's architecture: the frame against its
|
||||
// own blurred reference. `orig + (orig - B) * strength` — the local contrast the
|
||||
// doc asks for, clamped because a file cannot hold more than white. Runs on the
|
||||
// ENCODED pixels like every other grade here (only EXPOSURE_SKSL is linear
|
||||
// light, see colorUtils.exposureStops) — the doc's formula is written for linear
|
||||
// light, and moving the whole renderer there is a bigger change than this pass.
|
||||
export const CLARITY_BLEND_SKSL = `
|
||||
uniform shader original;
|
||||
uniform shader blurred;
|
||||
uniform float strength;
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = original.eval(xy);
|
||||
vec3 b = blurred.eval(xy).rgb;
|
||||
return vec4(clamp(c.rgb + (c.rgb - b) * strength, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
// Strength that keeps CLARITY 10 where the 3x3 kernel had it: that kernel was
|
||||
// `c*(1+4a) - a*sum` with a = 0.8, i.e. `c + 3.2*(c - mean4)`, so the same 3.2
|
||||
// lands the same local contrast through the wider bilateral reference.
|
||||
export const CLARITY_GAIN = 3.2;
|
||||
|
||||
// DEHAZE — raw_parameter_processing_gradient_mask_algorithms.md, section 3.2.
|
||||
// Haze is scattered light: it lifts the DARKEST channel of every patch, which is
|
||||
// the Dark Channel Prior. `haze` is the frame's own bilateral reference (the
|
||||
// patch average the blur already computes), `air` the atmospheric light the
|
||||
// caller estimated from the frame, and the transmission is what is left of the
|
||||
// dark channel once the haze is taken out of it, floored so a flat sky cannot
|
||||
// divide by zero. Then `J = (I - A)/t + A` removes the scattered light and the
|
||||
// division is also the contrast stretch the doc asks for afterwards.
|
||||
export const DEHAZE_SKSL = `
|
||||
uniform shader img;
|
||||
uniform shader haze;
|
||||
uniform float3 air;
|
||||
uniform float amount;
|
||||
uniform float floorT;
|
||||
vec4 main(vec2 xy) {
|
||||
vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0);
|
||||
vec3 b = clamp(haze.eval(xy).rgb, 0.0, 1.0);
|
||||
vec3 a = max(air, vec3(0.05));
|
||||
float dark = min(min(b.r / a.r, b.g / a.g), b.b / a.b);
|
||||
float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0);
|
||||
return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Ray marching the doc's A estimate would need the histogram; the caller reads a
|
||||
// 32x32 copy of the frame instead and takes its brightest dark-channel pixel —
|
||||
// the same 0.1% answer, in one readback (see exportEngine's atmosphericLight).
|
||||
export const DEHAZE_FLOOR_T = 0.1;
|
||||
export const DEHAZE_MAX_OMEGA = 0.95;
|
||||
|
||||
export function dehazeUniformArray(air: [number, number, number], amount: number): number[] {
|
||||
'worklet';
|
||||
return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T];
|
||||
}
|
||||
|
||||
export interface ToneUniforms {
|
||||
// All zero → no tone adjustment needed (caller can skip the shader pass).
|
||||
dr: number; // 0..1
|
||||
|
||||
+101
-18
@@ -16,6 +16,8 @@ import { useI18n } from './i18n/I18nProvider';
|
||||
import { loadEngine, MARK_FACES } from './engine/assets';
|
||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
|
||||
import { developRaw, isRawName } from './engine/rawDevelop';
|
||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||
import { cameraSupported } from './engine/camera';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
@@ -297,6 +299,10 @@ export function Workspace() {
|
||||
...session.recipe,
|
||||
}));
|
||||
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
|
||||
// HOVER PREVIEW: the look of the sim or recipe the pointer is over, laid on the
|
||||
// photo for as long as it stays there. It never becomes `recipe`, so SAVE,
|
||||
// UNDO and every knob still speak about the look the click left behind.
|
||||
const [preview, setPreview] = useState<Recipe | null>(null);
|
||||
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
|
||||
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
|
||||
const [cropRect, setCropRect] = useState<CropRect>(() => session.cropRect ?? DEFAULT_CROP_RECT);
|
||||
@@ -618,16 +624,19 @@ export function Workspace() {
|
||||
.me()
|
||||
.then((r) => alive && setUser(r.user))
|
||||
.catch(() => undefined); // signed out is a valid state — the demo needs no account
|
||||
// ...and the photo half of the session, back out of IndexedDB.
|
||||
// ...and the photo half of the session, back out of IndexedDB. When that is
|
||||
// empty but a RAW is still parked in OPFS, the RAW is what "the last photo"
|
||||
// means: develop it again rather than open on nothing.
|
||||
loadPhoto()
|
||||
.then((photo) => {
|
||||
if (!alive || !photo) return;
|
||||
return adoptPhoto(
|
||||
photo.bytes,
|
||||
photo.name,
|
||||
new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }),
|
||||
true
|
||||
);
|
||||
.then(async (photo) => {
|
||||
if (!alive) return;
|
||||
if (photo) {
|
||||
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
|
||||
}
|
||||
const parked = await readRaw();
|
||||
if (!alive || !parked) return;
|
||||
const jpeg = await developRaw(parked.bytes);
|
||||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
@@ -674,6 +683,14 @@ export function Workspace() {
|
||||
[frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
|
||||
);
|
||||
|
||||
// What the stage paints: the hovered look while the pointer is on a sim or a
|
||||
// recipe chip, the user's own look otherwise. The hovered look brings its
|
||||
// colour alone — the masks, the dust spots and the mosaic of the photo being
|
||||
// edited stay on it, or a pointer crossing a chip row would rub them off.
|
||||
const shownRecipe = preview
|
||||
? { ...preview, adjustments: { ...preview.adjustments, masks, heal: healSpots, mosaic: mosaicSpots } }
|
||||
: recipe;
|
||||
|
||||
useEffect(() => {
|
||||
// While the viewfinder is up it grades the camera's frames with this very
|
||||
// recipe, so a preview behind it would only compete for the same renderer —
|
||||
@@ -689,7 +706,7 @@ export function Workspace() {
|
||||
const options = stageOptions;
|
||||
const out = await renderPhoto({
|
||||
source: previewBytes,
|
||||
recipe,
|
||||
recipe: shownRecipe,
|
||||
// An uncommitted crop is framed on the bare photo, so the overlay
|
||||
// measures exactly what the engine will cut.
|
||||
frameId: frame,
|
||||
@@ -733,7 +750,7 @@ export function Workspace() {
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]);
|
||||
}, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]);
|
||||
|
||||
// The source copy behind previewBytes, re-made only when previewMax moves:
|
||||
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
|
||||
@@ -845,14 +862,17 @@ export function Workspace() {
|
||||
}, [place, t, capturedAt]);
|
||||
|
||||
const adoptPhoto = useCallback(
|
||||
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
|
||||
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false, exifBytes?: Uint8Array) => {
|
||||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||||
const found = await readGps(bytes);
|
||||
const foundIso = await readIso(bytes);
|
||||
// A RAW is decoded to a JPEG with no EXIF of its own, so the tags its
|
||||
// stamps read (date, position, ISO) come off the camera file instead.
|
||||
const tagged = exifBytes ?? bytes;
|
||||
const found = await readGps(tagged);
|
||||
const foundIso = await readIso(tagged);
|
||||
// The photo's own date, held apart from its GPS: a frame with a date but no
|
||||
// position still stamps the date when the position is typed in or asked of
|
||||
// the device (see the two places the clock is fallen back on, below).
|
||||
const taken = await readCapturedAt(bytes);
|
||||
const taken = await readCapturedAt(tagged);
|
||||
setCapturedAt(taken);
|
||||
// The photo's own shape drives the default crop frame, and its own pixels
|
||||
// are what an export is measured against — a 1600px copy would make a
|
||||
@@ -928,10 +948,21 @@ export function Workspace() {
|
||||
setOpenPhotoId(null);
|
||||
try {
|
||||
const bytes = await fileToBytes(file);
|
||||
if (isRawName(file.name)) {
|
||||
// A camera RAW: parked in OPFS whole, developed to the JPEG the studio
|
||||
// works on, and remembered in that same shape by the session — so the
|
||||
// 30MB is read once, not once per reload.
|
||||
await storeRaw(bytes, file.name);
|
||||
const jpeg = await developRaw(bytes);
|
||||
await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
|
||||
void savePhoto(file.name, jpeg);
|
||||
return;
|
||||
}
|
||||
await clearRaw();
|
||||
await adoptPhoto(bytes, file.name, file);
|
||||
void savePhoto(file.name, bytes);
|
||||
} catch {
|
||||
setError(t('err.decode'));
|
||||
setError(isRawName(file.name) ? t('err.raw') : t('err.decode'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -2268,11 +2299,27 @@ export function Workspace() {
|
||||
};
|
||||
};
|
||||
|
||||
// The pair a chip carries when its option is a whole look: the photo takes the
|
||||
// look while the pointer rests on the chip, and gives it back when it leaves.
|
||||
const lookPreview = (look: Recipe): Pick<ChipDef, 'onHover' | 'onHoverEnd'> => ({
|
||||
onHover: () => setPreview(look),
|
||||
onHoverEnd: () => setPreview(null),
|
||||
});
|
||||
|
||||
// PHOTO STYLE's own pair, off the sim the chip names. A PRO sim stays out of
|
||||
// it: a hover that showed its look would hand over what the click gates.
|
||||
const simPreview = (id: string): Pick<ChipDef, 'onHover' | 'onHoverEnd'> => {
|
||||
const sim = FILM_SIMS.find((s) => s.id === id);
|
||||
if (!sim || (PRO_SIMS.includes(sim.id) && !pro)) return {};
|
||||
return lookPreview(filmSimToRecipe(sim));
|
||||
};
|
||||
|
||||
const choiceChips = (
|
||||
prefix: string,
|
||||
options: { v: string; d: string; pro?: boolean }[],
|
||||
current: string,
|
||||
onPick: (v: string) => void
|
||||
onPick: (v: string) => void,
|
||||
hover?: (v: string) => Partial<ChipDef>
|
||||
): ChipDef[] =>
|
||||
options.map((o) => ({
|
||||
key: `${prefix}:${o.v}`,
|
||||
@@ -2280,6 +2327,7 @@ export function Workspace() {
|
||||
active: current === o.v,
|
||||
pro: o.pro,
|
||||
onClick: () => onPick(o.v),
|
||||
...(hover?.(o.v) ?? {}),
|
||||
}));
|
||||
|
||||
const wmOn = useGeotag || markOn;
|
||||
@@ -2352,6 +2400,31 @@ export function Workspace() {
|
||||
// app's -10..+10, which the shader reads as the spec's -1..+1; and a radial
|
||||
// mask adds the feather it fades over — the percentage of its own axis the
|
||||
// alpha holds full before it dies at the rim.
|
||||
// The spec's section 4 knobs, one row each: HIGHLIGHT and SHADOW through the
|
||||
// soft masks the shader builds from the pixel's own luma, WHITE and BLACK as a
|
||||
// gain on the end each one owns, and the two spatial ones — CLARITY against the
|
||||
// frame's blurred reference, DEHAZE through the dark channel. All six are the
|
||||
// same -10..+10 row, so one factory builds them; DEHAZE is the one knob with
|
||||
// nothing to do on the negative side.
|
||||
const maskKnobRow = (
|
||||
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze',
|
||||
label: string
|
||||
): SliderDef => {
|
||||
const v = selMask?.[key] ?? 0;
|
||||
return {
|
||||
key: `mask-${key}`,
|
||||
label,
|
||||
value: v,
|
||||
min: key === 'dehaze' ? 0 : -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
display: v > 0 ? `+${v}` : String(v),
|
||||
edited: v !== 0,
|
||||
onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) } as Partial<GradientMask>),
|
||||
onReset: () => setMaskKnob({ [key]: 0 } as Partial<GradientMask>),
|
||||
};
|
||||
};
|
||||
|
||||
const maskRulers: SliderDef[] = !selMask
|
||||
? []
|
||||
: [
|
||||
@@ -2391,6 +2464,12 @@ export function Workspace() {
|
||||
onChange: (v) => setMaskKnob({ saturation: Math.round(v) }),
|
||||
onReset: () => setMaskKnob({ saturation: 0 }),
|
||||
},
|
||||
maskKnobRow('highlights', 'HIGHLIGHT'),
|
||||
maskKnobRow('shadows', 'SHADOW'),
|
||||
maskKnobRow('whites', 'WHITE'),
|
||||
maskKnobRow('blacks', 'BLACK'),
|
||||
maskKnobRow('clarity', 'CLARITY'),
|
||||
maskKnobRow('dehaze', 'DEHAZE'),
|
||||
...(selMask.kind === 'radial'
|
||||
? [
|
||||
{
|
||||
@@ -2556,6 +2635,7 @@ export function Workspace() {
|
||||
// preset that was merely filed under a new name must not light up,
|
||||
// hence the name check too.
|
||||
active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name,
|
||||
...lookPreview({ ...(entry.recipe as Recipe), name: entry.name }),
|
||||
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
|
||||
}));
|
||||
case 'photos': {
|
||||
@@ -2770,7 +2850,7 @@ export function Workspace() {
|
||||
if (g === 'gradient') return gradientChips;
|
||||
return [
|
||||
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
|
||||
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) =>
|
||||
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick, g === 'style' ? simPreview : undefined).map((c) =>
|
||||
// A hand-straightened frame is no longer on a clean quarter turn: the
|
||||
// angle chips let go the moment the fine angle leaves 0, so the strip
|
||||
// shows which of the two is actually steering the photo.
|
||||
@@ -2835,6 +2915,7 @@ export function Workspace() {
|
||||
key: `saved-${entry.id}`,
|
||||
label: entry.name,
|
||||
active: recipe.name === entry.name,
|
||||
...lookPreview({ ...(entry.recipe as Recipe), name: entry.name }),
|
||||
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
|
||||
})),
|
||||
...created
|
||||
@@ -2844,6 +2925,7 @@ export function Workspace() {
|
||||
label: entry.name,
|
||||
active: recipe.id === entry.id,
|
||||
drag: entry.id,
|
||||
...lookPreview(entry),
|
||||
onClick: () => applyRecipe(entry),
|
||||
})),
|
||||
...DEFAULT_RECIPES.map((preset) => ({
|
||||
@@ -2851,6 +2933,7 @@ export function Workspace() {
|
||||
label: preset.name,
|
||||
active: recipe.name === preset.name && !recipe.isCustom,
|
||||
drag: preset.id,
|
||||
...lookPreview(preset),
|
||||
onClick: () => applyRecipe({ ...preset }),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// RAW → JPEG on the client, so a camera's own file opens in the studio without
|
||||
// a DNG converter in the middle (see native_raw_processing_opfs_architecture.md).
|
||||
//
|
||||
// LibRaw demosaics in its own worker; what comes back is linear camera data,
|
||||
// which this file turns into the sRGB the rest of the pipeline expects. The
|
||||
// whole thing is measured against a real 26MP Sony ARW — the settings below are
|
||||
// the ones that gave the correct colours there:
|
||||
// - noAutoScale + useCameraWb:false + noAutoBright + gamm [1,1] keep LibRaw out
|
||||
// of white balance and tone, so `cam_mul` and `rgb_cam` can be applied here
|
||||
// exactly once.
|
||||
// - halfSize: 26MP → 6.5MP. ponytail: drop it for full resolution if a user
|
||||
// ever asks for a print from the RAW; the develop pass is the whole cost.
|
||||
//
|
||||
// The band loop exists because a single Float32 copy of the whole plane would be
|
||||
// ~100MB. Each band is decoded, normalised and drawn before the next is read.
|
||||
// ponytail: the develop itself is one full-resolution shader draw per band, on
|
||||
// the main thread (Skia is not available in the RAW worker). Move it to a worker
|
||||
// with an OffscreenCanvas if the develop ever blocks the UI visibly.
|
||||
import LibRaw from 'libraw-wasm';
|
||||
import { Skia } from './skiaShim';
|
||||
|
||||
// What `imageData()` returns for the settings below: 16-bit, 3 channels, with
|
||||
// the black level still in it — hence the two normalisations in the shader.
|
||||
const SETTINGS = {
|
||||
halfSize: true,
|
||||
outputBps: 16,
|
||||
outputColor: 0,
|
||||
noAutoScale: true,
|
||||
useCameraWb: false,
|
||||
noAutoBright: true,
|
||||
gamm: [1, 1] as [number, number],
|
||||
userQual: 3,
|
||||
highlight: 0,
|
||||
};
|
||||
|
||||
// 16-bit samples, but everything below is [0,1] — Skia reads an F32 image as
|
||||
// already-normalised colour, and a raw 0..65535 plane comes back black.
|
||||
const SAMPLE_MAX = 65535;
|
||||
// Pixels per band, held under the point where the Float32 copy dominates the
|
||||
// memory the page is allowed to use.
|
||||
const BAND_PIXELS = 2_000_000;
|
||||
|
||||
const RAW_DEVELOP_SKSL = `
|
||||
uniform shader raw;
|
||||
uniform float4 black; // (black level, 1 / (white level - black level))
|
||||
uniform float4 mul; // cam_mul, green-normalised
|
||||
uniform float4 m0; // camera -> sRGB, the first three columns of rgb_cam
|
||||
uniform float4 m1;
|
||||
uniform float4 m2;
|
||||
uniform float4 crop; // (y offset of this band, 0, 0, 0)
|
||||
|
||||
float3 encode(float3 x) {
|
||||
x = clamp(x, 0.0, 1.0);
|
||||
return mix(x * 12.92, 1.055 * pow(x, float3(1.0 / 2.4)) - 0.055, step(float3(0.0031308), x));
|
||||
}
|
||||
|
||||
half4 main(float2 pos) {
|
||||
float4 p = raw.eval(float2(pos.x, pos.y - crop.x));
|
||||
float3 lin = clamp((p.rgb - black.rgb) * black.a, 0.0, 1.0) * mul.rgb;
|
||||
float3 rgb = float3(dot(m0.xyz, lin), dot(m1.xyz, lin), dot(m2.xyz, lin));
|
||||
return half4(half3(encode(rgb)), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// The extensions LibRaw is compiled for. Not `tif`: a TIFF in a camera folder is
|
||||
// as likely to be an export as a RAW.
|
||||
const RAW_EXT = [
|
||||
'arw', 'srf', 'sr2', 'cr2', 'cr3', 'crw', 'nef', 'nrw', 'dng', 'orf', 'raf',
|
||||
'rw2', 'pef', 'srw', 'raw', 'rwl', '3fr', 'fff', 'iiq', 'mos', 'mrw', 'x3f',
|
||||
];
|
||||
|
||||
export const RAW_ACCEPT = RAW_EXT.map((e) => `.${e}`).join(',');
|
||||
|
||||
export function isRawName(name: string): boolean {
|
||||
const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
|
||||
return name.includes('.') && RAW_EXT.includes(ext);
|
||||
}
|
||||
|
||||
export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
const raw = new LibRaw();
|
||||
try {
|
||||
// LibRaw copies the buffer it is handed, so the caller's bytes stay intact.
|
||||
await raw.open(bytes as unknown as BufferSource, SETTINGS);
|
||||
const meta = await raw.metadata(true);
|
||||
const img = await raw.imageData();
|
||||
const cd = meta?.color_data;
|
||||
if (!img || !cd?.cam_mul || !cd.rgb_cam) throw new Error('RAW has no colour data');
|
||||
|
||||
const { width: w, height: h } = img;
|
||||
const data = img.data as Uint16Array;
|
||||
if (!w || !h) throw new Error('RAW decoded to nothing');
|
||||
|
||||
const surface = Skia.Surface.MakeOffscreen(w, h) ?? Skia.Surface.Make(w, h);
|
||||
if (!surface) throw new Error('no surface for the develop');
|
||||
const effect = Skia.RuntimeEffect.Make(RAW_DEVELOP_SKSL);
|
||||
if (!effect) throw new Error('develop shader failed to compile');
|
||||
|
||||
const green = cd.cam_mul[1] || 1;
|
||||
const mul = cd.cam_mul.map((v) => v / green);
|
||||
const row = (i: number) => cd.rgb_cam[i].slice(0, 3);
|
||||
const [r0, r1, r2] = [row(0), row(1), row(2)];
|
||||
|
||||
const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w)));
|
||||
const f32 = new Float32Array(w * bandH * 4);
|
||||
for (let y0 = 0; y0 < h; y0 += bandH) {
|
||||
const rows = Math.min(bandH, h - y0);
|
||||
let o = 0;
|
||||
for (let i = y0 * w * 3, end = (y0 + rows) * w * 3; i < end; i += 3) {
|
||||
f32[o++] = data[i] / SAMPLE_MAX;
|
||||
f32[o++] = data[i + 1] / SAMPLE_MAX;
|
||||
f32[o++] = data[i + 2] / SAMPLE_MAX;
|
||||
f32[o++] = 1;
|
||||
}
|
||||
const band = Skia.Image.MakeImage(
|
||||
{ width: w, height: rows, colorType: Skia.ColorType.RGBA_F32, alphaType: Skia.AlphaType.Unpremul },
|
||||
new Uint8Array(f32.buffer, 0, w * rows * 16),
|
||||
w * 16
|
||||
);
|
||||
if (!band) throw new Error('band image failed');
|
||||
const child = band.makeShaderOptions(
|
||||
Skia.TileMode.Clamp,
|
||||
Skia.TileMode.Clamp,
|
||||
Skia.FilterMode.Nearest,
|
||||
Skia.MipmapMode.None
|
||||
);
|
||||
const uniforms = new Float32Array([
|
||||
cd.black / SAMPLE_MAX, cd.black / SAMPLE_MAX, cd.black / SAMPLE_MAX,
|
||||
SAMPLE_MAX / (cd.maximum - cd.black),
|
||||
mul[0], mul[1], mul[2], 0,
|
||||
r0[0], r0[1], r0[2], 0,
|
||||
r1[0], r1[1], r1[2], 0,
|
||||
r2[0], r2[1], r2[2], 0,
|
||||
y0, 0, 0, 0,
|
||||
]);
|
||||
const shader = effect.makeShaderWithChildren(uniforms, [child]);
|
||||
const paint = Skia.Paint();
|
||||
paint.setShader(shader);
|
||||
surface.getCanvas().drawRect(Skia.XYWHRect(0, y0, w, rows), paint);
|
||||
surface.flush();
|
||||
paint.delete();
|
||||
shader.delete();
|
||||
child.delete();
|
||||
band.delete();
|
||||
}
|
||||
|
||||
const jpeg = surface.makeImageSnapshot().encodeToBytes(Skia.ImageFormat.JPEG, 92);
|
||||
surface.dispose();
|
||||
if (!jpeg?.length) throw new Error('develop produced no bytes');
|
||||
return jpeg;
|
||||
} finally {
|
||||
raw.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// The RAW the current photo came from, parked in the origin private file system
|
||||
// so the studio can go back to it instead of asking for 30MB a second time (see
|
||||
// native_raw_processing_opfs_architecture.md). One slot only: the studio holds
|
||||
// one photo, and the previous RAW is stale the moment the next one lands.
|
||||
//
|
||||
// The session keeps the *developed* JPEG, so the ordinary reload path never
|
||||
// touches this — it is the fallback for an empty IndexedDB, and the file a
|
||||
// future full-resolution or different-settings develop would read.
|
||||
//
|
||||
// ponytail: OPFS's own sync-access handle would let LibRaw read in place, but
|
||||
// LibRaw.open takes a BufferSource, so every path here reads a Blob anyway.
|
||||
// Switch to createSyncAccessHandle if that API ever grows a worker-side reader.
|
||||
const FILE = 'current_image.raw';
|
||||
const NAME = 'current_image.name';
|
||||
|
||||
async function opfs(name: string, create: boolean): Promise<FileSystemFileHandle | null> {
|
||||
const root = await navigator.storage?.getDirectory?.();
|
||||
if (!root) return null;
|
||||
return root.getFileHandle(name, { create });
|
||||
}
|
||||
|
||||
async function write(name: string, data: BlobPart): Promise<void> {
|
||||
const fh = await opfs(name, true);
|
||||
if (!fh) return;
|
||||
const w = await fh.createWritable();
|
||||
await w.write(data);
|
||||
await w.close();
|
||||
}
|
||||
|
||||
export async function storeRaw(bytes: Uint8Array, fileName: string): Promise<void> {
|
||||
try {
|
||||
await write(FILE, bytes as unknown as BufferSource);
|
||||
await write(NAME, fileName);
|
||||
} catch {
|
||||
// No OPFS (private mode, older browser): the photo still develops, it just
|
||||
// cannot be recovered after a reload. Nothing here is worth failing a load.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRaw(): Promise<{ name: string; bytes: Uint8Array } | null> {
|
||||
try {
|
||||
const fh = await opfs(FILE, false);
|
||||
if (!fh) return null;
|
||||
const nameHandle = await opfs(NAME, false);
|
||||
const name = nameHandle ? (await (await nameHandle.getFile()).text()).trim() : FILE;
|
||||
return { name: name || FILE, bytes: new Uint8Array(await (await fh.getFile()).arrayBuffer()) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearRaw(): Promise<void> {
|
||||
try {
|
||||
const root = await navigator.storage?.getDirectory?.();
|
||||
await root?.removeEntry(FILE).catch(() => undefined);
|
||||
await root?.removeEntry(NAME).catch(() => undefined);
|
||||
} catch {
|
||||
// Absent is the desired state.
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,7 @@ export const en: Dict = {
|
||||
'err.noWebgl':
|
||||
'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).',
|
||||
'err.decode': 'Could not read this image. Try another JPG or PNG.',
|
||||
'err.raw': 'Could not develop this RAW file. Try again, or use its JPG.',
|
||||
'err.generic': 'Something went wrong: {msg}',
|
||||
'err.offline': 'Could not reach the server.',
|
||||
|
||||
|
||||
@@ -333,6 +333,7 @@ export const vi = {
|
||||
'err.noWebgl':
|
||||
'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).',
|
||||
'err.decode': 'Không đọc được ảnh này. Thử JPG hoặc PNG khác.',
|
||||
'err.raw': 'Không tráng được file RAW này. Thử lại, hoặc dùng bản JPG.',
|
||||
'err.generic': 'Có lỗi xảy ra: {msg}',
|
||||
'err.offline': 'Không gọi được máy chủ.',
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface ChipDef {
|
||||
// Present when the chip itself can be dragged somewhere: the payload the drop
|
||||
// target reads back (the recipe's id, for the FAVORITED rail button).
|
||||
drag?: string;
|
||||
// HOVER PREVIEW: a look the pointer lays on the photo before any click. Only
|
||||
// the chips that carry a whole look (a sim, a recipe) have one; the pair is
|
||||
// always both or neither, so leaving the chip always takes it back off.
|
||||
onHover?: () => void;
|
||||
onHoverEnd?: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
@@ -64,7 +69,16 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={chip.onClick}
|
||||
onMouseEnter={chip.onHover}
|
||||
onMouseLeave={chip.onHoverEnd}
|
||||
// The click takes the preview off first: a click that applies the look
|
||||
// (and closes the column the chip was in) would otherwise leave a
|
||||
// preview nothing is left to take off — and every later knob would
|
||||
// move the recipe under a photo still painted from the preview.
|
||||
onClick={() => {
|
||||
chip.onHoverEnd?.();
|
||||
chip.onClick();
|
||||
}}
|
||||
>
|
||||
{chip.color ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
|
||||
{chip.label}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { MIN_CROP_FRAC, type CropRect, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
|
||||
import { bitmapReader, samplePixel } from '../engine/imageOps';
|
||||
import { RAW_ACCEPT } from '../engine/rawDevelop';
|
||||
import { findHealSource } from '../../shared/utils/heal';
|
||||
import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask';
|
||||
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
|
||||
@@ -2048,7 +2049,7 @@ export function ImageStage({
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={`image/*,${RAW_ACCEPT}`}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -23,6 +23,14 @@ export default defineConfig({
|
||||
// runtime from /wasm/, never bundled.
|
||||
chunkSizeWarningLimit: 9000,
|
||||
},
|
||||
optimizeDeps: {
|
||||
// libraw-wasm starts its own worker with `new URL('./worker.js',
|
||||
// import.meta.url)`. Pre-bundling moves the module into .vite/deps/, where
|
||||
// that sibling does not exist and the worker never loads. Excluding it keeps
|
||||
// the package's own relative layout; the production build resolves it
|
||||
// normally either way.
|
||||
exclude: ['libraw-wasm'],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': process.env.API_ORIGIN ?? 'http://localhost:3000' },
|
||||
|
||||
Reference in New Issue
Block a user