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
+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;