Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/photo-filmstrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ function FilmstripItem({ photo }: { photo: Photo }) {
)}
>
<img
src={photo.url}
src={photo.previewUrl ?? photo.url}
alt={photo.name}
className="size-full object-cover"
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/photo-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function StripItem({ photo, index }: { photo: Photo; index: number }) {
<GripVertical className="size-4" />
</span>
<img
src={photo.url}
src={photo.previewUrl ?? photo.url}
alt={photo.name}
className="size-11 shrink-0 rounded object-cover"
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/sheet-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function SheetCard({
style={{ height: imageH }}
>
<CroppedImage
src={photo.url}
src={photo.previewUrl ?? photo.url}
alt={photo.name}
crop={photo.crop}
aspect={aspect}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/strip-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function StripPage({
}
>
<CroppedImage
src={photo.url}
src={photo.previewUrl ?? photo.url}
alt={photo.name}
crop={photo.crop}
aspect={1}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/hooks/use-session-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function useSessionPersistence(): void {
// Guard against clobbering anything the user added during the load tick.
if (photos.length && usePhotoStore.getState().photos.length === 0) {
usePhotoStore.setState({ photos })
usePhotoStore.getState().generatePreviews()
}
if (settings) useSettingsStore.setState(settings)

Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/lib/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Longest-edge cap (px) for on-screen preview images. */
export const PREVIEW_MAX_EDGE = 1280

/**
* Downscales an already-decoded bitmap to a JPEG object URL no larger than
* `maxEdge` on its longest side. Returns null when the source is already small
* enough (caller should keep the original) or the browser can't make a blob.
*
* Rendering the original multi-megapixel files directly is what exhausts the
* mobile image-memory budget and leaves some frames blank/black; a small
* preview keeps the on-screen decode tiny while the full file is kept for export.
*/
export function bitmapToPreviewUrl(
bitmap: ImageBitmap,
maxEdge = PREVIEW_MAX_EDGE,
): Promise<string | null> {
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,
),
)
}
93 changes: 56 additions & 37 deletions apps/web/src/lib/pdf.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<HTMLImageElement | null> {
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<ImageBitmap | null> {
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<Uint8Array | null> {
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<Blob | null>((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<Blob | null>((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. */
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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, {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/photos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
99 changes: 73 additions & 26 deletions apps/web/src/stores/photo-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PhotoState>((set) => {
export const usePhotoStore = create<PhotoState>((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<unknown> = 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)
Expand Down Expand Up @@ -179,18 +214,30 @@ export const usePhotoStore = create<PhotoState>((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)
}
Loading