Add self-contained docker/ stack for the web UI

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

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

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

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

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

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