Engineering a Photography Portfolio Without Sacrificing the Photographs
I wanted the photographs to keep their detail, privacy, and 360-degree behavior without making every visitor download the archive.
- Frontend Engineer & Photographer
- Independent
- 2026
- I had twenty photographs ready to publish, but the folder mixed RAW files, photospheres, and private EXIF that I could not simply move into the public site.
- I built one deterministic, read-only pipeline with separate raster, RAW, and photosphere branches, then exposed only role-specific files through a typed manifest.
- I turned 172.5 MB of sources into 28.9 MB of generated assets while keeping the gallery view to 201 KB of image transfer.

Photography Is Part of the Product
I built this photography page inside my frontend-engineering portfolio, so I treated image delivery as part of the work rather than a final export step. The photographs still had to feel visually convincing, but the page also had to behave like a fast, accessible application.
I had already designed the contact-sheet layout, darkroom interactions, keyboard lightbox, responsive columns, and reduced-motion behavior. I deliberately kept this phase narrower: replace the placeholder frames with a safe, reproducible asset system without redesigning the page around the pipeline.
One Folder, Several Different Problems
- Large and inconsistent sources: My selected set totalled 172.5 MB and mixed portrait, landscape, wide, alpha-channel PNG, JPEG, and DNG inputs.
- Private metadata: I found GPS markers in seven files. I wanted to keep useful camera and lens details, but coordinates, serials, source paths, filenames, and editing identities could not enter the public bundle.
- Different viewing contexts: I did not want a dense masonry grid, a small filmstrip, a full-screen viewer, and an 8K photosphere to request the same file when each context needed a different level of detail.
- RAW and panorama branches: I needed explicit fallback rules for two DNGs, and I wanted two confirmed photospheres to remain interactive instead of publishing them as flattened wide strips.
The Rules I Set Before Optimising
I made the source folder read-only. The pipeline never renames, moves, edits, strips, or deletes an original, and I verify every source checksum again after processing.
For the first version, I kept delivery local and static, left categories and tags empty, drafted factual alt text for review, avoided creative colour grading, and refused to upscale an image. I also preserved reduced-motion behavior and deferred each panorama until someone chose to open it.
What I Found Before Processing
- 22
- 541 MP
- 7
I was working with twenty standard rasters and two DNGs. I used GPano XMP to confirm that two files were genuine equirectangular photospheres. Six other files had a 2:1 ratio, but I kept them as ordinary photographs because aspect ratio alone was not enough evidence of their projection type.
I found no exact duplicate groups with SHA-256. Filename association and a perceptual review revealed two likely edit/original groups, so I confirmed which versions I wanted to publish and excluded the duplicates by checksum. I left every source file untouched.
The Pipeline I Chose
- Read-only source audit22 images · SHA-256 · EXIF/XMP
- Duplicate + privacy review2 likely groups · 7 GPS-bearing sources
- Raster / DNG / panoramaseparate, explicit processing branches
- Orientation + sRGBno upscaling · metadata stripped
- Role-based derivativesthumb · grid · viewer · panorama
- Typed manifeststable IDs · safe EXIF · draft alt text
- Gallery + viewercontext-sized delivery with measured timing
I derive each stable public ID from a sanitized source stem and the first eight characters of its SHA-256 hash. For the cache key, I combine the full source checksum with the processing-profile, Sharp, and libvips versions. That lets me skip unchanged work on a warm rerun without trusting modification times.
The Standard Raster Path
For standard images, I use Sharp 0.34.5 and libvips 8.17.3 to apply EXIF orientation, flatten any alpha channel, convert to sRGB, and create four uncropped WebP roles. I set thumb to a 360 px long edge at quality 55, grid to 1400 px at 72, viewer to 2800 px at 84, and the 32 px placeholder to quality 30.
I rely on Sharp's default metadata stripping for public derivatives. I calculate dominant colours from the generated grid image so the contact-sheet wells stay stable, and I reuse the tiny WebP as the viewer blur data URL. I apply no creative sharpening or grading in the pipeline.
RAW Files Without Pretending
I did not have ExifTool, darktable, RawTherapee, or another full RAW developer available on the machine. Both DNGs did contain sufficiently large embedded 8-bit previews: one at 3456 × 4608 after orientation and one at 1928 × 2560. Rather than imply that I had developed the RAW files, I record both outcomes explicitly as extracted from embedded preview.
If either preview had only been a small thumbnail, I would have required a manual conversion. I do not treat every decodable DNG payload as automatically suitable for a full-screen result.
Keeping the Photospheres Spherical
I keep each confirmed panorama as an uncropped, 8192 px equirectangular JPEG at quality 88 and re-inject its GPano XMP after optimisation. I generate separate centre posters for the thumb and grid roles so browsing never downloads a compressed panoramic strip.
I wrote the validator to reopen both public panoramas, confirm ProjectionType=equirectangular and UsePanoramaViewer=True, and measure their normalized left/right seam scores. They scored 0.0237 and 0.0249 against my 0.35 review threshold.
I split panorama loading into deliberate stages. When someone opens one, Next.js dynamically loads the panorama component; only then does the component import Three.js and request the full equirectangular file. I keep the normal poster visible until the texture becomes interactive, and I set both photospheres to start at a 180-degree yaw from their previous default.
Download the Detail the Current Context Needs
Grid WebPActive viewer WebPPoster → panorama JPEGI wrote the grid sizes string around the actual two-, three-, and four-column layouts after page gutters. I made only the first grid frame eager and high-priority, left the other nineteen lazy, and gave the filmstrip 360 px thumbs instead of making it repeat the grid assets.
I mount only the active viewer asset in the lightbox. After it decodes, I use a generation token to prefetch the previous and next viewer files; rapid navigation invalidates stale work. I added performance marks for click-to-decode, adjacent readiness, and panorama open-to-interactive timing so I could measure the real sequence.
I give only the selected frame a temporary photo-active View Transition name. During open and close, I move that identity between the loaded grid image and the active viewer or panorama poster. Keeping the other nineteen frames in the root snapshot prevents them from floating above the lightbox.
I chose not to virtualize the gallery. It does not use JavaScript virtualization because Next/Image already defers off-screen network work, and I reserve every frame's aspect ratio; unmounting masonry items would have added observer state, blank back-scrolls, and repeat decodes for only twenty photographs. When the page still felt heavy, my scroll profile pointed to the route-wide Lenis 0.1 interpolation instead. I switched Photography to immediate scrolling while keeping the smooth-scroll treatment everywhere else.
Privacy and Accessibility Share the Manifest Boundary
I keep source paths, filenames, checksums, metadata findings, associations, warnings, and duplicate rationale inside a private audit. The generated client manifest exposes only stable IDs, role URLs, dimensions, safe camera fields, colours, blur placeholders, and alt-review state.
I visually reviewed factual alt drafts for all twenty published items, but I keep them marked as drafts until final editorial approval. I reserve each exact aspect ratio in the grid and expose the same descriptions through the image-opening controls. I also kept keyboard arrows, Escape, focus return, reduced motion, and the panorama poster fallback intact.
Measured Output, Not a Claimed Score
- 172.5 MB
- 28.9 MB
- 1.43 ms
I measured a 6.55 MB median source size. The generated medians came down to 8.8 KB for thumbs, 116 KB for grid images, and 464 KB for standard viewers. Even after accounting for duplicated viewing roles, I measured a 45.85% median per-photo reduction and a 57.92% average reduction.
The two full panoramas account for 16.08 MB of the 28.9 MB output, which justified keeping them behind interaction. My cold run took 28.53 seconds. On the final warm run, I skipped all twenty published sources in 1.43 ms while still rerunning the complete twenty-two-source audit and validating every published output.
In a cache-disabled local production run at 1440 × 1000, I measured 1.18 MB of initial transfer, including 201 KB of image transfer, with zero initial layout shift and no panorama request. The active viewer decoded 63.2 ms after click, adjacent prefetch completed 9.6 ms later, and the panorama became interactive in 541 ms. At 390 × 844 with a 3× device scale and emulated 4G, I measured 1.75 MB of initial transfer, a 1.31 s LCP, and again zero layout shift. A 180 px wheel step reached 157 px in 10 ms and settled in 60 ms on Photography; the unchanged smooth route reached 35 px at 10 ms and remained at 150 px after 250 ms. I treat these as local lab observations, not field performance claims.
Choices I Made—and What I Rejected
- Local assets over a CDN: I kept the twenty published photographs in a source-controlled workflow. A CDN would have added operational complexity before the collection's scale required it.
- WebP over AVIF: I chose WebP because it kept encoding fast and predictable in the existing Next.js path. I did not add AVIF without a representative benchmark showing that a second format would improve this collection.
- Role-specific files over one master: I generated separate files because the filmstrip, masonry grid, viewer, and panorama have materially different jobs. One master would have made the browser repeatedly decode or request detail it did not need.
- Adjacent prefetch over preload-all: I warm only the two likely next images after the active frame decodes. That keeps hidden viewer files from competing with the initial grid delivery.
- Interactive panoramas over flattened strips: I kept the photospheres navigable because the interaction is part of those photographs. Their larger files and rendering code are loaded only when a visitor chooses to open them.
The Cost of Doing It Deliberately
I accepted more machinery than copying one JPEG into public: a processing profile, cache, typed manifest, private review artifacts, validation reports, and four common roles per standard photograph. That extra metadata and storage is the cost of making the output reproducible and context-aware.
I also kept automatic RAW handling intentionally conservative. Embedded previews were adequate for this set, but I would manually convert future DNGs—or add a dedicated developer—when their previews are not sufficient. Panoramas remain my largest files, and the alt text still needs final human approval.
A Gallery With Context-Sized Detail
I now send only the detail needed for browsing, inspecting, or exploring a photosphere. The complete public derivative set is 83% smaller than my untouched selection in aggregate, while the role-aware comparison accounts for duplicated viewing roles at a 57.92% average per-photo reduction.
More importantly, I left the originals unchanged, kept GPS outside the public boundary, reserved layout space before download, and preserved the interaction that makes the two 360-degree photographs distinct.
What I Would Improve Next
- Editorial approval: I would finalize the factual alt-text drafts after one more editorial review.
- RAW consistency: I would add a colour-managed RAW developer when the selection includes files whose embedded previews are not sufficient.
- Curated viewing: I would store focal points, collections, and per-panorama starting views only after deliberately authoring them.
- Delivery scale: I would move derivatives to an image CDN only when collection size or deployment cost makes local assets the limiting factor.