diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 1556cd6..932b6be 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -13,7 +13,7 @@ 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 { download, fileToBytes, readGps, readIso, resizedJpeg, scaledJpeg } 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'; @@ -62,6 +62,11 @@ const ORIGINAL_MIME = { // what the decode can afford: past it a wheel gesture stops keeping up. const PREVIEW_HARD_MAX = 3200; const CROP_PREVIEW_MIN_FRAC = 0.05; +// An export resamples a whole photo by up to this factor without the model. +// Below it a plain filter and the model are the same picture and the filter +// finishes in a second; above it (a small crop of a big photo) the model earns +// the minutes it costs. Never a cap on the exported size itself. +const MODEL_GAIN = 1.5; // Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it // on every notch. const PREVIEW_QUANTUM = 512; @@ -302,16 +307,6 @@ 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 // is live the source is re-derived larger — 1/f, where f is the crop's @@ -327,6 +322,18 @@ export function Workspace() { const long = Math.max(d?.w ?? 0, d?.h ?? 0); return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h); })(); + // 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 the photo itself. + const sourceLongest = photoDims ? Math.max(photoDims.w, photoDims.h) : 0; + // The crop's own long edge: the frame's shape decides which side of the + // rotated photo it eats, so the fraction above (not the wider of the two + // rect sides) is what scales the photo's longest edge down. A 1:1 crop of a + // 4:3 frame keeps full height, and this is what keeps the export from + // believing the crop is still 1600px across. + const editedLongest = Math.max(1, Math.round(sourceLongest * (cropLive ? cropLongFrac : 1))); + const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; const previewMax = Math.min( PREVIEW_HARD_MAX, Math.max( @@ -700,9 +707,11 @@ export function Workspace() { const preview = await resizedJpeg(bytes, PREVIEW_MAX); const found = await readGps(bytes); const foundIso = await readIso(bytes); - // The photo's own shape drives the default crop frame. Read off the - // downscaled copy — same aspect, a fraction of the decode cost. - const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' })) + // The photo's own shape drives the default crop frame, and its own pixels + // are what an export is measured against — a 1600px copy would make a + // 4000px photo look like it needed inventing pixels it already has. Read + // off the original, not the preview. + const dims = await createImageBitmap(new Blob([bytes as BlobPart])) .then((bmp) => { const d = { w: bmp.width, h: bmp.height }; bmp.close(); @@ -1177,6 +1186,9 @@ export function Workspace() { const tier = TIERS[pro ? 'user' : 'guest']; // The menu's answer, under the tier's ceiling. UNCHANGED is `null`. const wanted = target === null ? null : Math.min(target, tier.maxExportDim ?? target); + // How far the photo's own long edge must be stretched for the CROP to + // land on `wanted`: the render keeps only `editedLongest` of the photo. + const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest); let src = source.bytes; if (wanted === null) { // The tier's ceiling is the only thing that still resizes, and only @@ -1184,15 +1196,20 @@ export function Workspace() { 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 }) => + } else if (wanted > sourceLongest || needLongest > sourceLongest * MODEL_GAIN) { + // The photo is short of the pixels being asked for — more so once the + // crop is counted — so the model invents them here, on the device; the + // picture never leaves it. The whole photo is enlarged by the factor the + // crop will cut it back by, so the file lands on `wanted` exactly. + src = await upscaleJpeg(source.bytes, needLongest, ({ done, total }) => setBusy(t('act.upscaling', { done, total })) ); - } else if (wanted < sourceLongest) { - src = await resizedJpeg(source.bytes, Math.round(sourceLongest * (wanted / editedLongest)), 0.95); + } else if (needLongest !== sourceLongest) { + // The photo already holds every pixel the export asks for, so there is + // nothing to invent: resample the whole photo (down, or a hair up to + // make up for the crop) and let the render cut the crop out of it. This + // is what keeps a 2400px photo exportable at 2K in a second. + src = await scaledJpeg(source.bytes, needLongest); } const out = await renderPhoto({ source: src, diff --git a/docker/frontend/src/engine/imageOps.ts b/docker/frontend/src/engine/imageOps.ts index 64126b6..62c30a5 100644 --- a/docker/frontend/src/engine/imageOps.ts +++ b/docker/frontend/src/engine/imageOps.ts @@ -31,6 +31,32 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0 } } +// The same resample, but it enlarges as well as shrinks and always re-encodes. +// `resizedJpeg` refuses to grow a photo back, which is right for the preview +// copy; the export needs the growth because a committed crop keeps only part of +// the photo, so the whole photo has to be stretched a little for the crop to +// land on the requested size — and a plain filter is the honest answer when the +// photo already holds those pixels. Only ever called with a target the photo +// already covers (see the export's gain guard). +export async function scaledJpeg(bytes: Uint8Array, targetLongest: number, quality = 0.95): Promise { + const bitmap = await createImageBitmap(new Blob([bytes as BlobPart])); + try { + const longest = Math.max(bitmap.width, bitmap.height); + if (longest === targetLongest) return bytes; + const scale = targetLongest / 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(); + } +} + // The colour under one point of a photo, for the HSL mixer's eyedropper. // `fx`/`fy` are fractions of the photo (0..1, from the click position over the // ), so the caller needs no knowledge of the stage's zoom, pan or box.