web: EXPORT offers a size, and a bigger one is upscaled in the browser
The server still never sees a photo, so the model has to run in the page. Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded lazily on the first export that actually needs it; the wasm runtime is copied next to CanvasKit at build time and stays lazily fetched, cached for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB asset lands in the bundle. UNCHANGED keeps the old path and the tier cap; 2K/4K/custom upscale only when the request is larger than the photo being edited, otherwise they resize down. Guests keep UNCHANGED and 2K. Tiling is 256px with an 8px overlap, so memory follows the target size rather than four times it.
This commit is contained in:
@@ -17,9 +17,21 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/css application/javascript application/json application/wasm image/svg+xml;
|
||||
|
||||
# canvas.wasm is ~8MB: immutable, hashed or not, it never changes under a
|
||||
# given build. Fonts and the wall-frame artwork are likewise static.
|
||||
location ~* ^/(wasm|assets)/ {
|
||||
# nginx's stock mime.types has no .mjs, and the browser refuses to run a module
|
||||
# the server hands over as octet-stream: onnxruntime's glue is one of those.
|
||||
# First match wins among regex locations, so this one stands before the
|
||||
# /wasm/ block that would otherwise swallow the file.
|
||||
location ~* \.mjs$ {
|
||||
types { application/javascript mjs; }
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# canvas.wasm is ~8MB and the super-resolution model is ~5MB: immutable, hashed
|
||||
# or not, they never change under a given build. Fonts and the wall-frame
|
||||
# artwork are likewise static.
|
||||
location ~* ^/(wasm|models|assets)/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
|
||||
Generated
+125
-2
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -813,6 +814,63 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1226,7 +1284,6 @@
|
||||
"version": "22.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
|
||||
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -1479,6 +1536,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flatbuffers": {
|
||||
"version": "25.9.23",
|
||||
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1504,6 +1567,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/guid-typescript": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1536,6 +1605,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1594,6 +1669,26 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.30.0.tgz",
|
||||
"integrity": "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.30.0.tgz",
|
||||
"integrity": "sha512-q0y+JrrtukXSzsBWEMccVfqX25LRmosXHF+CaRJmg8pZClzcV7svNc4rKY3jL02Vb7QmRMDs1SigqR4CXAfKYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flatbuffers": "^25.1.24",
|
||||
"guid-typescript": "^1.0.9",
|
||||
"long": "^5.2.3",
|
||||
"onnxruntime-common": "1.30.0",
|
||||
"platform": "^1.3.6",
|
||||
"protobufjs": "^7.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1614,6 +1709,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/platform": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
||||
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
@@ -1643,6 +1744,29 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
|
||||
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1788,7 +1912,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"exifr": "^7.1.3",
|
||||
"onnxruntime-web": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,31 @@
|
||||
// canvaskit-wasm resolves its wasm at runtime, not through the bundler, so the
|
||||
// binary has to be a served asset. 8MB in the repo is worse than 8MB copied out
|
||||
// of node_modules at build time (the Docker build stage does the same).
|
||||
// canvaskit and onnxruntime resolve their wasm at runtime, not through the
|
||||
// bundler, so the binaries have to be served assets. 20MB of wasm in the repo is
|
||||
// worse than 20MB copied out of node_modules at build time (the Docker build
|
||||
// stage does the same).
|
||||
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const from = 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm';
|
||||
const to = 'public/wasm/canvaskit.wasm';
|
||||
const copies = [
|
||||
['node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', 'public/wasm/canvaskit.wasm'],
|
||||
// onnxruntime-web's default entry (its own bundled glue) looks for the jsep
|
||||
// build of the runtime in `env.wasm.wasmPaths`; that build carries both the
|
||||
// WebGPU and the plain WebAssembly execution provider, so one file is enough.
|
||||
[
|
||||
'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm',
|
||||
'public/wasm/ort/ort-wasm-simd-threaded.jsep.wasm',
|
||||
],
|
||||
[
|
||||
'node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs',
|
||||
'public/wasm/ort/ort-wasm-simd-threaded.jsep.mjs',
|
||||
],
|
||||
];
|
||||
|
||||
if (!existsSync(from)) {
|
||||
console.error(`missing ${from} — run npm install first`);
|
||||
process.exit(1);
|
||||
for (const [from, to] of copies) {
|
||||
if (!existsSync(from)) {
|
||||
console.error(`missing ${from} — run npm install first`);
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(`copied ${from} -> ${to}`);
|
||||
}
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(`copied ${from} -> ${to}`);
|
||||
|
||||
@@ -8,12 +8,14 @@ import { AuthModal } from './ui/AuthModal';
|
||||
import { NameModal } from './ui/NameModal';
|
||||
import { DeletePhotoModal } from './ui/DeletePhotoModal';
|
||||
import { ClearPhotoModal } from './ui/ClearPhotoModal';
|
||||
import { ExportModal } from './ui/ExportModal';
|
||||
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { loadEngine } from './engine/assets';
|
||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readGps, readIso, resizedJpeg } from './engine/imageOps';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||
import { upscaleJpeg } from './engine/superRes';
|
||||
import { api, type Photo, type SavedRecipe, type User } from './api';
|
||||
import { TIERS } from './config/tiers';
|
||||
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
||||
@@ -294,6 +296,15 @@ export function Workspace() {
|
||||
}, []);
|
||||
const cropEditing = crop !== 'none' && !cropApplied;
|
||||
const cropLive = crop !== 'none' && cropApplied;
|
||||
// The two sizes an export is measured against: the photo as it arrived, and
|
||||
// the photo as the stage stands (a committed crop keeps only part of it). The
|
||||
// export menu names the second one, and asks the model to invent the pixels
|
||||
// when the target is bigger than it.
|
||||
const sourceLongest = photoDims ? Math.max(photoDims.w, photoDims.h) : 0;
|
||||
const editedLongest = Math.max(
|
||||
1,
|
||||
Math.round(sourceLongest * (cropLive && cropRect ? Math.max(cropRect.w, cropRect.h) : 1))
|
||||
);
|
||||
const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null;
|
||||
// A committed crop keeps only part of the photo, so the 1600px copy leaves
|
||||
// too few pixels behind and the stage shows a blocky upscale. While the crop
|
||||
@@ -387,6 +398,9 @@ export function Workspace() {
|
||||
};
|
||||
}, []);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// The export menu: the sizes are picked before the render starts, because a
|
||||
// target bigger than the photo has a model run behind it first.
|
||||
const [choosingExport, setChoosingExport] = useState(false);
|
||||
const [savingPhoto, setSavingPhoto] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
// PRO is the old "signed in", and then some: the account has to have proven
|
||||
@@ -1115,7 +1129,7 @@ export function Workspace() {
|
||||
|
||||
// `true` when a file was handed over, so CLEAR knows the export it offered
|
||||
// actually happened before the photo comes off the stage.
|
||||
const exportPhoto = useCallback(async (): Promise<boolean> => {
|
||||
const exportPhoto = useCallback(async (target: number | null): Promise<boolean> => {
|
||||
if (!engine || !source) return false;
|
||||
if (!pro && proLookInUse()) {
|
||||
promptPro();
|
||||
@@ -1125,7 +1139,25 @@ export function Workspace() {
|
||||
setError(null);
|
||||
try {
|
||||
const tier = TIERS[pro ? 'user' : 'guest'];
|
||||
const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes;
|
||||
// The menu's answer, under the tier's ceiling. UNCHANGED is `null`.
|
||||
const wanted = target === null ? null : Math.min(target, tier.maxExportDim ?? target);
|
||||
let src = source.bytes;
|
||||
if (wanted === null) {
|
||||
// The tier's ceiling is the only thing that still resizes, and only
|
||||
// downwards: this is the export the button did before it had a menu.
|
||||
if (tier.maxExportDim && sourceLongest > tier.maxExportDim) {
|
||||
src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95);
|
||||
}
|
||||
} else if (wanted > editedLongest) {
|
||||
// More pixels than the photo has: the model invents them here, on the
|
||||
// device — the picture never leaves it. The source is enlarged by the
|
||||
// same factor the crop will cut, so the file lands on `wanted` exactly.
|
||||
src = await upscaleJpeg(source.bytes, Math.round(sourceLongest * (wanted / editedLongest)), ({ done, total }) =>
|
||||
setBusy(t('act.upscaling', { done, total }))
|
||||
);
|
||||
} else if (wanted < sourceLongest) {
|
||||
src = await resizedJpeg(source.bytes, Math.round(sourceLongest * (wanted / editedLongest)), 0.95);
|
||||
}
|
||||
const out = await renderPhoto({
|
||||
source: src,
|
||||
recipe,
|
||||
@@ -1176,8 +1208,10 @@ export function Workspace() {
|
||||
return false;
|
||||
} finally {
|
||||
setExporting(false);
|
||||
// The upscale's own progress line goes with the export that wrote it.
|
||||
setBusy(null);
|
||||
}
|
||||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
|
||||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
|
||||
|
||||
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
|
||||
// The upload is left clean (no burned strip) so the landing reel can draw its
|
||||
@@ -1312,9 +1346,21 @@ export function Workspace() {
|
||||
[savePhotoToFolder, clearPhoto]
|
||||
);
|
||||
const exportThenClear = useCallback(async () => {
|
||||
if (await exportPhoto()) clearPhoto();
|
||||
// The CLEAR dialog's EXPORT hands over the file as it already is, no menu.
|
||||
if (await exportPhoto(null)) clearPhoto();
|
||||
}, [exportPhoto, clearPhoto]);
|
||||
|
||||
// EXPORT asks for a size first. The gate is checked here as well as in
|
||||
// exportPhoto so a guest never sees a menu it cannot use.
|
||||
const openExportPicker = useCallback(() => {
|
||||
if (!engine || !source) return;
|
||||
if (!pro && proLookInUse()) {
|
||||
promptPro();
|
||||
return;
|
||||
}
|
||||
setChoosingExport(true);
|
||||
}, [engine, source, pro, proLookInUse, promptPro]);
|
||||
|
||||
// The original file, handed back byte for byte — the untouched frame the
|
||||
// camera took, never the render. A page cannot write to the camera roll, so
|
||||
// on a phone the file goes to the share sheet and "Save Image" on it is the
|
||||
@@ -2123,7 +2169,7 @@ export function Workspace() {
|
||||
onRedo={redoLast}
|
||||
onReset={reset}
|
||||
onSavePhoto={() => void saveThenMaybeClear()}
|
||||
onExport={exportPhoto}
|
||||
onExport={openExportPicker}
|
||||
photoCount={myPhotos.length}
|
||||
photoMax={photoCap}
|
||||
onAuth={() => setAuthMode('login')}
|
||||
@@ -2528,6 +2574,18 @@ export function Workspace() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{choosingExport ? (
|
||||
<ExportModal
|
||||
longest={editedLongest}
|
||||
maxDim={TIERS[pro ? 'user' : 'guest'].maxExportDim}
|
||||
onPick={(target) => {
|
||||
setChoosingExport(false);
|
||||
void exportPhoto(target);
|
||||
}}
|
||||
onClose={() => setChoosingExport(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{removing ? (
|
||||
<DeletePhotoModal
|
||||
id={removing.id}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type * as Ort from 'onnxruntime-web';
|
||||
|
||||
// Inventing pixels, in the browser. A small Real-ESRGAN — realesr-general-x4v3,
|
||||
// a 4.9MB SRVGGNetCompact that turns one pixel into four — runs through
|
||||
// onnxruntime-web on the visitor's own machine. The photo never leaves the
|
||||
// device, so the API's promise ("Never sees a photo") survives an export that
|
||||
// asks for more pixels than the picture has.
|
||||
//
|
||||
// Both the runtime (served next to canvaskit.wasm, see scripts/copy-wasm.mjs)
|
||||
// and the model (public/models) are static files; nothing here changes what the
|
||||
// server can do.
|
||||
|
||||
const MODEL_URL = '/models/realesr-general-x4v3.onnx';
|
||||
// The wasm dir, not the file: the runtime picks its own name inside it.
|
||||
const WASM_DIR = '/wasm/ort/';
|
||||
// The model's own factor is fixed at 4; the destination is whatever the export
|
||||
// asked for, so each finished tile is drawn to its place in the output rather
|
||||
// than assembled at 4x and shrunk (which would cost the memory of both).
|
||||
const MODEL_SCALE = 4;
|
||||
// Source pixels per pass, and the margin the model sees but the draw discards —
|
||||
// without it every tile boundary shows as a seam.
|
||||
const TILE = 256;
|
||||
const PAD = 8;
|
||||
|
||||
export interface UpscaleProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
ort: typeof Ort;
|
||||
session: Ort.InferenceSession;
|
||||
}
|
||||
|
||||
let loaded: Promise<Loaded> | null = null;
|
||||
|
||||
// Loads the runtime and the model once per page. WebGPU first — an export waits
|
||||
// seconds instead of a minute — and the same jsep build falls back to plain
|
||||
// WebAssembly when the browser has no GPU adapter to give.
|
||||
function load(): Promise<Loaded> {
|
||||
if (!loaded) {
|
||||
loaded = (async () => {
|
||||
const ort = await import('onnxruntime-web');
|
||||
ort.env.wasm.wasmPaths = WASM_DIR;
|
||||
// No cross-origin isolation on this site, so the threaded path has no
|
||||
// SharedArrayBuffer to use; saying so keeps the runtime from fetching a
|
||||
// proxy worker it cannot run.
|
||||
ort.env.wasm.numThreads = 1;
|
||||
const session = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] }).catch(() =>
|
||||
ort.InferenceSession.create(MODEL_URL, { executionProviders: ['wasm'] })
|
||||
);
|
||||
return { ort, session };
|
||||
})().catch((err) => {
|
||||
// A failed load must not poison the next attempt.
|
||||
loaded = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// The photo's bytes, enlarged so its longest edge is `targetLongest`. The caller
|
||||
// only gets here when the target is larger than the picture (App decides), and
|
||||
// gets back the same bytes untouched when it is not.
|
||||
export async function upscaleJpeg(
|
||||
bytes: Uint8Array,
|
||||
targetLongest: number,
|
||||
onProgress?: (p: UpscaleProgress) => void
|
||||
): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]));
|
||||
try {
|
||||
const w = bitmap.width;
|
||||
const h = bitmap.height;
|
||||
const longest = Math.max(w, h);
|
||||
if (targetLongest <= longest) return bytes;
|
||||
const scale = targetLongest / longest;
|
||||
|
||||
const srcCanvas = new OffscreenCanvas(w, h);
|
||||
const srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const dstCanvas = new OffscreenCanvas(Math.max(1, Math.round(w * scale)), Math.max(1, Math.round(h * scale)));
|
||||
const dstCtx = dstCanvas.getContext('2d');
|
||||
if (!srcCtx || !dstCtx) return bytes;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
const src = srcCtx.getImageData(0, 0, w, h);
|
||||
|
||||
const { ort, session } = await load();
|
||||
const inputName = session.inputNames[0];
|
||||
const cols = Math.ceil(w / TILE);
|
||||
const rows = Math.ceil(h / TILE);
|
||||
let done = 0;
|
||||
onProgress?.({ done, total: cols * rows });
|
||||
|
||||
for (let ty = 0; ty < rows; ty++) {
|
||||
for (let tx = 0; tx < cols; tx++) {
|
||||
const x0 = tx * TILE;
|
||||
const y0 = ty * TILE;
|
||||
const tw = Math.min(TILE, w - x0);
|
||||
const th = Math.min(TILE, h - y0);
|
||||
// The margin the model gets: full on the inside, clipped at the photo's
|
||||
// own edge, so the tensor covers whole pixels only.
|
||||
const left = Math.min(PAD, x0);
|
||||
const top = Math.min(PAD, y0);
|
||||
const pw = tw + left + Math.min(PAD, w - (x0 + tw));
|
||||
const ph = th + top + Math.min(PAD, h - (y0 + th));
|
||||
|
||||
// NCHW, 0..1 RGB — what the model was trained to read.
|
||||
const input = new Float32Array(3 * pw * ph);
|
||||
const plane = pw * ph;
|
||||
for (let y = 0; y < ph; y++) {
|
||||
const srow = ((y0 - top + y) * w + (x0 - left)) * 4;
|
||||
for (let x = 0; x < pw; x++) {
|
||||
const s = srow + x * 4;
|
||||
input[y * pw + x] = src.data[s] / 255;
|
||||
input[plane + y * pw + x] = src.data[s + 1] / 255;
|
||||
input[2 * plane + y * pw + x] = src.data[s + 2] / 255;
|
||||
}
|
||||
}
|
||||
|
||||
const out = await session.run({ [inputName]: new ort.Tensor('float32', input, [1, 3, ph, pw]) });
|
||||
const tensor = out[session.outputNames[0]];
|
||||
const dims = tensor.dims as number[];
|
||||
const ow = dims[3];
|
||||
const oh = dims[2];
|
||||
const opts = tensor.data as Float32Array;
|
||||
|
||||
// Paint the tile's own pixels into a canvas of their own, then draw only
|
||||
// the unpadded middle of it into the output, at the output's scale.
|
||||
const tile = new OffscreenCanvas(ow, oh);
|
||||
const tileCtx = tile.getContext('2d');
|
||||
if (!tileCtx) return bytes;
|
||||
const img = tileCtx.createImageData(ow, oh);
|
||||
const oplane = ow * oh;
|
||||
for (let i = 0; i < oplane; i++) {
|
||||
const d = i * 4;
|
||||
img.data[d] = clamp8(opts[i]);
|
||||
img.data[d + 1] = clamp8(opts[oplane + i]);
|
||||
img.data[d + 2] = clamp8(opts[2 * oplane + i]);
|
||||
img.data[d + 3] = 255;
|
||||
}
|
||||
tileCtx.putImageData(img, 0, 0);
|
||||
dstCtx.drawImage(
|
||||
tile,
|
||||
left * MODEL_SCALE,
|
||||
top * MODEL_SCALE,
|
||||
tw * MODEL_SCALE,
|
||||
th * MODEL_SCALE,
|
||||
x0 * scale,
|
||||
y0 * scale,
|
||||
tw * scale,
|
||||
th * scale
|
||||
);
|
||||
|
||||
done++;
|
||||
onProgress?.({ done, total: cols * rows });
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await dstCanvas.convertToBlob({ type: 'image/jpeg', quality: 0.95 });
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
function clamp8(v: number): number {
|
||||
const n = v * 255;
|
||||
return n <= 0 ? 0 : n >= 255 ? 255 : Math.round(n);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const en: Dict = {
|
||||
'act.reset': 'RESET',
|
||||
'act.export': 'EXPORT',
|
||||
'act.exporting': 'Exporting…',
|
||||
'act.upscaling': 'Upscaling the photo {done}/{total}…',
|
||||
'act.cancel': 'Cancel',
|
||||
|
||||
'drop.title': 'Drag & drop a photo here',
|
||||
@@ -174,6 +175,18 @@ export const en: Dict = {
|
||||
'clear.discard': 'CLEAR WITHOUT SAVING',
|
||||
'clear.cancel': 'CANCEL',
|
||||
|
||||
// Export size. A target over the photo's own longest edge is upscaled on the
|
||||
// device by the model (engine/superRes), so the file carries the pixels asked for.
|
||||
'export.title': 'Export photo',
|
||||
'export.hint':
|
||||
"Longest edge of the photo on the stage: {px}px. Anything bigger is upscaled on this device.",
|
||||
'export.same': 'EXPORT UNCHANGED',
|
||||
'export.2k': 'EXPORT 2K (2048px)',
|
||||
'export.4k': 'EXPORT 4K (4096px)',
|
||||
'export.customLabel': 'Longest edge (px)',
|
||||
'export.custom': 'EXPORT',
|
||||
'export.cap': 'Guest exports are capped at {px}px.',
|
||||
|
||||
'photo.uploading': 'UPLOADING…',
|
||||
|
||||
'photos.title': 'My photos',
|
||||
|
||||
@@ -25,6 +25,7 @@ export const vi = {
|
||||
'act.reset': 'ĐẶT LẠI',
|
||||
'act.export': 'XUẤT ẢNH',
|
||||
'act.exporting': 'Đang xuất…',
|
||||
'act.upscaling': 'Đang phóng to ảnh {done}/{total}…',
|
||||
'act.cancel': 'Huỷ',
|
||||
|
||||
'drop.title': 'Kéo & thả ảnh vào đây',
|
||||
@@ -182,6 +183,18 @@ export const vi = {
|
||||
'clear.discard': 'XOÁ KHÔNG LƯU',
|
||||
'clear.cancel': 'HUỶ',
|
||||
|
||||
// Xuất ảnh: kích thước đích. Mức lớn hơn cạnh dài nhất của ảnh đang sửa được
|
||||
// model phóng to ngay trên máy (engine/superRes), nên tệp ra đúng số pixel đã hỏi.
|
||||
'export.title': 'Xuất ảnh',
|
||||
'export.hint':
|
||||
'Cạnh dài nhất của ảnh đang sửa: {px}px. Mức lớn hơn sẽ được phóng to ngay trên máy này.',
|
||||
'export.same': 'XUẤT KHÔNG ĐỔI',
|
||||
'export.2k': 'XUẤT 2K (2048px)',
|
||||
'export.4k': 'XUẤT 4K (4096px)',
|
||||
'export.customLabel': 'Cạnh dài nhất (px)',
|
||||
'export.custom': 'XUẤT',
|
||||
'export.cap': 'Bản khách giới hạn ở {px}px.',
|
||||
|
||||
'photo.uploading': 'ĐANG TẢI…',
|
||||
|
||||
'photos.title': 'Ảnh của tôi',
|
||||
|
||||
@@ -480,6 +480,14 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
gap: 10px;
|
||||
}
|
||||
.modal h2 { margin: 0; font-size: 17px; }
|
||||
/* The one row in the export menu that is not a plain button: a number to type
|
||||
and the button that takes it. */
|
||||
.export-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.export-custom .field { width: 96px; }
|
||||
|
||||
/* --- strip moderation (/admin) ------------------------------------------ */
|
||||
.adm { height: 100%; overflow-y: auto; padding: 0 22px 40px; }
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
// Export size. The photo on the stage has a longest edge; the three fixed
|
||||
// answers name the sizes people ask for by name (2048/4096), the fourth takes a
|
||||
// number, and UNCHANGED is what the button used to do on its own. Anything
|
||||
// bigger than the photo is invented by the model (engine/superRes) before the
|
||||
// render, so the file really carries the pixels the user asked for.
|
||||
const CUSTOM_MAX = 8192;
|
||||
|
||||
export function ExportModal({
|
||||
longest,
|
||||
maxDim,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
/** Longest edge of the photo as it stands, crop included. */
|
||||
longest: number;
|
||||
/** The tier's own ceiling, or null for none. */
|
||||
maxDim: number | null;
|
||||
onPick: (target: number | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [custom, setCustom] = useState(String(Math.min(CUSTOM_MAX, Math.max(1024, longest * 2))));
|
||||
|
||||
const pick = (n: number | null) => onPick(n !== null && maxDim !== null ? Math.min(n, maxDim) : n);
|
||||
const customValue = Number(custom);
|
||||
const customOk = Number.isFinite(customValue) && customValue >= 1 && customValue <= CUSTOM_MAX;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" data-key="export-modal">
|
||||
<h2>{t('export.title')}</h2>
|
||||
<p className="hint" data-key="export-hint">
|
||||
{t('export.hint', { px: longest })}
|
||||
</p>
|
||||
<button type="button" className="btn" data-key="export-same" onClick={() => pick(null)}>
|
||||
{t('export.same')}
|
||||
</button>
|
||||
<button type="button" className="btn" data-key="export-2k" onClick={() => pick(2048)}>
|
||||
{t('export.2k')}
|
||||
</button>
|
||||
{/* A capped account cannot be handed a size its tier refuses, so the two
|
||||
sizes over the ceiling are not offered at all. */}
|
||||
{maxDim === null || maxDim > 2048 ? (
|
||||
<>
|
||||
<button type="button" className="btn" data-key="export-4k" onClick={() => pick(4096)}>
|
||||
{t('export.4k')}
|
||||
</button>
|
||||
<div className="export-custom" data-key="export-custom">
|
||||
<label className="hint" htmlFor="export-custom-px">
|
||||
{t('export.customLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="export-custom-px"
|
||||
className="field"
|
||||
type="number"
|
||||
min={1}
|
||||
max={CUSTOM_MAX}
|
||||
value={custom}
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="export-custom-go"
|
||||
disabled={!customOk}
|
||||
onClick={() => customOk && pick(Math.round(customValue))}
|
||||
>
|
||||
{t('export.custom')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint" data-key="export-cap">
|
||||
{t('export.cap', { px: maxDim })}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className="btn ghost" data-key="export-cancel" onClick={onClose}>
|
||||
{t('clear.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function TopBar({
|
||||
? t('save.photoNoMax', { n: photoCount })
|
||||
: t('save.photo', { n: photoCount, max: photoMax })}
|
||||
</button>
|
||||
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
|
||||
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting} onClick={onExport}>
|
||||
{exporting ? t('act.exporting') : t('act.export')}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import { fileURLToPath, URL } from 'node:url';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// onnxruntime-web ships two flavours of its default entry: one with the
|
||||
// runtime's wasm inlined as a build asset, one that fetches it at runtime.
|
||||
// This picks the second, so the 28MB binary is served from /wasm/ort/ (see
|
||||
// scripts/copy-wasm.mjs) instead of being emitted twice into dist.
|
||||
conditions: ['onnxruntime-web-use-extern-wasm'],
|
||||
alias: {
|
||||
'@shopify/react-native-skia': fileURLToPath(new URL('./src/engine/skiaShim.ts', import.meta.url)),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user