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 }) {
)}
>
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 }) {
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)
+}