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:
+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 }),
|
||||
})),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user