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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user