Add self-contained docker/ stack for the web UI

`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
This commit is contained in:
2026-09-17 17:43:03 +07:00
parent 608901bd0f
commit 8c6e7930db
57 changed files with 9359 additions and 2 deletions
+547
View File
@@ -0,0 +1,547 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ToolRail, type TabId } from './ui/ToolRail';
import { ChipRow, Section, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { useI18n } from './i18n/I18nProvider';
import { loadEngine } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readGps, resizedJpeg } from './engine/imageOps';
import { api, type SavedRecipe, type User } from './api';
import { TIERS } from './config/tiers';
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
import { writeJpegExif } from '../shared/utils/exifWrite';
import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types';
import type { MsgKey } from './i18n/vi';
// Preview renders on a downscaled copy; export always re-renders the original
// bytes, so nothing shown here is baked into the file.
const PREVIEW_MAX = 1600;
const PREVIEW_DEBOUNCE_MS = 120;
const SECTION_TITLE: Record<TabId, MsgKey> = {
presets: 'sec.filmSims',
favorited: 'sec.saved',
light: 'tab.light',
wb: 'tab.wb',
fx: 'tab.fx',
frame: 'sec.frame',
};
const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'none', label: 'frame.none' },
{ id: 'polaroid', label: 'frame.polaroid' },
{ id: 'classic-white', label: 'frame.classic' },
{ id: 'cinematic', label: 'frame.cinematic' },
{ id: 'wallframe', label: 'frame.wall' },
];
// ponytail: nothing here is a rectangle the user drags yet, so only the fixed
// ratios are listed — 'free' (DEFAULT_CROP_RECT + drag on the canvas) lands
// with the crop overlay in W3. The type already carries it.
const CROPS: Exclude<CropRatio, 'free'>[] = ['none', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9'];
const ROTATIONS = [0, 90, 180, 270] as const;
const TEMP_PRESETS: { label: string; kelvin: number }[] = [
{ label: 'AUTO', kelvin: 5500 },
{ label: 'DAYLIGHT', kelvin: 5600 },
{ label: 'DAYLIGHT -3R', kelvin: 5800 },
{ label: 'CLOUDY', kelvin: 6500 },
{ label: 'SHADE', kelvin: 7500 },
{ label: 'TUNGSTEN', kelvin: 3200 },
{ label: 'FLUOR', kelvin: 4000 },
];
interface LoadedSource {
bytes: Uint8Array;
name: string;
url: string;
}
export function Workspace() {
const { t } = useI18n();
const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<LoadedSource | null>(null);
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [rendered, setRendered] = useState<{ w: number; h: number; ms: number } | null>(null);
const [recipe, setRecipe] = useState<Recipe>(() => ({
...DEFAULT_RECIPES[0],
id: 'web-current',
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
}));
const [simId, setSimId] = useState<string | null>(null);
const [frameId, setFrameId] = useState<FrameId>('none');
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>('none');
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0);
const [openParam, setOpenParam] = useState<string | null>(null);
const [tab, setTab] = useState<TabId>('presets');
const [peek, setPeek] = useState(false);
const [gps, setGps] = useState<GPSInfo | null>(null);
const [gpsManual, setGpsManual] = useState({ lat: '', lng: '' });
const [place, setPlace] = useState('');
const [useGeotag, setUseGeotag] = useState(false);
const [markText, setMarkText] = useState('');
const [markOn, setMarkOn] = useState(false);
const [undo, setUndo] = useState<Recipe[]>([]);
const [exporting, setExporting] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
const [authOpen, setAuthOpen] = useState(false);
const renderToken = useRef(0);
// --- boot -----------------------------------------------------------------
useEffect(() => {
let alive = true;
loadEngine()
.then((loaded) => {
if (!alive) return;
setEngine(loaded);
if (!loaded.gpu) setError(t('err.noWebgl'));
})
.catch((err) => alive && setError(String(err)));
api
.me()
.then((r) => alive && setUser(r.user))
.catch(() => undefined); // signed out is a valid state — the demo needs no account
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!user) {
setSaved([]);
return;
}
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
}, [user]);
// --- preview render -------------------------------------------------------
useEffect(() => {
if (!engine || !previewBytes) return;
const token = ++renderToken.current;
const timer = setTimeout(async () => {
setBusy(t('act.exporting'));
const started = performance.now();
try {
const out = await renderPhoto({
source: previewBytes,
recipe,
frameId,
useGeotag,
gpsInfo: gps,
assets: engine.assets,
options: {
sharpen: true,
photoRotation: rotation,
cropWH: crop === 'none' ? undefined : CROP_W_H[crop],
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 } : null,
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null,
},
});
if (token !== renderToken.current) return;
if (!out) throw new Error('render returned null');
const url = URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
setRendered({ w: out.width, h: out.height, ms: Math.round(performance.now() - started) });
} catch (err) {
if (token === renderToken.current) setError(t('err.generic', { msg: String(err) }));
} finally {
if (token === renderToken.current) setBusy(null);
}
}, PREVIEW_DEBOUNCE_MS);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, markOn, markText]);
// --- input ---------------------------------------------------------------
const loadFile = useCallback(
async (file: File) => {
setError(null);
setBusy(t('auth.busy'));
try {
const bytes = await fileToBytes(file);
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
const found = await readGps(bytes);
setSource((prev) => {
if (prev) URL.revokeObjectURL(prev.url);
return { bytes, name: file.name, url: URL.createObjectURL(file) };
});
setPreviewBytes(preview);
setGps(found);
setUseGeotag(!!found);
if (!found) setGpsManual({ lat: '', lng: '' });
} catch {
setError(t('err.decode'));
} finally {
setBusy(null);
}
},
[t]
);
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const file = e.clipboardData?.files?.[0];
if (file) void loadFile(file);
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [loadFile]);
// --- recipe edits ---------------------------------------------------------
const remember = useCallback(() => setUndo((stack) => [...stack.slice(-19), recipe]), [recipe]);
const applyRecipe = useCallback(
(next: Recipe) => {
remember();
setRecipe(next);
setSimId(next.isCustom ? null : next.id);
},
[remember]
);
const setAdjustment = useCallback(
(patch: Partial<Recipe['adjustments']>) => {
setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } }));
},
[]
);
const reset = useCallback(() => {
remember();
setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...DEFAULT_ADJUSTMENTS }, baseFilter: 'none', cinema: null }));
setSimId(null);
setFrameId('none');
setCrop('none');
setRotation(0);
setMarkOn(false);
setOpenParam(null);
}, [remember]);
const undoLast = useCallback(() => {
setUndo((stack) => {
const last = stack[stack.length - 1];
if (last) setRecipe(last);
return stack.slice(0, -1);
});
}, []);
const saveRecipe = useCallback(async () => {
if (!user) {
setAuthOpen(true);
return;
}
const name = window.prompt(t('save.name'), recipe.name);
if (!name) return;
try {
const created = await api.createRecipe(name, { ...recipe, name });
setSaved((list) => [created.recipe, ...list]);
setRecipe((prev) => ({ ...prev, name }));
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipe, user, t]);
const exportPhoto = useCallback(async () => {
if (!engine || !source) return;
setExporting(true);
setError(null);
try {
const tier = TIERS[user ? 'user' : 'guest'];
const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes;
const out = await renderPhoto({
source: src,
recipe,
frameId,
useGeotag,
gpsInfo: gps,
assets: engine.assets,
options: {
dpi: 300,
sharpen: true,
photoRotation: rotation,
cropWH: crop === 'none' ? undefined : CROP_W_H[crop],
watermark: markOn && markText
? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 }
: tier.watermark
? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
: null,
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null,
},
});
if (!out) throw new Error('render returned null');
// Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same
// order the app uses. The source photo donates its camera/lens tags.
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,
},
source.bytes
);
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
} finally {
setExporting(false);
}
}, [engine, source, recipe, frameId, useGeotag, gps, rotation, crop, markOn, markText, place, user, t]);
// --- chips ----------------------------------------------------------------
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
defs.map((def) => ({
key: def.key,
label: def.label,
value: def.display(def.get(recipe.adjustments)),
active: openParam === def.key,
pro: def.pro && !user,
disabled: !!def.pro && !user,
onClick: () => setOpenParam(openParam === def.key ? null : def.key),
}));
const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null;
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
const def = all.find((d) => d.key === openParam);
if (!def) return null;
return {
key: def.key,
label: def.label,
value: def.get(recipe.adjustments),
min: def.min,
max: def.max,
step: def.step,
display: def.display(def.get(recipe.adjustments)),
onChange: (value) => setAdjustment(def.set(value)),
onClose: () => setOpenParam(null),
};
}, [openParam, recipe.adjustments, setAdjustment]);
const chips: ChipDef[] = useMemo(() => {
if (tab === 'presets') {
return [
...FILM_SIMS.map((sim) => ({
key: sim.id,
label: sim.name,
active: simId === sim.id,
onClick: () => applyRecipe(filmSimToRecipe(sim)),
})),
...DEFAULT_RECIPES.slice(0, 6).map((preset) => ({
key: preset.id,
label: preset.name,
active: recipe.name === preset.name && !recipe.isCustom,
onClick: () => applyRecipe({ ...preset }),
})),
];
}
if (tab === 'favorited') {
return saved.map((entry) => ({
key: String(entry.id),
label: entry.name,
active: false,
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
}));
}
if (tab === 'light') return paramChips(PARAM_DEFS.iq);
if (tab === 'wb') {
return [
...TEMP_PRESETS.map((preset) => ({
key: preset.label,
label: preset.label,
active: Math.round(recipe.adjustments.temperature) === preset.kelvin,
onClick: () => setAdjustment({ temperature: preset.kelvin }),
})),
...paramChips(PARAM_DEFS.wb),
];
}
if (tab === 'fx') return paramChips(PARAM_DEFS.filters);
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, saved, simId, recipe, openParam, user, applyRecipe, setAdjustment]);
const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready');
return (
<div className="shell">
<TopBar
recipeName={recipe.name}
user={user}
exporting={exporting}
canUndo={undo.length > 0}
onUndo={undoLast}
onReset={reset}
onExport={exportPhoto}
onSave={saveRecipe}
onAuth={() => setAuthOpen(true)}
onLogout={async () => {
await api.logout().catch(() => undefined);
setUser(null);
}}
/>
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} />
<div className="chips">
{tab === 'frame' ? (
<>
<Section title={t('sec.frame')}>
<ChipRow
chips={FRAMES.map((frame) => ({
key: frame.id,
label: t(frame.label),
active: frameId === frame.id,
onClick: () => {
remember();
setFrameId(frame.id);
},
}))}
/>
</Section>
<Section title={t('sec.crop')}>
<ChipRow
chips={CROPS.map((ratio) => ({
key: ratio,
label: t(`crop.${ratio}` as 'crop.none'),
active: crop === ratio,
onClick: () => setCrop(ratio),
}))}
/>
</Section>
<Section title={t('sec.rotate')}>
<ChipRow
chips={ROTATIONS.map((deg) => ({
key: String(deg),
label: t(`rot.${deg}` as 'rot.0'),
active: rotation === deg,
onClick: () => setRotation(deg),
}))}
/>
</Section>
<Section title={t('sec.watermark')}>
<ChipRow
chips={[
{
key: 'mark',
label: t('wm.text'),
active: markOn,
onClick: () => setMarkOn(!markOn),
},
]}
/>
{markOn ? (
<input
className="field"
style={{ marginTop: 6 }}
value={markText}
placeholder="RECIPESCAM"
onChange={(e) => setMarkText(e.target.value)}
/>
) : null}
</Section>
<Section title={t('sec.gps')}>
<ChipRow
chips={[
{
key: 'gps',
label: t('wm.gps'),
active: useGeotag,
disabled: !gps && !(gpsManual.lat && gpsManual.lng),
onClick: () => setUseGeotag(!useGeotag),
},
]}
/>
<input
className="field"
style={{ marginTop: 6 }}
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
/>
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
{!gps ? (
<div className="chip-row" style={{ marginTop: 6 }}>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lat: e.target.value }))}
onBlur={() => {
const lat = Number(gpsManual.lat);
const lng = Number(gpsManual.lng);
if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) {
setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' });
setUseGeotag(true);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
) : null}
</Section>
</>
) : (
<>
<Section title={t(SECTION_TITLE[tab])}>
<ChipRow chips={chips} />
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
</Section>
{slider ? <SliderRow slider={slider} /> : null}
</>
)}
</div>
<ImageStage
previewUrl={previewUrl}
originalUrl={source?.url ?? null}
busy={status}
error={error}
peek={peek}
onPeek={setPeek}
onFile={(file) => void loadFile(file)}
/>
</div>
{authOpen ? (
<AuthModal
onClose={() => setAuthOpen(false)}
onDone={async () => {
setAuthOpen(false);
const me = await api.me().catch(() => null);
if (me) setUser(me.user);
}}
/>
) : null}
</div>
);
}