diff --git a/apps/web/src/components/photo-filmstrip.tsx b/apps/web/src/components/photo-filmstrip.tsx index 987291f..f295149 100644 --- a/apps/web/src/components/photo-filmstrip.tsx +++ b/apps/web/src/components/photo-filmstrip.tsx @@ -112,7 +112,7 @@ function FilmstripItem({ photo }: { photo: Photo }) { )} > {photo.name} diff --git a/apps/web/src/components/photo-strip.tsx b/apps/web/src/components/photo-strip.tsx index 35386c0..6a279f9 100644 --- a/apps/web/src/components/photo-strip.tsx +++ b/apps/web/src/components/photo-strip.tsx @@ -99,7 +99,7 @@ function StripItem({ photo, index }: { photo: Photo; index: number }) { {photo.name} diff --git a/apps/web/src/components/sheet-card.tsx b/apps/web/src/components/sheet-card.tsx index eb0bc32..cbfc95e 100644 --- a/apps/web/src/components/sheet-card.tsx +++ b/apps/web/src/components/sheet-card.tsx @@ -77,7 +77,7 @@ export function SheetCard({ style={{ height: imageH }} > { + const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height)) + if (scale >= 1) return Promise.resolve(null) + const w = Math.max(1, Math.round(bitmap.width * scale)) + const h = Math.max(1, Math.round(bitmap.height * scale)) + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d') + if (!ctx) return Promise.resolve(null) + ctx.drawImage(bitmap, 0, 0, w, h) + return new Promise((resolve) => + canvas.toBlob( + (blob) => resolve(blob ? URL.createObjectURL(blob) : null), + 'image/jpeg', + 0.85, + ), + ) +} diff --git a/apps/web/src/lib/pdf.ts b/apps/web/src/lib/pdf.ts index 9cab69a..ed67bb4 100644 --- a/apps/web/src/lib/pdf.ts +++ b/apps/web/src/lib/pdf.ts @@ -1,7 +1,7 @@ import { PDFDocument, StandardFonts, rgb } from 'pdf-lib' import { captionColors, hexToRgb01 } from '@/lib/color' -import { type Crop, type Orientation, cropSource, orientationAspect } from '@/lib/crop' +import { type Orientation, cropSource, orientationAspect } from '@/lib/crop' import { type PaperSize, CARD, @@ -15,47 +15,71 @@ import { type Photo } from '@/lib/photos' import { paginateStrips, stripLayout, toStrips } from '@/lib/strip' const IMAGE_DPI = 300 +// Cap the decoded source. Well above 300 DPI for any A4 card (a full-page card +// is ~3500px), but keeps huge camera files from blowing the mobile image-memory +// budget — which otherwise leaves photos missing from the exported PDF. +const PRINT_MAX_EDGE = 3600 -function loadImage(url: string): Promise { - return new Promise((resolve) => { - const img = new Image() - img.onload = () => resolve(img) - img.onerror = () => resolve(null) - img.src = url - }) +async function decodeForPrint(photo: Photo): Promise { + const { file, naturalWidth: nw, naturalHeight: nh } = photo + const oriented: ImageBitmapOptions = { imageOrientation: 'from-image' } + try { + if (nw && nh && Math.max(nw, nh) > PRINT_MAX_EDGE) { + const scale = PRINT_MAX_EDGE / Math.max(nw, nh) + return await createImageBitmap(file, { + ...oriented, + resizeWidth: Math.round(nw * scale), + resizeHeight: Math.round(nh * scale), + resizeQuality: 'high', + }) + } + return await createImageBitmap(file, oriented) + } catch { + // Older browsers may reject the resize/orientation options — decode plainly. + try { + return await createImageBitmap(file) + } catch { + return null + } + } } /** * Draws the cropped photo onto a canvas of the window's pixel size and returns * sRGB JPEG bytes. The sampled region comes from `cropSource`, so it matches the * on-screen framing exactly. Canvas output is always sRGB — what photo labs and - * home inkjets want (see README). Handles any format the browser can decode. + * home inkjets want (see README). The source is decoded one at a time and freed + * immediately (`bitmap.close()`) so a sheet full of large photos can't exhaust + * memory mid-export. Handles any format the browser can decode. */ async function rasterizeJpeg( - url: string, + photo: Photo, outW: number, outH: number, - crop: Crop, ): Promise { - const img = await loadImage(url) - if (!img) return null - const canvas = document.createElement('canvas') - canvas.width = outW - canvas.height = outH - const ctx = canvas.getContext('2d') - if (!ctx) return null - const { sx, sy, sw, sh } = cropSource( - img.naturalWidth, - img.naturalHeight, - crop, - outW / outH, - ) - ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH) - const blob = await new Promise((resolve) => - canvas.toBlob(resolve, 'image/jpeg', 0.92), - ) - if (!blob) return null - return new Uint8Array(await blob.arrayBuffer()) + const bitmap = await decodeForPrint(photo) + if (!bitmap) return null + try { + const canvas = document.createElement('canvas') + canvas.width = outW + canvas.height = outH + const ctx = canvas.getContext('2d') + if (!ctx) return null + const { sx, sy, sw, sh } = cropSource( + bitmap.width, + bitmap.height, + photo.crop, + outW / outH, + ) + ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, outW, outH) + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/jpeg', 0.92), + ) + if (!blob) return null + return new Uint8Array(await blob.arrayBuffer()) + } finally { + bitmap.close() + } } /** Builds a paginated, sRGB, print-ready PDF of all photos on the given stock. */ @@ -150,12 +174,7 @@ export async function buildSheetPdf( // The photo covers the image box edge-to-edge — the even border around it // is the frame card showing through. const toPx = (pt: number) => Math.round((pt / PT_PER_MM) * (IMAGE_DPI / 25.4)) - const jpeg = await rasterizeJpeg( - photo.url, - toPx(imgW), - toPx(imgH), - photo.crop, - ) + const jpeg = await rasterizeJpeg(photo, toPx(imgW), toPx(imgH)) if (jpeg) { const embedded = await doc.embedJpg(jpeg) page.drawImage(embedded, { @@ -272,7 +291,7 @@ export async function buildStripPdf( const pr = photoRects[i] const pw = pr.width * PT_PER_MM const ph = pr.height * PT_PER_MM - const jpeg = await rasterizeJpeg(photo.url, toPx(pw), toPx(ph), photo.crop) + const jpeg = await rasterizeJpeg(photo, toPx(pw), toPx(ph)) if (!jpeg) continue const embedded = await doc.embedJpg(jpeg) page.drawImage(embedded, { diff --git a/apps/web/src/lib/photos.ts b/apps/web/src/lib/photos.ts index ad4005b..34a695a 100644 --- a/apps/web/src/lib/photos.ts +++ b/apps/web/src/lib/photos.ts @@ -8,8 +8,13 @@ import { export interface Photo { id: string file: File - /** Object URL for previewing — must be revoked when the photo is removed. */ + /** Object URL of the original file — must be revoked when the photo is removed. */ url: string + /** Downscaled preview object URL for on-screen rendering; falls back to `url`. */ + previewUrl?: string + /** Natural pixel size after EXIF orientation, captured when first decoded. */ + naturalWidth?: number + naturalHeight?: number name: string size: number /** Card caption lines (auto-filled from EXIF later; editable). */ diff --git a/apps/web/src/stores/photo-store.ts b/apps/web/src/stores/photo-store.ts index 8164975..b038637 100644 --- a/apps/web/src/stores/photo-store.ts +++ b/apps/web/src/stores/photo-store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand' import { type Crop, type Orientation, orientationFor } from '@/lib/crop' import { type DateFormat, formatCaptionDate, isAutoDate } from '@/lib/date' import { readExif } from '@/lib/exif' +import { bitmapToPreviewUrl } from '@/lib/image' import { isAutoLocation, placeLabel, reverseGeocode } from '@/lib/geocode' import { type CaptionField, @@ -34,34 +35,68 @@ interface PhotoState { remove: (id: string) => void /** Replaces the whole set (e.g. opening a project file), revoking old URLs. */ replaceAll: (photos: Photo[]) => void + /** Builds preview URLs (and captures sizes) for any photos still missing them. */ + generatePreviews: () => void clear: () => void } -export const usePhotoStore = create((set) => { +export const usePhotoStore = create((set, get) => { + // Serialise decoding so we never hold several full-resolution bitmaps at once + // — that memory spike is what makes mobile browsers drop images (blank/black + // frames on screen and missing photos in the PDF). Each photo is decoded once + // (respecting EXIF orientation) to capture its natural size, default its + // window orientation, and build a small preview URL for on-screen rendering. + // The full-resolution file is untouched and used only for export. + let previewQueue: Promise = Promise.resolve() + + function processImage(photo: Photo) { + previewQueue = previewQueue.then(async () => { + let bitmap: ImageBitmap + try { + bitmap = await createImageBitmap(photo.file, { + imageOrientation: 'from-image', + }) + } catch { + return // undecodable — the original url stays the on-screen fallback + } + const width = bitmap.width + const height = bitmap.height + let previewUrl: string | null = null + try { + previewUrl = await bitmapToPreviewUrl(bitmap) + } finally { + bitmap.close() + } + set((state) => { + if (!state.photos.some((p) => p.id === photo.id)) { + if (previewUrl) URL.revokeObjectURL(previewUrl) // removed mid-decode + return state + } + return { + photos: state.photos.map((p) => + p.id === photo.id + ? { + ...p, + naturalWidth: width, + naturalHeight: height, + previewUrl: previewUrl ?? p.previewUrl, + orientation: + p.orientation === 'square' + ? orientationFor(width, height) + : p.orientation, + } + : p, + ), + } + }) + }) + } + // Fills empty captions from EXIF — city from GPS, date from capture time. // Runs async after the photo appears; never overwrites a caption the user // has already typed. - // Defaults a photo's window orientation to its own aspect ratio, unless the - // user has already changed it away from the square default. - async function autoOrient(photo: Photo) { - try { - const bitmap = await createImageBitmap(photo.file) - const orientation = orientationFor(bitmap.width, bitmap.height) - bitmap.close() - set((state) => ({ - photos: state.photos.map((p) => - p.id === photo.id && p.orientation === 'square' - ? { ...p, orientation } - : p, - ), - })) - } catch { - // Leave the square default if the image can't be decoded. - } - } - async function enrich(photo: Photo) { - void autoOrient(photo) + processImage(photo) try { const settings = useSettingsStore.getState() const exif = await readExif(photo.file) @@ -179,18 +214,30 @@ export const usePhotoStore = create((set) => { remove: (id) => set((state) => { const target = state.photos.find((photo) => photo.id === id) - if (target) URL.revokeObjectURL(target.url) + if (target) revokeUrls(target) return { photos: state.photos.filter((photo) => photo.id !== id) } }), - replaceAll: (photos) => + replaceAll: (photos) => { set((state) => { - state.photos.forEach((photo) => URL.revokeObjectURL(photo.url)) + state.photos.forEach(revokeUrls) return { photos } - }), + }) + photos.forEach((photo) => processImage(photo)) + }, + generatePreviews: () => { + for (const photo of get().photos) { + if (!photo.naturalWidth) processImage(photo) + } + }, clear: () => set((state) => { - state.photos.forEach((photo) => URL.revokeObjectURL(photo.url)) + state.photos.forEach(revokeUrls) return { photos: [] } }), } }) + +function revokeUrls(photo: Photo) { + URL.revokeObjectURL(photo.url) + if (photo.previewUrl) URL.revokeObjectURL(photo.previewUrl) +}