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:
2026-09-22 09:40:18 +07:00
parent f9a40a9e1c
commit c0c99a9672
13 changed files with 17553 additions and 22 deletions
+63 -5
View File
@@ -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}