feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings

- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
This commit is contained in:
2026-10-12 07:59:24 +07:00
parent 807c271b6b
commit 97fd181cfa
25 changed files with 2402 additions and 616 deletions
+440 -10
View File
@@ -22,9 +22,11 @@ import { sharePhoto, type ShareSite } from './engine/share';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
import { immichConfig, uploadToImmich, type ImmichServer } from './engine/immich';
import { offerInstall } from './pwa/install';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
import { analyzeCustomFrame, clearCustomFrame, loadCustomFrame, saveCustomFrame, type CustomFrameData } from './engine/customFrame';
import { keep } from './remember';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe } from './api';
@@ -122,6 +124,7 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'wallframe-landscape', label: 'frame.wallLand' },
{ id: 'old-film', label: 'frame.oldFilm' },
{ id: 'old-film-portrait', label: 'frame.oldFilmPort' },
{ id: 'custom', label: 'frame.custom' },
];
// The recipe a saved photo's base is rendered through: the neutral stock —
@@ -348,11 +351,7 @@ interface Look {
export function Workspace() {
const { t } = useI18n();
const [forceView] = useState<'auto' | 'mobile' | 'desktop'>(() => {
return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto';
});
const detectedMobile = useIsMobile(860);
const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile);
const isMobile = useIsMobile(860);
// Session restore: read once, synchronously, so the first paint already holds
// the user's own settings instead of a flash of the defaults. The photo comes
@@ -397,6 +396,76 @@ export function Workspace() {
const [preview, setPreview] = useState<Recipe | null>(null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [framePanX, setFramePanX] = useState<number>(() => session.framePanX ?? 0);
const [customFrame, setCustomFrame] = useState<CustomFrameData | null>(null);
const frameFileInputRef = useRef<HTMLInputElement>(null);
const saveCustomFrameTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let alive = true;
loadCustomFrame().then((saved) => {
if (alive && saved) setCustomFrame(saved);
});
return () => {
alive = false;
};
}, []);
const updateCustomFrame = useCallback((updater: (prev: CustomFrameData) => CustomFrameData) => {
setCustomFrame((prev) => {
if (!prev) return null;
const next = updater(prev);
if (saveCustomFrameTimer.current) clearTimeout(saveCustomFrameTimer.current);
saveCustomFrameTimer.current = setTimeout(() => {
void saveCustomFrame(next);
}, 400);
return next;
});
}, []);
const onImportFrameFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setBusy(t('frame.analyzing'));
const analyzed = await analyzeCustomFrame(file);
await saveCustomFrame(analyzed);
setCustomFrame(analyzed);
setFrameId('custom');
} catch (err: any) {
setError(err?.message || 'Failed to load custom frame');
} finally {
setBusy(null);
if (e.target) e.target.value = '';
}
};
const onRemoveCustomFrame = async () => {
await clearCustomFrame();
setCustomFrame(null);
if (frameId === 'custom') setFrameId('none');
};
const onCustomFramePan = useCallback(
(dx: number, dy: number) => {
updateCustomFrame((cf) => {
const nextPanX = Math.max(-1000, Math.min(1000, Math.round(cf.panX + dx)));
const nextPanY = Math.max(-1000, Math.min(1000, Math.round(cf.panY + dy)));
return { ...cf, panX: nextPanX, panY: nextPanY };
});
},
[updateCustomFrame]
);
const onCustomFrameZoom = useCallback(
(delta: number) => {
updateCustomFrame((cf) => {
const factor = delta > 0 ? 0.95 : 1.05;
const nextScale = Math.max(0.2, Math.min(5.0, Number((cf.scale * factor).toFixed(3))));
return { ...cf, scale: nextScale };
});
},
[updateCustomFrame]
);
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => session.cropRect ?? DEFAULT_CROP_RECT);
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
@@ -678,6 +747,26 @@ export function Workspace() {
// for them (server.ts), so the studio stops counting at the same place. null
// means "no cap" all the way down to SAVE PHOTO's label.
const photoCap = user?.admin ? null : MAX_PHOTOS;
// Immich Cloud servers connected to this account
const [immichServers, setImmichServers] = useState<ImmichServer[]>([]);
const [uploadingImmich, setUploadingImmich] = useState(false);
const [toast, setToast] = useState<string | null>(null);
useEffect(() => {
if (member) {
immichConfig().then((cfg) => setImmichServers(cfg.servers)).catch(() => {});
} else {
setImmichServers([]);
}
}, [member]);
const hasImmich = immichServers.length > 0;
const showToast = useCallback((msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 4000);
}, []);
// What the stamp's name line prints: the visitor's own PLACE NAME wins over
// whatever the fix carried, because neither a phone's position nor a photo's
// EXIF ever brings one, and typing it after locating must still show up.
@@ -838,7 +927,7 @@ export function Workspace() {
const parked = await readRaw();
if (!alive) return;
if (!parked) return photo ? show(photo) : undefined;
const jpeg = await developRaw(parked.bytes);
const jpeg = await developRaw(parked.bytes, parked.name);
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
})
.catch(() => undefined);
@@ -881,6 +970,16 @@ export function Workspace() {
const stageOptions = useMemo(
() => ({
wallframeLandscape: frameId === 'wallframe-landscape',
customFrame:
frameId === 'custom' && customFrame
? {
bytes: customFrame.bytes,
hole: customFrame.hole,
scale: customFrame.scale,
panX: customFrame.panX,
panY: customFrame.panY,
}
: null,
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
@@ -891,7 +990,7 @@ export function Workspace() {
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
[frameId, customFrame, 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
@@ -1248,7 +1347,7 @@ export function Workspace() {
// ponytail: the reload pays one develop. Stamp the session copy with
// the build and keep it while the stamp holds, if that ever hurts.
await storeRaw(bytes, file.name);
const jpeg = await developRaw(bytes);
const jpeg = await developRaw(bytes, file.name);
await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
void forgetPhoto();
return;
@@ -1925,6 +2024,16 @@ export function Workspace() {
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
customFrame:
frameId === 'custom' && customFrame
? {
bytes: customFrame.bytes,
hole: customFrame.hole,
scale: customFrame.scale,
panX: customFrame.panX,
panY: customFrame.panY,
}
: null,
dpi: 300,
sharpen: true,
photoRotation: rotation,
@@ -1980,6 +2089,107 @@ export function Workspace() {
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, exportCap, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, member, promptPro, proLookInUse, t, strip, photoSettings]);
// Upload the currently edited photo from Studio straight to Immich Cloud
const uploadPhotoToImmich = useCallback(async (target: number | null = null): Promise<boolean> => {
if (!engine || !source) return false;
if (!immichServers.length) {
setError(t('immich.noServer'));
return false;
}
if (!pro && proLookInUse()) {
promptPro();
return false;
}
setUploadingImmich(true);
setBusy(t('immich.uploading'));
setError(null);
try {
const tier = pro ? TIERS.pro : member ? TIERS.member : TIERS.guest;
const wanted = target === null ? null : Math.min(target, exportCap ?? target);
const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest);
let src = source.bytes;
if (wanted === null) {
if (tier.maxExportDim && sourceLongest > tier.maxExportDim) {
src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95);
}
} else if (needLongest > sourceLongest * MODEL_GAIN) {
src = await upscaleJpeg(source.bytes, needLongest, ({ done, total }) =>
setBusy(t('act.upscaling', { done, total }))
);
} else if (needLongest !== sourceLongest) {
src = await scaledJpeg(source.bytes, needLongest);
}
const out = await renderPhoto({
source: src,
recipe,
frameId: cropEditing ? 'none' : frameId,
useGeotag,
gpsInfo,
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
customFrame:
frameId === 'custom' && customFrame
? {
bytes: customFrame.bytes,
hole: customFrame.hole,
scale: customFrame.scale,
panX: customFrame.panX,
panY: customFrame.panY,
}
: null,
dpi: 300,
sharpen: true,
photoRotation: rotation,
photoStraighten: straighten,
photoFlipH: flipH,
photoFlipV: flipV,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText
? { text: markText, ...markStyle }
: tier.watermark
? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
: null,
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
},
});
if (!out) throw new Error('render returned null');
const bytes = writeJpegExif(
out.bytes,
{
dateTime: new Date(),
gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null,
width: out.width,
height: out.height,
dpi: 300,
locationName: place || gps?.locality || null,
imageDescription: strip.tag,
userComment: JSON.stringify(photoSettings),
},
source.bytes
);
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
const name = `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`;
const primaryServer = immichServers[0];
const res = await uploadToImmich(bytes, name, primaryServer?.id);
if (res.ok) {
showToast(t('immich.uploadSuccess'));
setChoosingExport(false);
return true;
} else {
throw new Error(res.error || 'Upload error');
}
} catch (err) {
setError(t('immich.uploadFailed', { msg: String(err) }));
return false;
} finally {
setUploadingImmich(false);
setBusy(null);
}
}, [engine, source, immichServers, pro, proLookInUse, promptPro, member, exportCap, sourceLongest, editedLongest, recipe, cropEditing, frameId, useGeotag, gpsInfo, customFrame, rotation, straighten, flipH, flipV, cropLive, cropRect, markOn, markText, markStyle, gps, place, strip, photoSettings, t, showToast]);
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
// The upload is left clean (no burned strip) so the landing reel can draw its
// own `.lp-shot` frame around it; the labels are stored alongside instead.
@@ -2026,6 +2236,16 @@ export function Workspace() {
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
customFrame:
frameId === 'custom' && customFrame
? {
bytes: customFrame.bytes,
hole: customFrame.hole,
scale: customFrame.scale,
panX: customFrame.panX,
panY: customFrame.panY,
}
: null,
dpi: 300,
sharpen: true,
photoRotation: rotation,
@@ -2950,10 +3170,10 @@ export function Workspace() {
// The frame row is the frames alone. CROP, ROTATE and WATERMARK used to close
// it — a frame is the look, and cutting, levelling and signing the print are
// moves made on the frame, which is what TOOLS is (see `shapeChips`).
const framesRow = (): ChipDef[] =>
const framesRow = (): ChipDef[] => {
// NO FRAME is not a chip of its own: clicking the frame already on the
// photo takes it off again, so the row carries one entry per frame.
FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({
const list: ChipDef[] = FRAMES.filter((frame) => frame.id !== 'none' && frame.id !== 'custom').map((frame) => ({
key: `fr:${frame.id}`,
label: t(frame.label),
active: frameId === frame.id,
@@ -2970,6 +3190,32 @@ export function Workspace() {
},
}));
if (customFrame) {
list.push({
key: 'fr:custom',
label: customFrame.name || t('frame.custom'),
active: frameId === 'custom',
onClick: () => {
setOpenParam(null);
setOpenGroup(null);
remember();
setFrameId(frameId === 'custom' ? 'none' : 'custom');
},
});
}
list.push({
key: 'fr:import-custom',
label: t('frame.import'),
active: false,
onClick: () => {
frameFileInputRef.current?.click();
},
});
return list;
};
// The three moves that shape the print rather than tint it: the frame is cut
// to a shape, turned to the straight, and signed. Each opens its own strip in
// the last column, the same strip it opened from the frame row.
@@ -3693,6 +3939,9 @@ export function Workspace() {
onReset={reset}
onSavePhoto={() => void saveThenMaybeClear()}
onExport={openExportPicker}
hasImmich={hasImmich}
uploadingImmich={uploadingImmich}
onUploadImmich={() => void uploadPhotoToImmich()}
photoCount={myPhotos.length}
photoMax={photoCap}
onAuth={() => setAuthMode('login')}
@@ -3706,6 +3955,33 @@ export function Workspace() {
}}
/>
{/* Toast notification */}
{toast && (
<div
style={{
position: 'fixed',
top: '56px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(24, 24, 27, 0.95)',
border: '1px solid #4285f4',
color: '#fff',
padding: '10px 20px',
borderRadius: '9999px',
fontSize: '13px',
fontWeight: 500,
zIndex: 9999,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>☁️</span>
<span>{toast}</span>
</div>
)}
{/* The trial notice: one band under the header, the same shape the verify
bar below takes. Copy only — nothing here is dismissible, because the
date IS the message. */}
@@ -4075,6 +4351,153 @@ export function Workspace() {
</div>
) : null}
{/* CUSTOM FRAME controls column: thumbnail, fit/fill/center, scale & pan sliders, replace & delete */}
{tab === 'frame' && frameId === 'custom' && customFrame ? (
<div className="col col-sub strip custom-frame-controls-col" data-col="custom-frame">
<div className="custom-frame-header">
<div className="custom-frame-thumb-wrap">
<img src={customFrame.dataUrl} alt={customFrame.name} className="custom-frame-thumb" />
</div>
<div className="custom-frame-meta">
<div className="custom-frame-title">{customFrame.name}</div>
<div className="custom-frame-res">
{customFrame.width} × {customFrame.height}
</div>
</div>
</div>
<div className="custom-frame-action-btns">
<button
type="button"
className="btn"
onClick={() => {
if (!photoDims) {
updateCustomFrame((cf) => ({ ...cf, scale: 1, panX: 0, panY: 0 }));
return;
}
const pa = photoDims.w / photoDims.h;
const ha = (customFrame.hole.w * customFrame.width) / (customFrame.hole.h * customFrame.height);
const fitScale = Number(Math.min(1, pa > ha ? ha / pa : pa / ha).toFixed(3));
updateCustomFrame((cf) => ({ ...cf, scale: fitScale, panX: 0, panY: 0 }));
}}
>
{t('frame.fit')}
</button>
<button
type="button"
className="btn"
onClick={() => {
updateCustomFrame((cf) => ({ ...cf, scale: 1, panX: 0, panY: 0 }));
}}
>
{t('frame.fill')}
</button>
<button
type="button"
className="btn"
onClick={() => {
updateCustomFrame((cf) => ({ ...cf, panX: 0, panY: 0 }));
}}
>
{t('frame.center')}
</button>
</div>
<div className="custom-frame-slider-group">
<div className="custom-frame-slider-header">
<span className="lbl">{t('frame.zoom')}</span>
<span className="val">{Math.round(customFrame.scale * 100)}%</span>
</div>
<div className="custom-frame-slider-row">
<input
type="range"
className="custom-frame-range"
min="0.2"
max="4.0"
step="0.02"
value={customFrame.scale}
onChange={(e) => {
const sc = parseFloat(e.target.value);
updateCustomFrame((cf) => ({ ...cf, scale: sc }));
}}
/>
</div>
</div>
<div className="custom-frame-slider-group">
<div className="custom-frame-slider-header">
<span className="lbl">{t('frame.panX')}</span>
<span className="val">{Math.round(customFrame.panX)} px</span>
</div>
<div className="custom-frame-slider-row">
<input
type="range"
className="custom-frame-range"
min="-500"
max="500"
step="1"
value={customFrame.panX}
onChange={(e) => {
const px = parseFloat(e.target.value);
updateCustomFrame((cf) => ({ ...cf, panX: px }));
}}
/>
</div>
</div>
<div className="custom-frame-slider-group">
<div className="custom-frame-slider-header">
<span className="lbl">{t('frame.panY')}</span>
<span className="val">{Math.round(customFrame.panY)} px</span>
</div>
<div className="custom-frame-slider-row">
<input
type="range"
className="custom-frame-range"
min="-500"
max="500"
step="1"
value={customFrame.panY}
onChange={(e) => {
const py = parseFloat(e.target.value);
updateCustomFrame((cf) => ({ ...cf, panY: py }));
}}
/>
</div>
</div>
<p className="custom-frame-hint">{t('frame.dragHint')}</p>
<div className="custom-frame-footer-btns">
<button
type="button"
className="btn"
onClick={() => {
frameFileInputRef.current?.click();
}}
>
{t('frame.replace')}
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => void onRemoveCustomFrame()}
>
{t('frame.remove')}
</button>
</div>
</div>
) : null}
{/* Hidden input for importing custom frames */}
<input
type="file"
ref={frameFileInputRef}
accept="image/png,image/webp,image/svg+xml,image/*"
style={{ display: 'none' }}
onChange={(e) => void onImportFrameFile(e)}
/>
{/* column 4 — the open ruler, sliders stacked */}
{slider ? (
<div className="col col-slider" data-col="slider">
@@ -4131,6 +4554,9 @@ export function Workspace() {
cropWH={cropWH}
dims={stageDims}
frameId={cropEditing ? 'none' : frameId}
customFrame={frameId === 'custom' ? customFrame : null}
onCustomFramePan={onCustomFramePan}
onCustomFrameZoom={onCustomFrameZoom}
framePanX={framePanX}
onFramePanXChange={(pan) => {
setFramePanX(pan);
@@ -4304,6 +4730,10 @@ export function Workspace() {
void exportPhoto(null, site);
}}
onClose={() => setChoosingExport(false)}
hasImmich={hasImmich}
onUploadImmich={(target) => {
void uploadPhotoToImmich(target);
}}
/>
) : null}