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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useI18n, LANGS } from './i18n/I18nProvider';
|
||||
import { ACCENTS, useTheme } from './theme/ThemeProvider';
|
||||
|
||||
// Landing page. The store links are placeholders on purpose — the real URLs are
|
||||
// still unknown (see WEB_PLAN.md, W6); they live in one place so filling them in
|
||||
// later is a one-line change.
|
||||
const STORE_LINKS = {
|
||||
play: '#',
|
||||
appstore: '#',
|
||||
};
|
||||
|
||||
export function Landing() {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { mode, accent, setMode, setAccent } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="header">
|
||||
<span className="brand">RecipesCam <small>web</small></span>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn ghost" onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}>
|
||||
{mode === 'dark' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')}
|
||||
title={LANGS.map((l) => l.label).join(' / ')}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{ACCENTS.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
aria-label={entry.label}
|
||||
aria-pressed={accent === entry.id}
|
||||
style={accent === entry.id ? { color: 'var(--accent)' } : undefined}
|
||||
onClick={() => setAccent(entry.id)}
|
||||
>
|
||||
●
|
||||
</button>
|
||||
))}
|
||||
</header>
|
||||
|
||||
<main className="landing">
|
||||
<h1>{t('land.title')}</h1>
|
||||
<p className="lede">{t('land.lede')}</p>
|
||||
|
||||
<div className="cta">
|
||||
<a className="btn primary" href="/app">{t('land.ctaDemo')}</a>
|
||||
<a className="btn" href="/app?auth=1">{t('land.ctaSignup')}</a>
|
||||
</div>
|
||||
|
||||
<div className="features">
|
||||
<article className="card">
|
||||
<h3>{t('land.f1t')}</h3>
|
||||
<p>{t('land.f1d')}</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>{t('land.f2t')}</h3>
|
||||
<p>{t('land.f2d')}</p>
|
||||
</article>
|
||||
<article className="card">
|
||||
<h3>{t('land.f3t')}</h3>
|
||||
<p>{t('land.f3d')}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="badges">
|
||||
<a className="badge" href={STORE_LINKS.play}>
|
||||
<small>{t('land.storeSoon')}</small>
|
||||
{t('land.badgePlay')}
|
||||
</a>
|
||||
<a className="badge" href={STORE_LINKS.appstore}>
|
||||
<small>{t('land.storeSoon')}</small>
|
||||
{t('land.badgeApp')}
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Thin wrapper over the API container. Same origin in production (nginx proxies
|
||||
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
|
||||
import type { Recipe } from '../shared/types';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SavedRecipe {
|
||||
id: number;
|
||||
name: string;
|
||||
recipe: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: init?.body ? { 'content-type': 'application/json' } : undefined,
|
||||
...init,
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
const body = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => call<{ user: User }>('/auth/me'),
|
||||
signup: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/signup', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
login: (email: string, password: string) =>
|
||||
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
logout: () => call<void>('/auth/logout', { method: 'POST' }),
|
||||
|
||||
listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'),
|
||||
createRecipe: (name: string, recipe: Recipe) =>
|
||||
call<{ recipe: SavedRecipe }>('/recipes', { method: 'POST', body: JSON.stringify({ name, recipe }) }),
|
||||
updateRecipe: (id: number, name: string, recipe: Recipe) =>
|
||||
call<{ recipe: SavedRecipe }>(`/recipes/${id}`, { method: 'PUT', body: JSON.stringify({ name, recipe }) }),
|
||||
deleteRecipe: (id: number) => call<void>(`/recipes/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// One table for the guest/user split, so the policy can change without touching
|
||||
// the UI. Guest = the landing + a capped, watermarked demo; user = full tools,
|
||||
// full-resolution export, saved recipes.
|
||||
|
||||
export type TierId = 'guest' | 'user';
|
||||
|
||||
export interface Tier {
|
||||
id: TierId;
|
||||
/** Longest edge of an exported photo, or null for the original size. */
|
||||
maxExportDim: number | null;
|
||||
/** Text burned into every export, or null for a clean file. */
|
||||
watermark: string | null;
|
||||
batchExport: boolean;
|
||||
}
|
||||
|
||||
export const TIERS: Record<TierId, Tier> = {
|
||||
guest: { id: 'guest', maxExportDim: 2048, watermark: 'RecipesCam demo', batchExport: false },
|
||||
user: { id: 'user', maxExportDim: null, watermark: null, batchExport: true },
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { initSkia, hasGpu } from './skiaShim';
|
||||
import type { RenderAssets } from './exportEngine';
|
||||
|
||||
// One CanvasKit instance per page, one copy of the three render assets. The wasm
|
||||
// file is served from /wasm/ (scripts/copy-wasm.mjs puts it there at build time);
|
||||
// the fonts and the wall-frame artwork are in public/assets.
|
||||
let assets: RenderAssets | null = null;
|
||||
|
||||
async function buf(url: string): Promise<ArrayBuffer> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
}
|
||||
|
||||
// The engine is only usable on a WebGL-backed surface (see skiaShim.ts), so the
|
||||
// app has to know whether it got one: reporting "no WebGL" beats silently
|
||||
// spending minutes per photo on the raster fallback.
|
||||
export function probeGpu(): boolean {
|
||||
return hasGpu();
|
||||
}
|
||||
|
||||
export async function loadEngine(): Promise<{ assets: RenderAssets; gpu: boolean }> {
|
||||
await initSkia({ locateFile: (file) => `/wasm/${file}` });
|
||||
if (!assets) {
|
||||
const [textFont, emojiFont, wallframePng] = await Promise.all([
|
||||
buf('/assets/Cousine-Regular.ttf'),
|
||||
buf('/assets/NotoEmoji-GPS.ttf'),
|
||||
buf('/assets/wallframe.png'),
|
||||
]);
|
||||
assets = { textFont, emojiFont, wallframe: new Uint8Array(wallframePng) };
|
||||
}
|
||||
return { assets, gpu: probeGpu() };
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
// Full-resolution render pipeline for the web build.
|
||||
//
|
||||
// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit,
|
||||
// step for step, so the file a browser downloads matches the file the phone
|
||||
// writes. What changed and why (W0 probe, measured — see skiaShim.ts header):
|
||||
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
|
||||
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
|
||||
// gone (the browser downloads a Blob instead).
|
||||
// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
|
||||
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
|
||||
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
|
||||
// as a shader pass. `convolve` replaces the pixels outright (the shader's
|
||||
// output is opaque and covers the same rect), so no clearing pass is needed.
|
||||
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
|
||||
// be composited over the un-blurred content, so those passes snapshot, clear
|
||||
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
|
||||
// mist → soften.
|
||||
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
|
||||
// alone keeps the print size metadata.
|
||||
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
|
||||
|
||||
import { Skia } from './skiaShim';
|
||||
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types';
|
||||
import { ASPECT_RATIO_W_H } from '../../shared/types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../../shared/utils/colorUtils';
|
||||
import {
|
||||
TONE_SKSL,
|
||||
GLOW_SKSL,
|
||||
CLARITY_SKSL,
|
||||
getToneUniforms,
|
||||
toneIsActive,
|
||||
toneUniformArray,
|
||||
glowUniformArray,
|
||||
} from '../../shared/utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader';
|
||||
import {
|
||||
drawFrameOnCanvas,
|
||||
polaroidLayout,
|
||||
POLAROID_CARD,
|
||||
POLAROID_WIN_W,
|
||||
wallframeLayout,
|
||||
WALLFRAME_W,
|
||||
WALLFRAME_H,
|
||||
WALLFRAME_LAND_W,
|
||||
WALLFRAME_LAND_H,
|
||||
} from '../../shared/utils/frameUtils';
|
||||
import { patchJpegDpi } from '../../shared/utils/jpegDpi';
|
||||
import { applyPhotoRotation } from '../../shared/utils/skiaImage';
|
||||
|
||||
export interface RenderAssets {
|
||||
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
|
||||
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
|
||||
wallframe: Uint8Array; // wallframe.png
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
dpi?: number;
|
||||
sharpen?: boolean;
|
||||
evFromCamera?: boolean;
|
||||
aspect?: AspectRatio;
|
||||
cropWH?: number;
|
||||
cropRect?: CropRect | null;
|
||||
watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
||||
liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
||||
watermarkRotation?: number;
|
||||
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
|
||||
wallframeLandscape?: boolean;
|
||||
frameWindowZoom?: { s: number; u: number; v: number } | null;
|
||||
photoRotation?: 0 | 90 | 180 | 270;
|
||||
photoStraighten?: number;
|
||||
}
|
||||
|
||||
export interface RenderInput {
|
||||
source: Uint8Array;
|
||||
recipe: Recipe;
|
||||
frameId: FrameId;
|
||||
useGeotag?: boolean;
|
||||
gpsInfo?: GPSInfo | null;
|
||||
options?: RenderOptions;
|
||||
assets: RenderAssets;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
bytes: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type SkDisposable = { dispose(): void };
|
||||
|
||||
function disposeAll(items: SkDisposable[]): void {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
items[i].dispose();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function release(owned: SkDisposable[], item: SkDisposable | null): void {
|
||||
if (!item) return;
|
||||
const i = owned.indexOf(item);
|
||||
if (i >= 0) owned.splice(i, 1);
|
||||
disposeAll([item]);
|
||||
}
|
||||
|
||||
function createSurface(width: number, height: number) {
|
||||
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
||||
}
|
||||
|
||||
// A WebGL surface batches draws; the readback paths (snapshot, encode) must see
|
||||
// them submitted first. A no-op on the raster fallback.
|
||||
function flush(surface: any): void {
|
||||
if (typeof surface?.flush === 'function') surface.flush();
|
||||
}
|
||||
|
||||
// --- shared runtime effects -------------------------------------------------
|
||||
|
||||
let sharpenEffect: any = null;
|
||||
let toneEffect: any = null;
|
||||
let cinemaEffect: any = null;
|
||||
let glowEffect: any = null;
|
||||
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect };
|
||||
}
|
||||
|
||||
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
|
||||
// a 1:1 export canvas, so the radius matches what the preview tuned.
|
||||
function convolvePaint(srcImage: any, amount: number): any {
|
||||
const { sharpenEffect: effect } = effects();
|
||||
if (!effect) return null;
|
||||
const paint = Skia.Paint();
|
||||
const child = srcImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]);
|
||||
if (!shader) {
|
||||
paint.dispose();
|
||||
child.dispose();
|
||||
return null;
|
||||
}
|
||||
paint.setShader(shader);
|
||||
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);
|
||||
if (!built) return;
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint);
|
||||
disposeAll([built.paint, built.shader, built.child]);
|
||||
}
|
||||
|
||||
function convolveImage(image: any, amount: number): any {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return image;
|
||||
try {
|
||||
drawConvolved(surf.getCanvas(), image, w, h, amount);
|
||||
return surf.makeImageSnapshot() ?? image;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function screenSharpenImage(image: any, amount = 0.5): any {
|
||||
return convolveImage(image, amount);
|
||||
}
|
||||
|
||||
// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
|
||||
// pixels it is meant to replace.
|
||||
function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const paint = Skia.Paint();
|
||||
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
||||
paint.setImageFilter(filter);
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
canvas.drawImage(snap, 0, 0, paint);
|
||||
disposeAll([paint, filter, snap]);
|
||||
}
|
||||
|
||||
// --- fonts / artwork --------------------------------------------------------
|
||||
|
||||
let faces: { typeface: any; iconTypeface: any } | null = null;
|
||||
let wallframeImage: any = null;
|
||||
|
||||
function loadFaces(assets: RenderAssets) {
|
||||
if (faces) return faces;
|
||||
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont);
|
||||
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
|
||||
if (!typeface || !iconTypeface) return null;
|
||||
faces = { typeface, iconTypeface };
|
||||
// Named-family lookup for the custom watermark's FONT option: register the
|
||||
// bundled face so at least that one name resolves on web.
|
||||
try {
|
||||
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
|
||||
} catch {
|
||||
/* provider optional */
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
function loadWallframe(assets: RenderAssets) {
|
||||
if (wallframeImage) return wallframeImage;
|
||||
wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe);
|
||||
return wallframeImage;
|
||||
}
|
||||
|
||||
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
|
||||
const { recipe, frameId, options } = input;
|
||||
const owned: SkDisposable[] = [];
|
||||
const own = <T,>(obj: T | null | undefined): T => {
|
||||
if (obj) owned.push(obj as unknown as SkDisposable);
|
||||
return obj as T;
|
||||
};
|
||||
try {
|
||||
const skImage0 = Skia.Image.MakeImageFromEncoded(input.source);
|
||||
if (!skImage0) {
|
||||
console.error('Failed to parse source image');
|
||||
return null;
|
||||
}
|
||||
own(skImage0);
|
||||
let skImage = skImage0;
|
||||
|
||||
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
|
||||
if (rotated && rotated !== skImage) {
|
||||
own(rotated);
|
||||
release(owned, skImage);
|
||||
skImage = rotated;
|
||||
}
|
||||
let width = skImage.width();
|
||||
let height = skImage.height();
|
||||
|
||||
// 1b. Composition / FRAME-tab crop — identical maths to the RN engine.
|
||||
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
|
||||
const cropRect = options?.cropRect ?? null;
|
||||
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
|
||||
if ((cropRect || ratioWH) && frameId !== 'wallframe') {
|
||||
const curAspect = width / height;
|
||||
let cropW = width;
|
||||
let cropH = height;
|
||||
let srcX = 0;
|
||||
let srcY = 0;
|
||||
if (cropRect) {
|
||||
srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width)));
|
||||
srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height)));
|
||||
cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width)));
|
||||
cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height)));
|
||||
} else if (curAspect > (ratioWH as number)) {
|
||||
cropW = Math.round(height * (ratioWH as number));
|
||||
srcX = Math.round((width - cropW) / 2);
|
||||
} else {
|
||||
cropH = Math.round(width / (ratioWH as number));
|
||||
srcY = Math.round((height - cropH) / 2);
|
||||
}
|
||||
const cropSurface = createSurface(cropW, cropH);
|
||||
if (!cropSurface) return null;
|
||||
own(cropSurface);
|
||||
cropSurface.getCanvas().drawImageRect(
|
||||
skImage,
|
||||
Skia.XYWHRect(srcX, srcY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, cropW, cropH),
|
||||
own(Skia.Paint())
|
||||
);
|
||||
const cropped = cropSurface.makeImageSnapshot();
|
||||
if (!cropped) return null;
|
||||
own(cropped);
|
||||
release(owned, skImage);
|
||||
release(owned, cropSurface);
|
||||
skImage = cropped;
|
||||
width = cropW;
|
||||
height = cropH;
|
||||
}
|
||||
|
||||
// 2. Render surface.
|
||||
const surface = createSurface(width, height);
|
||||
if (!surface) return null;
|
||||
own(surface);
|
||||
const canvas = surface.getCanvas();
|
||||
const paint = own(Skia.Paint());
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
|
||||
// 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
|
||||
const evStops = adjustments.exposureCompensation ?? 0;
|
||||
const evMatrix = options?.evFromCamera
|
||||
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
||||
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 3b. Tone shader.
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
let toneShader: any = null;
|
||||
if (toneIsActive(tone)) {
|
||||
const { toneEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const imageShader = own(
|
||||
skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
)
|
||||
);
|
||||
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
|
||||
if (toneShader) paint.setShader(toneShader);
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Cinema seasonal grade (cinema → tone → image).
|
||||
const cinema = getCinemaUniforms(recipe.cinema);
|
||||
let paintShader = toneShader;
|
||||
if (cinema && cinemaIsActive(cinema.flat)) {
|
||||
const { cinemaEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const child =
|
||||
toneShader ??
|
||||
own(skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
));
|
||||
const cs = own(effect.makeShaderWithChildren(cinema.flat, [child]));
|
||||
if (cs) {
|
||||
paintShader = cs;
|
||||
paint.setShader(paintShader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6;
|
||||
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
|
||||
}
|
||||
|
||||
// 5. Draw the graded photo.
|
||||
if (paintShader) {
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
|
||||
} else {
|
||||
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);
|
||||
} else if (adjustments.clarity < 0) {
|
||||
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
||||
drawBlurred(canvas, surface, width, height, mistSigma);
|
||||
}
|
||||
// Negative SHARPENING takes edge enhancement back out — after the conv, so
|
||||
// it cannot blur away what the CLARITY pass just added.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
if (sharpKnob < 0) {
|
||||
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
|
||||
}
|
||||
|
||||
// 5b. HDF bright-pass bloom.
|
||||
if ((adjustments.hdf ?? 0) > 0) {
|
||||
const strength = (adjustments.hdf ?? 0) / 10;
|
||||
const sigma = width * (0.004 + 0.015 * strength);
|
||||
const { glowEffect: effect } = effects();
|
||||
const srcShader =
|
||||
paintShader ??
|
||||
own(skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
));
|
||||
const glowShader =
|
||||
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
||||
const glowPaint = own(Skia.Paint());
|
||||
glowPaint.setBlendMode(Skia.BlendMode.Screen);
|
||||
glowPaint.setAlphaf(0.15 + 0.35 * strength);
|
||||
if (glowShader != null) {
|
||||
glowPaint.setShader(glowShader);
|
||||
glowPaint.setImageFilter(
|
||||
own(Skia.ImageFilter.MakeCompose(
|
||||
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
|
||||
own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
|
||||
))
|
||||
);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
glowPaint.setImageFilter(
|
||||
own(Skia.ImageFilter.MakeCompose(
|
||||
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
|
||||
own(Skia.ImageFilter.MakeColorFilter(
|
||||
own(Skia.ColorFilter.MakeCompose(
|
||||
own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
|
||||
colorFilter
|
||||
)),
|
||||
null
|
||||
))
|
||||
))
|
||||
);
|
||||
if (paintShader) {
|
||||
glowPaint.setShader(paintShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, glowPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
|
||||
const grainAmount = Math.min(
|
||||
10,
|
||||
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
|
||||
);
|
||||
if (grainAmount > 0) {
|
||||
const grainPaint = own(Skia.Paint());
|
||||
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainAmount / 20);
|
||||
const noiseEffect = own(Skia.RuntimeEffect.Make(`
|
||||
uniform float u;
|
||||
float grainHash(vec2 q) {
|
||||
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
vec2 p = pos.xy / max(u, 1.0);
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float g00 = grainHash(i);
|
||||
float g10 = grainHash(i + vec2(1.0, 0.0));
|
||||
float g01 = grainHash(i + vec2(0.0, 1.0));
|
||||
float g11 = grainHash(i + vec2(1.0, 1.0));
|
||||
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
||||
}
|
||||
`));
|
||||
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Vignette.
|
||||
if ((adjustments.vignette ?? 0) > 0) {
|
||||
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
|
||||
const vignetteShader = own(Skia.Shader.MakeRadialGradient(
|
||||
Skia.Point(width / 2, height / 2),
|
||||
Math.hypot(width, height) / 2,
|
||||
[Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
|
||||
[0, 0.45, 1],
|
||||
Skia.TileMode.Clamp
|
||||
));
|
||||
if (vignetteShader) {
|
||||
const vignettePaint = own(Skia.Paint());
|
||||
vignettePaint.setShader(vignetteShader);
|
||||
vignettePaint.setAlphaf(0.9 * v);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
|
||||
}
|
||||
}
|
||||
|
||||
release(owned, skImage);
|
||||
|
||||
// 7. Frame.
|
||||
let outputSurface = surface;
|
||||
let stampCanvas = canvas;
|
||||
let stampW = width;
|
||||
let stampH = height;
|
||||
let cardSource: any = null;
|
||||
if (frameId === 'polaroid') {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const outW = width;
|
||||
const frameAspect =
|
||||
ratioWH ??
|
||||
(options?.photoRotation === 90 || options?.photoRotation === 270
|
||||
? height / width
|
||||
: width / height);
|
||||
const outH = Math.round(
|
||||
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) +
|
||||
(width * POLAROID_WIN_W) / frameAspect
|
||||
);
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const bgPaint = own(Skia.Paint());
|
||||
bgPaint.setColor(Skia.Color('#faf9f6'));
|
||||
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
||||
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
|
||||
const win = pl.window;
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const pz = options?.frameWindowZoom ?? null;
|
||||
const pwz = pz && pz.s > 1.001 ? pz : null;
|
||||
const pVisW = pwz ? width / pwz.s : width;
|
||||
const pVisH = pwz ? height / pwz.s : height;
|
||||
const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0;
|
||||
const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0;
|
||||
const srcAspect = width / height;
|
||||
let dstW = win.w;
|
||||
let dstH = dstW / srcAspect;
|
||||
if (dstH > win.h) {
|
||||
dstH = win.h;
|
||||
dstW = dstH * srcAspect;
|
||||
}
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
|
||||
Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
|
||||
plainPaint
|
||||
);
|
||||
const seamPaint = own(Skia.Paint());
|
||||
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
seamPaint.setStyle(1);
|
||||
seamPaint.setStrokeWidth(1);
|
||||
cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else if (frameId === 'wallframe') {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const pw = photoImg.width();
|
||||
const ph = photoImg.height();
|
||||
const wallLand = options?.wallframeLandscape === true;
|
||||
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W;
|
||||
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H;
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const wfImage = loadWallframe(input.assets);
|
||||
if (!wfImage) {
|
||||
console.error('Failed to decode wallframe.png');
|
||||
return null;
|
||||
}
|
||||
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
|
||||
const win = wl.window;
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const scale = Math.max(win.w / pw, win.h / ph);
|
||||
const srcW = win.w / scale;
|
||||
const srcH = win.h / scale;
|
||||
const planeX = (pw - srcW) / 2;
|
||||
const planeY = (ph - srcH) / 2;
|
||||
const wz = options?.frameWindowZoom ?? null;
|
||||
const wZoom = wz && wz.s > 1.001 ? wz : null;
|
||||
const wVisW = wZoom ? srcW / wZoom.s : srcW;
|
||||
const wVisH = wZoom ? srcH / wZoom.s : srcH;
|
||||
const wSx = wZoom
|
||||
? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW)
|
||||
: planeX;
|
||||
const wSy = wZoom
|
||||
? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH)
|
||||
: planeY;
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
|
||||
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
||||
plainPaint
|
||||
);
|
||||
cardCanvas.save();
|
||||
if (wallLand) {
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
} else {
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
}
|
||||
cardCanvas.restore();
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
if (cardSource) {
|
||||
release(owned, cardSource);
|
||||
release(owned, surface);
|
||||
}
|
||||
|
||||
// 8. GPS + custom watermark.
|
||||
const customWm = options?.watermark ?? null;
|
||||
const liteWm = options?.liteMark ?? null;
|
||||
if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) {
|
||||
const loaded = loadFaces(input.assets);
|
||||
if (loaded) {
|
||||
const { typeface, iconTypeface } = loaded;
|
||||
const fontSize = Math.round(stampW * 0.032);
|
||||
const textPaint = own(Skia.Paint());
|
||||
textPaint.setColor(Skia.Color('#f59e0b'));
|
||||
const iconPaint = own(Skia.Paint());
|
||||
iconPaint.setColor(Skia.Color('#ffffff'));
|
||||
|
||||
if (input.useGeotag && input.gpsInfo) {
|
||||
const locationName = input.gpsInfo.locality || 'STREET VIEW';
|
||||
const d = new Date(input.gpsInfo.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const gpsPos = options?.gpsWatermark ?? null;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
|
||||
const gpsFont = own(Skia.Font(typeface, gpsSize));
|
||||
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
|
||||
const gpsGap = Math.round(gpsSize * 1.6);
|
||||
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
||||
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
||||
const lines: { icon: string; text: string }[] = [];
|
||||
if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
|
||||
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
|
||||
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
||||
lines.forEach((line, i) => {
|
||||
const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2));
|
||||
stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont);
|
||||
});
|
||||
stampCanvas.restore();
|
||||
}
|
||||
|
||||
if (customWm || liteWm) {
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
for (const mark of [customWm, liteWm]) {
|
||||
if (!mark) continue;
|
||||
const wmSize = Math.round(fontSize * (mark.size ?? 1));
|
||||
const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
|
||||
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
|
||||
const wmPaint = own(Skia.Paint());
|
||||
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
|
||||
const tx = clamp01(mark.x) * stampW;
|
||||
const ty = clamp01(mark.y) * stampH + Math.round(wmSize * 0.35);
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
|
||||
stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont);
|
||||
stampCanvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot, optional output sharpen, JPEG encode + DPI patch.
|
||||
flush(outputSurface);
|
||||
let resultImage = own(outputSurface.makeImageSnapshot());
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
if (sharpenAmount > 0) {
|
||||
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
|
||||
if (sharpened && sharpened !== resultImage) {
|
||||
own(sharpened);
|
||||
release(owned, resultImage);
|
||||
resultImage = sharpened;
|
||||
}
|
||||
}
|
||||
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
||||
if (!bytes || bytes.length === 0) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
return null;
|
||||
}
|
||||
if (options?.dpi && options.dpi > 0) {
|
||||
bytes = patchJpegDpi(bytes, options.dpi);
|
||||
}
|
||||
return { bytes, width: resultImage.width(), height: resultImage.height() };
|
||||
} catch (error) {
|
||||
console.error('Render failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
disposeAll(owned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import exifr from 'exifr';
|
||||
import type { GPSInfo } from '../../shared/types';
|
||||
|
||||
// Browser-side input handling: turn a dropped File into the source bytes the
|
||||
// engine wants, plus the two derived things the UI needs — a smaller copy for
|
||||
// the live preview, and whatever GPS the photo already carries.
|
||||
|
||||
export async function fileToBytes(file: File | Blob): Promise<Uint8Array> {
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
// Preview renders run on a downscaled copy: the grade is pixel-wise, so grading
|
||||
// a 1600px copy shows the same look, and it keeps the slider responsive. Export
|
||||
// always runs on the original bytes.
|
||||
export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0.92): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]));
|
||||
try {
|
||||
const longest = Math.max(bitmap.width, bitmap.height);
|
||||
if (longest <= maxDim) return bytes;
|
||||
const scale = maxDim / longest;
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return bytes;
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
|
||||
// Returns null when the photo has none — the caller then offers manual entry.
|
||||
export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
|
||||
try {
|
||||
const gps = await exifr.gps(bytes);
|
||||
if (!gps || typeof gps.latitude !== 'number' || typeof gps.longitude !== 'number') return null;
|
||||
return {
|
||||
latitude: gps.latitude,
|
||||
longitude: gps.longitude,
|
||||
timestamp: Date.now(),
|
||||
source: 'exif',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function download(bytes: Uint8Array, filename: string): void {
|
||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'image/jpeg' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// CanvasKit-backed stand-in for `@shopify/react-native-skia`.
|
||||
//
|
||||
// The web build aliases the package name to THIS module (vite `resolve.alias`,
|
||||
// tsconfig `paths`), so every renderer we share with the app — `frameUtils.ts`,
|
||||
// `skiaImage.ts`, the ported `exportEngine.ts` — runs unchanged on CanvasKit.
|
||||
//
|
||||
// W0 probe results that shaped this file (measured in Node, canvaskit-wasm 0.42):
|
||||
// - the default build CANNOT encode JPEG (`encodeToBytes(JPEG)` → null), the
|
||||
// `canvaskit-wasm/full` build can → always import the full build (+0.9MB wasm);
|
||||
// - `Skia.ImageFilter.MakeMatrixConvolution` DOES NOT EXIST in CanvasKit → the
|
||||
// two convolution steps (CLARITY, output sharpen) are re-implemented as an
|
||||
// SkSL runtime effect, see convolve.ts;
|
||||
// - `Surface.MakeOffscreen` does not exist either → one shared GrDirectContext
|
||||
// + `MakeRenderTarget` for GPU, raster `MakeSurface` as the no-GL fallback
|
||||
// (see gpuSurface below for why the context must be shared);
|
||||
// - `Skia.Font`/`Typeface.MakeFreeTypeFaceFromData`/`Canvas.drawText` all work,
|
||||
// and `assets/NotoEmoji-GPS.ttf` really paints 📍/📷 (1731 lit pixels in the
|
||||
// probe) → GPS watermark needs no Canvas2D fallback.
|
||||
|
||||
import CanvasKitInit from 'canvaskit-wasm/full';
|
||||
|
||||
// Live binding: modules import `{ Skia }` once, `initSkia()` fills it in before
|
||||
// the first render. ESM live bindings make the late assignment visible.
|
||||
export let Skia: any = null;
|
||||
export let CK: any = null;
|
||||
// `frameUtils.ts` imports `BlendMode` as a value, so it has to be a live
|
||||
// binding too — filled in once CanvasKit is up, long before any draw.
|
||||
export let BlendMode: any = null;
|
||||
|
||||
export type SkCanvas = any;
|
||||
export type SkPaint = any;
|
||||
export type SkImage = any;
|
||||
export type SkShader = any;
|
||||
export type SkSurface = any;
|
||||
export type SkTypeface = any;
|
||||
export type SkFont = any;
|
||||
|
||||
// CanvasKit's EMBind objects are released with `delete()`; the RN Skia API (and
|
||||
// therefore the shared renderers) calls `dispose()`.
|
||||
const DISPOSABLE = [
|
||||
'Image', 'Surface', 'Paint', 'Shader', 'ImageFilter', 'ColorFilter',
|
||||
'Typeface', 'Font', 'RuntimeEffect', 'Path', 'Canvas', 'BlendMode',
|
||||
];
|
||||
|
||||
function patchPrototypes(canvasKit: any): void {
|
||||
for (const name of DISPOSABLE) {
|
||||
const proto = canvasKit[name]?.prototype;
|
||||
if (proto && typeof proto.delete === 'function' && typeof proto.dispose !== 'function') {
|
||||
proto.dispose = function dispose() { this.delete(); };
|
||||
}
|
||||
}
|
||||
// frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke).
|
||||
const setStyle = canvasKit.Paint.prototype.setStyle;
|
||||
canvasKit.Paint.prototype.setStyle = function (style: any) {
|
||||
return setStyle.call(
|
||||
this,
|
||||
typeof style === 'number'
|
||||
? (style === 1 ? canvasKit.PaintStyle.Stroke : canvasKit.PaintStyle.Fill)
|
||||
: style
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export interface InitSkiaOptions {
|
||||
// Browser: path of canvaskit.wasm (Vite serves it as an asset). Node: omitted.
|
||||
locateFile?: (file: string) => string;
|
||||
}
|
||||
|
||||
function rasterSurface(w: number, h: number) {
|
||||
return CK.MakeSurface(w, h);
|
||||
}
|
||||
|
||||
// ONE GrDirectContext for every surface. `MakeWebGLCanvasSurface` makes its own
|
||||
// WebGL context per call, and a texture from one context cannot be sampled by a
|
||||
// surface on another: the snapshot of surface A drawn onto surface B came back
|
||||
// solid black (measured — 12MP JPEG + output sharpen, preview and export alike,
|
||||
// GL 0.0 vs raster 107.0 mean). Sharing the context fixes it.
|
||||
let grContext: any = null;
|
||||
|
||||
function ensureGrContext(): any {
|
||||
if (grContext) return grContext;
|
||||
if (typeof OffscreenCanvas === 'undefined') return null;
|
||||
try {
|
||||
grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8)));
|
||||
} catch {
|
||||
grContext = null;
|
||||
}
|
||||
return grContext;
|
||||
}
|
||||
|
||||
function gpuSurface(w: number, h: number) {
|
||||
const ctx = ensureGrContext();
|
||||
if (!ctx) return null;
|
||||
try {
|
||||
return CK.MakeRenderTarget(ctx, w, h) ?? null;
|
||||
} catch {
|
||||
return null; // no GL context available — caller falls back to raster
|
||||
}
|
||||
}
|
||||
|
||||
// Callers (the app's engine banner) ask the same context the renderer will use,
|
||||
// so "GPU ok" cannot disagree with what the pipeline actually gets.
|
||||
export function hasGpu(): boolean {
|
||||
return !!ensureGrContext();
|
||||
}
|
||||
|
||||
export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
|
||||
if (Skia) return Skia;
|
||||
const canvasKit = await (CanvasKitInit as any)(
|
||||
options.locateFile ? { locateFile: options.locateFile } : undefined
|
||||
);
|
||||
CK = canvasKit;
|
||||
BlendMode = canvasKit.BlendMode;
|
||||
patchPrototypes(canvasKit);
|
||||
|
||||
Skia = {
|
||||
// --- geometry / color ---------------------------------------------------
|
||||
XYWHRect: (x: number, y: number, w: number, h: number) => canvasKit.XYWHRect(x, y, w, h),
|
||||
Point: (x: number, y: number) => [x, y],
|
||||
Color: (value: string) =>
|
||||
value === 'transparent'
|
||||
? canvasKit.Color(0, 0, 0, 0)
|
||||
: canvasKit.parseColorString(value),
|
||||
Color4f: (r: number, g: number, b: number, a: number) => canvasKit.Color4f(r, g, b, a),
|
||||
|
||||
// --- factories ----------------------------------------------------------
|
||||
Paint: () => new canvasKit.Paint(),
|
||||
Font: (typeface: any, size: number) => new canvasKit.Font(typeface, size),
|
||||
Data: {
|
||||
// CanvasKit exposes no Data class; callers pass bytes straight through.
|
||||
fromBytes: (bytes: Uint8Array) => bytes,
|
||||
},
|
||||
Image: {
|
||||
MakeImageFromEncoded: (data: Uint8Array) =>
|
||||
canvasKit.MakeImageFromEncoded(
|
||||
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer)
|
||||
),
|
||||
},
|
||||
Typeface: {
|
||||
MakeFreeTypeFaceFromData: (data: ArrayBuffer) =>
|
||||
canvasKit.Typeface.MakeFreeTypeFaceFromData(data),
|
||||
},
|
||||
ColorFilter: {
|
||||
MakeMatrix: (m: number[]) => canvasKit.ColorFilter.MakeMatrix(m),
|
||||
MakeCompose: (outer: any, inner: any) => canvasKit.ColorFilter.MakeCompose(outer, inner),
|
||||
},
|
||||
ImageFilter: {
|
||||
MakeBlur: (sx: number, sy: number, tile: any, input: any) =>
|
||||
canvasKit.ImageFilter.MakeBlur(sx, sy, tile, input ?? null),
|
||||
MakeCompose: (outer: any, inner: any) =>
|
||||
canvasKit.ImageFilter.MakeCompose(outer ?? null, inner ?? null),
|
||||
MakeColorFilter: (cf: any, input: any) =>
|
||||
canvasKit.ImageFilter.MakeColorFilter(cf, input ?? null),
|
||||
},
|
||||
Shader: {
|
||||
MakeRadialGradient: (
|
||||
center: number[], radius: number, colors: any[], pos: number[], mode: any
|
||||
) => canvasKit.Shader.MakeRadialGradient(center, radius, colors, pos, mode),
|
||||
},
|
||||
RuntimeEffect: {
|
||||
Make: (sksl: string) => canvasKit.RuntimeEffect.Make(sksl),
|
||||
},
|
||||
Surface: {
|
||||
// A WebGL-backed surface is not a nicety here, it is the difference
|
||||
// between usable and unusable: the same 12MP pipeline that finishes in
|
||||
// well under a second on a GL surface took 3.2s for ONE tone pass and
|
||||
// 13.7s for the CLARITY tap in the Node CPU raster benchmark. Raster is
|
||||
// the fallback for a context-less environment (Node spikes, tests).
|
||||
Make: (w: number, h: number) => rasterSurface(w, h),
|
||||
MakeOffscreen: (w: number, h: number) => gpuSurface(w, h) ?? rasterSurface(w, h),
|
||||
},
|
||||
FontMgr: {
|
||||
System: () => fontMgr,
|
||||
},
|
||||
|
||||
// --- enums --------------------------------------------------------------
|
||||
BlendMode: canvasKit.BlendMode,
|
||||
TileMode: canvasKit.TileMode,
|
||||
FilterMode: canvasKit.FilterMode,
|
||||
MipmapMode: canvasKit.MipmapMode,
|
||||
FontStyle: canvasKit.FontStyle,
|
||||
ImageFormat: canvasKit.ImageFormat,
|
||||
ColorType: canvasKit.ColorType,
|
||||
ColorSpace: canvasKit.ColorSpace,
|
||||
AlphaType: canvasKit.AlphaType,
|
||||
PaintStyle: canvasKit.PaintStyle,
|
||||
};
|
||||
|
||||
fontMgr = makeFontMgr(canvasKit);
|
||||
return Skia;
|
||||
}
|
||||
|
||||
// Named-family lookup for the custom watermark's FONT option. Only faces the
|
||||
// bundle ships can be matched on web; anything else falls back to the caller's
|
||||
// default typeface — the honest behaviour when there is no OS font service.
|
||||
let fontMgr: any = null;
|
||||
|
||||
function makeFontMgr(canvasKit: any) {
|
||||
const provider = canvasKit.TypefaceFontProvider.Make();
|
||||
return {
|
||||
registerFace(typeface: any, familyName: string) {
|
||||
provider.registerFont(typeface, familyName);
|
||||
},
|
||||
matchFamilyStyle(family: string) {
|
||||
if (!family) return null;
|
||||
try {
|
||||
return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { vi, type Dict, type MsgKey } from './vi';
|
||||
import { en } from './en';
|
||||
|
||||
// Two dictionaries, no i18n library: the whole set is a few KB of text and the
|
||||
// `Dict` type makes a missing English key a build error. Reach for a library
|
||||
// (or lazy-loading) when the dictionaries outgrow the bundle — not before.
|
||||
|
||||
export type Lang = 'vi' | 'en';
|
||||
|
||||
const DICTS: Record<Lang, Dict> = { vi, en };
|
||||
const KEY = 'rc.lang';
|
||||
|
||||
function readLang(): Lang {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
if (stored === 'vi' || stored === 'en') return stored;
|
||||
return (navigator.language || '').toLowerCase().startsWith('vi') ? 'vi' : 'en';
|
||||
}
|
||||
|
||||
interface I18nValue {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: MsgKey, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const Ctx = createContext<I18nValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(readLang);
|
||||
|
||||
const setLang = useCallback((next: Lang) => {
|
||||
localStorage.setItem(KEY, next);
|
||||
setLangState(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang]);
|
||||
|
||||
const value = useMemo<I18nValue>(() => ({
|
||||
lang,
|
||||
setLang,
|
||||
t: (key, params) => {
|
||||
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
|
||||
if (params) {
|
||||
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}), [lang, setLang]);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nValue {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error('useI18n used outside I18nProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
export const LANGS: { id: Lang; label: string }[] = [
|
||||
{ id: 'vi', label: vi['lang.name'] },
|
||||
{ id: 'en', label: en['lang.name'] },
|
||||
];
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Dict } from './vi';
|
||||
|
||||
export const en: Dict = {
|
||||
'lang.name': 'English',
|
||||
|
||||
'nav.demo': 'TRY DEMO',
|
||||
'nav.login': 'LOG IN',
|
||||
'nav.signup': 'SIGN UP',
|
||||
'nav.logout': 'Log out',
|
||||
'nav.account': 'Account',
|
||||
'nav.theme': 'Theme',
|
||||
'nav.language': 'Language',
|
||||
'nav.back': 'Back to home',
|
||||
'nav.guest': 'Guest',
|
||||
|
||||
'act.undo': 'UNDO',
|
||||
'act.reset': 'RESET',
|
||||
'act.export': 'EXPORT',
|
||||
'act.exporting': 'Exporting…',
|
||||
'act.cancel': 'Cancel',
|
||||
|
||||
'land.title': 'The same colour recipes, now on your desktop',
|
||||
'land.lede':
|
||||
'RecipesCam turns a photo into a film-looking one with recipes. The web build runs the app\u2019s exact colour pipeline: same recipes, same frames, same GPS stamp.',
|
||||
'land.ctaDemo': 'OPEN THE DEMO',
|
||||
'land.ctaSignup': 'CREATE AN ACCOUNT',
|
||||
'land.f1t': 'Film recipes',
|
||||
'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… plus EXPOSURE, WB, CLARITY, HDF and VIGNETTING.',
|
||||
'land.f2t': 'Frames & watermark',
|
||||
'land.f2d': 'Polaroid, classic border, WALL FRAME, custom text and a GPS stamp read from the photo\u2019s EXIF.',
|
||||
'land.f3t': 'Processed on your own machine',
|
||||
'land.f3d': 'The photo never leaves the browser. The server only keeps accounts and the recipes you save.',
|
||||
'land.badgePlay': 'Get it on Google Play',
|
||||
'land.badgeApp': 'Download on the App Store',
|
||||
'land.storeSoon': 'Coming soon',
|
||||
|
||||
'drop.title': 'Drag & drop a photo here',
|
||||
'drop.hint': 'or click to pick one · paste from the clipboard with Ctrl+V',
|
||||
'drop.replace': 'Choose another photo',
|
||||
'drop.peeking': 'SHOWING THE ORIGINAL',
|
||||
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
|
||||
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
|
||||
|
||||
'tab.presets': 'PRESETS',
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
|
||||
'sec.filmSims': 'PHOTO STYLE',
|
||||
'sec.recipes': 'RECIPES',
|
||||
'sec.saved': 'SAVED',
|
||||
'sec.savedEmpty': 'No saved recipes yet. Log in and hit SAVE to sync them.',
|
||||
'sec.frame': 'FRAME',
|
||||
'sec.crop': 'CROP',
|
||||
'sec.rotate': 'ROTATE',
|
||||
'sec.watermark': 'WATERMARK',
|
||||
'sec.gps': 'GPS',
|
||||
|
||||
'wb.temp': 'COLOR TEMP',
|
||||
|
||||
'frame.none': 'NO FRAME',
|
||||
'frame.polaroid': 'RETRO INSTANT',
|
||||
'frame.classic': 'CLASSIC BORDER',
|
||||
'frame.cinematic': 'CINEMATIC',
|
||||
'frame.wall': 'WALL FRAME',
|
||||
'frame.wallLand': 'WALL LANDSCAPE',
|
||||
|
||||
'crop.none': 'NO CROP',
|
||||
'crop.1:1': '1:1',
|
||||
'crop.2:3': '2:3',
|
||||
'crop.3:2': '3:2',
|
||||
'crop.3:4': '3:4',
|
||||
'crop.4:3': '4:3',
|
||||
'crop.16:9': '16:9',
|
||||
'crop.free': 'FREE',
|
||||
|
||||
'rot.0': '0°',
|
||||
'rot.90': '90°',
|
||||
'rot.180': '180°',
|
||||
'rot.270': '270°',
|
||||
|
||||
'wm.text': 'TEXT',
|
||||
'wm.color': 'COLOR',
|
||||
'wm.size': 'SIZE',
|
||||
'wm.gps': 'GPS STAMP',
|
||||
'wm.place': 'PLACE NAME',
|
||||
'wm.placePh': 'e.g. HANOI',
|
||||
'wm.manual': 'ENTER COORDINATES',
|
||||
'wm.lat': 'Latitude',
|
||||
'wm.lng': 'Longitude',
|
||||
'wm.hasExif': 'GPS read from the photo.',
|
||||
'wm.noExif': 'This photo has no GPS — type it in if you want the stamp.',
|
||||
|
||||
'auth.loginTitle': 'Log in',
|
||||
'auth.signupTitle': 'Create an account',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.passwordHint': 'At least 8 characters',
|
||||
'auth.noAccount': 'No account yet? Sign up',
|
||||
'auth.haveAccount': 'Already have an account? Log in',
|
||||
'auth.continueDemo': 'Keep using the demo',
|
||||
'auth.busy': 'Working…',
|
||||
'auth.loggedInAs': 'Signed in as {email}',
|
||||
|
||||
'save.title': 'Save recipe',
|
||||
'save.name': 'Recipe name',
|
||||
'save.save': 'SAVE',
|
||||
'save.saved': 'Saved "{name}"',
|
||||
'save.needLogin': 'Log in to save a recipe.',
|
||||
|
||||
'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.generic': 'Something went wrong: {msg}',
|
||||
'err.offline': 'Could not reach the server.',
|
||||
|
||||
'meta.ready': 'Ready',
|
||||
'meta.rendered': 'Rendered in {ms}ms · {w}×{h}',
|
||||
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -0,0 +1,131 @@
|
||||
// Vietnamese dictionary — the source of truth for key names (`en.ts` is typed
|
||||
// as `Dict`, so a missing or misspelled key there is a typecheck error).
|
||||
// Not translated anywhere, in any language: film-sim names, user recipe names,
|
||||
// user watermark text — they are proper nouns, see WEB_PLAN.md.
|
||||
|
||||
export const vi = {
|
||||
'lang.name': 'Tiếng Việt',
|
||||
|
||||
'nav.demo': 'DÙNG THỬ',
|
||||
'nav.login': 'ĐĂNG NHẬP',
|
||||
'nav.signup': 'ĐĂNG KÝ',
|
||||
'nav.logout': 'Đăng xuất',
|
||||
'nav.account': 'Tài khoản',
|
||||
'nav.theme': 'Giao diện',
|
||||
'nav.language': 'Ngôn ngữ',
|
||||
'nav.back': 'Về trang chủ',
|
||||
'nav.guest': 'Khách',
|
||||
|
||||
'act.undo': 'HOÀN TÁC',
|
||||
'act.reset': 'ĐẶT LẠI',
|
||||
'act.export': 'XUẤT ẢNH',
|
||||
'act.exporting': 'Đang xuất…',
|
||||
'act.cancel': 'Huỷ',
|
||||
|
||||
'land.title': 'Cùng công thức màu, giờ trên máy tính',
|
||||
'land.lede':
|
||||
'RecipesCam biến ảnh chụp thành ảnh có "chất" bằng công thức phim. Bản web chạy đúng pipeline màu của app: cùng công thức, cùng khung, cùng dấu GPS.',
|
||||
'land.ctaDemo': 'DÙNG THỬ NGAY',
|
||||
'land.ctaSignup': 'TẠO TÀI KHOẢN',
|
||||
'land.f1t': 'Công thức phim',
|
||||
'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… cộng bộ chỉnh tay EXPOSURE, WB, CLARITY, HDF, VIGNETTING.',
|
||||
'land.f2t': 'Khung & watermark',
|
||||
'land.f2d': 'Khung polaroid, viền classic, WALL FRAME, watermark chữ và dấu GPS đọc từ EXIF của ảnh.',
|
||||
'land.f3t': 'Xử lý ngay trên máy bạn',
|
||||
'land.f3d': 'Ảnh không bao giờ rời khỏi trình duyệt. Máy chủ chỉ giữ tài khoản và công thức bạn lưu.',
|
||||
'land.badgePlay': 'Tải trên Google Play',
|
||||
'land.badgeApp': 'Tải trên App Store',
|
||||
'land.storeSoon': 'Sắp có',
|
||||
|
||||
'drop.title': 'Kéo & thả ảnh vào đây',
|
||||
'drop.hint': 'hoặc bấm để chọn ảnh · dán từ clipboard bằng Ctrl+V',
|
||||
'drop.replace': 'Đổi ảnh khác',
|
||||
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
|
||||
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
|
||||
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
|
||||
|
||||
'tab.presets': 'PRESETS',
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
|
||||
'sec.filmSims': 'PHOTO STYLE',
|
||||
'sec.recipes': 'RECIPES',
|
||||
'sec.saved': 'ĐÃ LƯU',
|
||||
'sec.savedEmpty': 'Chưa lưu công thức nào. Đăng nhập rồi bấm LƯU để đồng bộ.',
|
||||
'sec.frame': 'KHUNG',
|
||||
'sec.crop': 'CẮT',
|
||||
'sec.rotate': 'XOAY',
|
||||
'sec.watermark': 'WATERMARK',
|
||||
'sec.gps': 'GPS',
|
||||
|
||||
'wb.temp': 'COLOR TEMP',
|
||||
|
||||
'frame.none': 'KHÔNG KHUNG',
|
||||
'frame.polaroid': 'RETRO INSTANT',
|
||||
'frame.classic': 'VIỀN CLASSIC',
|
||||
'frame.cinematic': 'ĐIỆN ẢNH',
|
||||
'frame.wall': 'WALL FRAME',
|
||||
'frame.wallLand': 'WALL LANDSCAPE',
|
||||
|
||||
'crop.none': 'KHÔNG CẮT',
|
||||
'crop.1:1': '1:1',
|
||||
'crop.2:3': '2:3',
|
||||
'crop.3:2': '3:2',
|
||||
'crop.3:4': '3:4',
|
||||
'crop.4:3': '4:3',
|
||||
'crop.16:9': '16:9',
|
||||
'crop.free': 'TỰ DO',
|
||||
|
||||
'rot.0': '0°',
|
||||
'rot.90': '90°',
|
||||
'rot.180': '180°',
|
||||
'rot.270': '270°',
|
||||
|
||||
'wm.text': 'CHỮ',
|
||||
'wm.color': 'MÀU',
|
||||
'wm.size': 'CỠ',
|
||||
'wm.gps': 'DẤU GPS',
|
||||
'wm.place': 'TÊN ĐỊA ĐIỂM',
|
||||
'wm.placePh': 'VD: HÀ NỘI',
|
||||
'wm.manual': 'NHẬP TOẠ ĐỘ',
|
||||
'wm.lat': 'Vĩ độ',
|
||||
'wm.lng': 'Kinh độ',
|
||||
'wm.hasExif': 'Đã đọc GPS từ ảnh.',
|
||||
'wm.noExif': 'Ảnh không có GPS — nhập tay nếu muốn đóng dấu.',
|
||||
|
||||
'auth.loginTitle': 'Đăng nhập',
|
||||
'auth.signupTitle': 'Tạo tài khoản',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Mật khẩu',
|
||||
'auth.passwordHint': 'Ít nhất 8 ký tự',
|
||||
'auth.noAccount': 'Chưa có tài khoản? Đăng ký',
|
||||
'auth.haveAccount': 'Đã có tài khoản? Đăng nhập',
|
||||
'auth.continueDemo': 'Tiếp tục dùng thử',
|
||||
'auth.busy': 'Đang xử lý…',
|
||||
'auth.loggedInAs': 'Đã đăng nhập: {email}',
|
||||
|
||||
'save.title': 'Lưu công thức',
|
||||
'save.name': 'Tên công thức',
|
||||
'save.save': 'LƯU',
|
||||
'save.saved': 'Đã lưu "{name}"',
|
||||
'save.needLogin': 'Đăng nhập để lưu công thức.',
|
||||
|
||||
'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.generic': 'Có lỗi xảy ra: {msg}',
|
||||
'err.offline': 'Không gọi được máy chủ.',
|
||||
|
||||
'meta.ready': 'Sẵn sàng',
|
||||
'meta.rendered': 'Kết xuất {ms}ms · {w}×{h}',
|
||||
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
|
||||
} as const;
|
||||
|
||||
// `vi` is `as const` so the key set is exact; `Dict` widens the values to
|
||||
// string, which is what lets `en.ts` hold different text under the same keys
|
||||
// while a missing or misspelled key stays a typecheck error.
|
||||
export type Dict = { [K in keyof typeof vi]: string };
|
||||
export type MsgKey = keyof Dict;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/tokens.css';
|
||||
import './styles/app.css';
|
||||
import { ThemeProvider } from './theme/ThemeProvider';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { Landing } from './Landing';
|
||||
import { Workspace } from './App';
|
||||
|
||||
// Two routes, no router: the landing page and the workspace. nginx serves
|
||||
// index.html for both (SPA fallback), so this is just a pathname check.
|
||||
const isWorkspace = window.location.pathname.startsWith('/app');
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('#root missing');
|
||||
|
||||
// No StrictMode: it double-invokes effects, which would load CanvasKit twice and
|
||||
// run the render pipeline twice on every mount.
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>{isWorkspace ? <Workspace /> : <Landing />}</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -0,0 +1,208 @@
|
||||
/* Workspace layout: header strip on top, then rail | chips | stage.
|
||||
Under 860px the rail becomes a bottom bar and the chips a horizontal strip,
|
||||
which is the same shape the phone app uses. */
|
||||
|
||||
.shell { display: flex; flex-direction: column; height: 100%; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--bg-elev);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 700; letter-spacing: 0.04em; }
|
||||
.brand small { color: var(--text-dim); font-weight: 400; margin-left: 6px; letter-spacing: 0; }
|
||||
.header .spacer { flex: 1 1 auto; }
|
||||
.recipe-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu-wrap { position: relative; }
|
||||
|
||||
.workspace { display: flex; flex: 1 1 auto; min-height: 0; }
|
||||
|
||||
/* --- tool rail ---------------------------------------------------------- */
|
||||
.rail {
|
||||
width: var(--rail-w);
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
background: var(--bg-sunken);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rail button .glyph { font-size: 17px; line-height: 1; }
|
||||
.rail button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
|
||||
.rail button:hover { color: var(--text); }
|
||||
|
||||
/* --- chip column -------------------------------------------------------- */
|
||||
.chips {
|
||||
width: var(--chip-w);
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid var(--border-soft);
|
||||
background: var(--bg);
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chips h2 { margin: 0 0 2px; font-size: 11px; letter-spacing: 0.14em; color: var(--text-dim); font-weight: 600; }
|
||||
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; }
|
||||
.chip .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
||||
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.slider-row { border-top: 1px solid var(--border-soft); padding-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); }
|
||||
.slider-row .head b { color: var(--accent); font-family: var(--mono); letter-spacing: 0; }
|
||||
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
|
||||
|
||||
/* --- stage -------------------------------------------------------------- */
|
||||
.stage {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: var(--bg-sunken);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px 28px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
max-width: 520px;
|
||||
}
|
||||
.dropzone.over { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
|
||||
.dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; }
|
||||
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; }
|
||||
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
|
||||
.busy {
|
||||
position: absolute;
|
||||
inset: auto 0 12px 0;
|
||||
margin: 0 auto;
|
||||
width: fit-content;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
|
||||
|
||||
/* --- landing ------------------------------------------------------------ */
|
||||
.landing { max-width: 960px; margin: 0 auto; padding: 56px 20px 72px; }
|
||||
.landing h1 { font-size: clamp(30px, 5vw, 52px); line-height: 1.1; margin: 0 0 14px; }
|
||||
.landing .lede { font-size: 17px; color: var(--text-dim); max-width: 620px; line-height: 1.6; }
|
||||
.landing .cta { display: flex; gap: 10px; margin: 24px 0 44px; flex-wrap: wrap; }
|
||||
.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||||
.card { border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--bg-elev); }
|
||||
.card h3 { margin: 0 0 6px; font-size: 14px; }
|
||||
.card p { margin: 0; color: var(--text-dim); line-height: 1.55; }
|
||||
.badges { display: flex; gap: 10px; margin-top: 34px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 14px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.badge small { color: var(--text-dim); font-size: 10px; letter-spacing: 0.08em; }
|
||||
|
||||
/* --- auth dialog -------------------------------------------------------- */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 60;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal h2 { margin: 0; font-size: 17px; }
|
||||
|
||||
/* --- responsive --------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
.workspace { flex-direction: column; }
|
||||
.rail {
|
||||
order: 2;
|
||||
width: auto;
|
||||
flex-direction: row;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
overflow-x: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
.rail button { flex: 1 0 auto; min-width: 62px; }
|
||||
.chips { order: 1; width: auto; border-right: none; border-bottom: 1px solid var(--border-soft); max-height: 42vh; }
|
||||
.stage { order: 0; flex: 1 1 auto; }
|
||||
.chip-row { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* Theme tokens. Switching = two attributes on <html>, set by ThemeProvider —
|
||||
no React re-render, no per-component colour code. Adding an accent group is
|
||||
one 3-line block below; see src/theme/themes.ts for the picker's list. */
|
||||
|
||||
:root {
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--rail-w: 84px;
|
||||
--chip-w: 300px;
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
/* --- base themes --------------------------------------------------------- */
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--bg-sunken: #f6f7f9;
|
||||
--bg-elev: #ffffff;
|
||||
--border: #dcdfe4;
|
||||
--border-soft: #eceef1;
|
||||
--text: #14181d;
|
||||
--text-dim: #5c6773;
|
||||
--danger: #c2402f;
|
||||
--accent-l: 42%;
|
||||
--accent-fg: #ffffff;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #000000;
|
||||
--bg-sunken: #0b0b0c;
|
||||
--bg-elev: #131315;
|
||||
--border: #2a2a2e;
|
||||
--border-soft: #1d1d20;
|
||||
--text: #f2f3f5;
|
||||
--text-dim: #9aa2ad;
|
||||
--danger: #ff6b57;
|
||||
--accent-l: 60%;
|
||||
--accent-fg: #0a0a0a;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* --- accent groups ------------------------------------------------------ */
|
||||
[data-accent="amber"] { --accent-h: 33; --accent-s: 92%; }
|
||||
[data-accent="blue"] { --accent-h: 214; --accent-s: 88%; }
|
||||
[data-accent="emerald"] { --accent-h: 158; --accent-s: 68%; }
|
||||
[data-accent="rose"] { --accent-h: 343; --accent-s: 82%; }
|
||||
[data-accent="violet"] { --accent-h: 265; --accent-s: 78%; }
|
||||
|
||||
:root {
|
||||
--accent: hsl(var(--accent-h) var(--accent-s) var(--accent-l));
|
||||
--accent-hover: hsl(var(--accent-h) var(--accent-s) calc(var(--accent-l) + 7%));
|
||||
--accent-soft: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.14);
|
||||
--accent-line: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.45);
|
||||
}
|
||||
|
||||
/* --- base elements ------------------------------------------------------ */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
a { color: var(--accent); }
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent-line); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); font-weight: 600; }
|
||||
.btn.primary:hover { background: var(--accent-hover); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-dim); }
|
||||
.btn.ghost:hover { color: var(--text); background: var(--bg-sunken); }
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 40;
|
||||
min-width: 220px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ACCENTS, applyTheme, readAccent, readTheme, type AccentId, type ThemeMode } from './themes';
|
||||
|
||||
interface ThemeValue {
|
||||
mode: ThemeMode;
|
||||
accent: AccentId;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setAccent: (accent: AccentId) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ThemeValue | null>(null);
|
||||
|
||||
// The tokens live in CSS; this provider only owns the two attributes on <html>
|
||||
// and the persisted choice. No component ever reads a colour from JS.
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [mode, setModeState] = useState<ThemeMode>(readTheme);
|
||||
const [accent, setAccentState] = useState<AccentId>(readAccent);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(mode, accent);
|
||||
}, [mode, accent]);
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => setModeState(next), []);
|
||||
const setAccent = useCallback((next: AccentId) => setAccentState(next), []);
|
||||
|
||||
const value = useMemo(() => ({ mode, accent, setMode, setAccent }), [mode, accent, setMode, setAccent]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeValue {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error('useTheme used outside ThemeProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
export { ACCENTS };
|
||||
export type { AccentId, ThemeMode };
|
||||
@@ -0,0 +1,41 @@
|
||||
// Theme data only — the CSS in styles/tokens.css owns the values. Adding an
|
||||
// accent group is one entry here plus one 1-line block in the stylesheet.
|
||||
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
export type AccentId = 'amber' | 'blue' | 'emerald' | 'rose' | 'violet';
|
||||
|
||||
export const ACCENTS: { id: AccentId; label: string }[] = [
|
||||
{ id: 'amber', label: 'Amber' },
|
||||
{ id: 'blue', label: 'Blue' },
|
||||
{ id: 'emerald', label: 'Emerald' },
|
||||
{ id: 'rose', label: 'Rose' },
|
||||
{ id: 'violet', label: 'Violet' },
|
||||
];
|
||||
|
||||
export const THEME_COLOR: Record<ThemeMode, string> = {
|
||||
light: '#ffffff',
|
||||
dark: '#0a0a0a',
|
||||
};
|
||||
|
||||
const KEY_THEME = 'rc.theme';
|
||||
const KEY_ACCENT = 'rc.accent';
|
||||
|
||||
export function readTheme(): ThemeMode {
|
||||
const stored = localStorage.getItem(KEY_THEME);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function readAccent(): AccentId {
|
||||
const stored = localStorage.getItem(KEY_ACCENT);
|
||||
return ACCENTS.some((a) => a.id === stored) ? (stored as AccentId) : 'amber';
|
||||
}
|
||||
|
||||
export function applyTheme(mode: ThemeMode, accent: AccentId): void {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = mode;
|
||||
root.dataset.accent = accent;
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLOR[mode]);
|
||||
localStorage.setItem(KEY_THEME, mode);
|
||||
localStorage.setItem(KEY_ACCENT, accent);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { api } from '../api';
|
||||
|
||||
// Email + password only: the cookie is set by the API container, this dialog
|
||||
// just drives it. The demo is always one click away — the app is usable signed
|
||||
// out, and only export resolution / saving are gated (see config/tiers.ts).
|
||||
export function AuthModal({
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await (mode === 'login' ? api.login(email, password) : api.signup(email, password));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<form className="modal" onSubmit={submit}>
|
||||
<h2>{mode === 'login' ? t('auth.loginTitle') : t('auth.signupTitle')}</h2>
|
||||
<input
|
||||
className="field"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t('auth.email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="field"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="hint">{t('auth.passwordHint')}</p>
|
||||
{error ? <p className="err">{error}</p> : null}
|
||||
<button type="submit" className="btn primary" disabled={busy}>
|
||||
{busy ? t('auth.busy') : mode === 'login' ? t('nav.login') : t('nav.signup')}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={() => setMode(mode === 'login' ? 'signup' : 'login')}>
|
||||
{mode === 'login' ? t('auth.noAccount') : t('auth.haveAccount')}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={onClose}>{t('auth.continueDemo')}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// One column, one open slider row at a time — the same shape as the app's
|
||||
// AdjustmentPanel: a chip row per group, and the tapped chip's row opens
|
||||
// underneath it.
|
||||
export interface ChipDef {
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
active?: boolean;
|
||||
pro?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface SliderDef {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
display: string;
|
||||
onChange: (value: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChipRow({ chips }: { chips: ChipDef[] }) {
|
||||
return (
|
||||
<div className="chip-row">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
className={`chip${chip.active ? ' on' : ''}`}
|
||||
aria-pressed={!!chip.active}
|
||||
disabled={chip.disabled}
|
||||
onClick={chip.onClick}
|
||||
>
|
||||
{chip.label}
|
||||
{chip.value ? <span className="val">{chip.value}</span> : null}
|
||||
{chip.pro ? <span className="pro">PRO</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SliderRow({ slider }: { slider: SliderDef }) {
|
||||
return (
|
||||
<div className="slider-row">
|
||||
<div className="head">
|
||||
<span>{slider.label}</span>
|
||||
<b>{slider.display}</b>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
value={slider.value}
|
||||
onChange={(e) => slider.onChange(Number(e.target.value))}
|
||||
/>
|
||||
<div className="chip-row">
|
||||
<button type="button" className="chip ghost" onClick={slider.onClose}>—</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
// The centre column: drop target, preview canvas, and the PEEK comparison.
|
||||
// Dragging a photo anywhere on the page works, not just on the frame — the
|
||||
// whole stage is the drop zone.
|
||||
export function ImageStage({
|
||||
previewUrl,
|
||||
originalUrl,
|
||||
busy,
|
||||
error,
|
||||
peek,
|
||||
onPeek,
|
||||
onFile,
|
||||
}: {
|
||||
previewUrl: string | null;
|
||||
originalUrl: string | null;
|
||||
busy: string | null;
|
||||
error: string | null;
|
||||
peek: boolean;
|
||||
onPeek: (on: boolean) => void;
|
||||
onFile: (file: File) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [over, setOver] = useState(false);
|
||||
|
||||
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === '\\') onPeek(true);
|
||||
};
|
||||
const up = (e: KeyboardEvent) => {
|
||||
if (e.key === '\\') onPeek(false);
|
||||
};
|
||||
window.addEventListener('keydown', down);
|
||||
window.addEventListener('keyup', up);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', down);
|
||||
window.removeEventListener('keyup', up);
|
||||
};
|
||||
}, [onPeek]);
|
||||
|
||||
const stop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const drop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
stop(e);
|
||||
setOver(false);
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) onFile(file);
|
||||
},
|
||||
[onFile, stop]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="stage"
|
||||
onDragOver={(e) => {
|
||||
stop(e);
|
||||
setOver(true);
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={drop}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) onPeek(true);
|
||||
}}
|
||||
onMouseUp={() => onPeek(false)}
|
||||
>
|
||||
{!previewUrl ? (
|
||||
<div
|
||||
className={`dropzone${over ? ' over' : ''}`}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
stop(e);
|
||||
setOver(true);
|
||||
}}
|
||||
onDrop={drop}
|
||||
>
|
||||
<strong>{t('drop.title')}</strong>
|
||||
<span>{t('drop.hint')}</span>
|
||||
<p className="hint">{t('drop.demoLimit', { px: 2048 })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="canvas-wrap">
|
||||
<img src={peek && originalUrl ? originalUrl : previewUrl} alt="preview" />
|
||||
{peek ? <div className="busy">{t('drop.peeking')}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewUrl ? (
|
||||
<div className="toolbar">
|
||||
<button type="button" className="btn" onClick={() => inputRef.current?.click()}>
|
||||
{t('drop.replace')}
|
||||
</button>
|
||||
<span className="hint">{t('drop.peekHint')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{busy ? <div className="busy">{busy}</div> : null}
|
||||
{error ? <p className="err">{error}</p> : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onFile(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MsgKey } from '../i18n/vi';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
// The app's six tabs, in the same order as the phone rail. The rail is vertical
|
||||
// on desktop and becomes the bottom bar under 860px (see styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame';
|
||||
|
||||
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
|
||||
{ id: 'favorited', label: 'tab.favorited', glyph: '★' },
|
||||
{ id: 'light', label: 'tab.light', glyph: '☀' },
|
||||
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
|
||||
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
|
||||
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
|
||||
];
|
||||
|
||||
export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<nav className="rail" aria-label="tool tabs">
|
||||
{TABS.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
aria-pressed={tab === entry.id}
|
||||
onClick={() => onTab(entry.id)}
|
||||
>
|
||||
<span className="glyph" aria-hidden="true">{entry.glyph}</span>
|
||||
{t(entry.label)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useI18n, LANGS } from '../i18n/I18nProvider';
|
||||
import { ACCENTS, useTheme } from '../theme/ThemeProvider';
|
||||
import type { User } from '../api';
|
||||
|
||||
// Header: the app's TopBar minus everything camera-only, plus the two controls
|
||||
// the web build adds — theme (light/dark + accent group) and language.
|
||||
export function TopBar({
|
||||
recipeName,
|
||||
user,
|
||||
exporting,
|
||||
canUndo,
|
||||
onUndo,
|
||||
onReset,
|
||||
onExport,
|
||||
onSave,
|
||||
onAuth,
|
||||
onLogout,
|
||||
}: {
|
||||
recipeName: string;
|
||||
user: User | null;
|
||||
exporting: boolean;
|
||||
canUndo: boolean;
|
||||
onUndo: () => void;
|
||||
onReset: () => void;
|
||||
onExport: () => void;
|
||||
onSave: () => void;
|
||||
onAuth: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { mode, accent, setMode, setAccent } = useTheme();
|
||||
const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null);
|
||||
const wrap = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const away = (e: MouseEvent) => {
|
||||
if (!wrap.current?.contains(e.target as Node)) setMenu(null);
|
||||
};
|
||||
document.addEventListener('mousedown', away);
|
||||
return () => document.removeEventListener('mousedown', away);
|
||||
}, [menu]);
|
||||
|
||||
return (
|
||||
<header className="header" ref={wrap}>
|
||||
<a className="brand" href="/">
|
||||
RecipesCam <small>web</small>
|
||||
</a>
|
||||
<span className="recipe-name">{recipeName}</span>
|
||||
<span className="spacer" />
|
||||
|
||||
<button type="button" className="btn ghost" disabled={!canUndo} onClick={onUndo}>{t('act.undo')}</button>
|
||||
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
|
||||
<button type="button" className="btn" onClick={onSave}>{t('save.save')}</button>
|
||||
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
|
||||
{exporting ? t('act.exporting') : t('act.export')}
|
||||
</button>
|
||||
|
||||
<div className="menu-wrap">
|
||||
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
|
||||
◐
|
||||
</button>
|
||||
{menu === 'theme' ? (
|
||||
<div className="popover">
|
||||
<div className="chip-row">
|
||||
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}>Light</button>
|
||||
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}>Dark</button>
|
||||
</div>
|
||||
<div className="chip-row" style={{ marginTop: 8 }}>
|
||||
{ACCENTS.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={`chip${accent === entry.id ? ' on' : ''}`}
|
||||
onClick={() => setAccent(entry.id)}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="menu-wrap">
|
||||
<button type="button" className="btn ghost" title={t('nav.language')} onClick={() => setMenu(menu === 'lang' ? null : 'lang')}>
|
||||
{lang.toUpperCase()}
|
||||
</button>
|
||||
{menu === 'lang' ? (
|
||||
<div className="popover">
|
||||
{LANGS.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={`chip${lang === entry.id ? ' on' : ''}`}
|
||||
onClick={() => {
|
||||
setLang(entry.id);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="menu-wrap">
|
||||
<button type="button" className="btn ghost" onClick={() => setMenu(menu === 'account' ? null : 'account')}>
|
||||
{user ? user.email.split('@')[0] : t('nav.guest')}
|
||||
</button>
|
||||
{menu === 'account' ? (
|
||||
<div className="popover">
|
||||
{user ? (
|
||||
<>
|
||||
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
|
||||
<button type="button" className="btn" onClick={onLogout}>{t('nav.logout')}</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn primary" onClick={onAuth}>{t('nav.login')}</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user