From f201deee4600675c8a1e81b0536dba086cb2b1f9 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 22 Sep 2026 20:43:46 +0700 Subject: [PATCH] web: export a big photo without inventing pixels it already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export menu measured the photo off the 1600px preview copy, so a 2400px photo was believed to be 1600px across: the hint named the wrong size, the model was asked to upscale a photo that already had more pixels than the target, and a guest's 2048 ceiling was skipped because 1600 never crossed it. A committed crop made it worse — the crop's longest edge was taken from the wider side of the crop rect rather than the side the frame actually keeps, so a 2400x1800 photo with the default 0.8 frame was called 1280px and ran the model over 80 tiles (158.7s) to reach 2K. The photo's own dimensions are now read off the original bytes, and the crop's long edge is the same axis-aware fraction the stage already uses. The export asks the model only when the photo itself is short of the requested size, or when the crop would have to be stretched past 1.5x to get there; otherwise it resamples — down, or a hair up to make up for the crop — which is what a photo that already holds the pixels deserves. Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to 3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a 1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact, with the model's 16.6 edge energy against bilinear's 4.8. --- docker/frontend/src/App.tsx | 59 +++++++++++++++++--------- docker/frontend/src/engine/imageOps.ts | 26 ++++++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) 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.