VLUU68 frames

Colophon

VLUU was a Next.js and Sanity site. This is the second one — same photographs, nothing else carried over. No CMS, no runtime image service: the pictures are baked into the repository by a build script, and the site is static all the way down.

Frames
68 of 90 shot
Live Photos
50
Source pixels
1113 megapixels
Shipped media
69.1 MB

The pipeline

Originals live outside git. pnpm ingest reads them, bakes a responsive set into public/media, and writes a manifest that the app imports like any other module. Run it twice and the second run changes nothing — the script hashes each source and skips what it has already seen.

Three things went wrong on the way, and all three were quiet rather than loud. That is the interesting part: none of them threw.

sharp cannot open an iPhone HEIC

libvips reports HEIF input support, so the format table says yes. Then it refuses the actual file: a Live Photo HEIC carries 45 item references and libheif caps them at 16 for safety. The fix is to decode through macOS sips to lossless PNG first, which turns the fallback into the primary path.

Portrait photographs are stored sideways

An iPhone writes the sensor’s landscape pixels and a rotation tag, not a rotated image. Anything that ignores the tag renders all 68 frames lying on their side — which is exactly what the first contact sheet looked like. sharp.rotate() with no argument reads the tag and actually turns the pixels.

The clock was nine hours off

exifr applies OffsetTimeOriginal and hands back true UTC. Read those fields as wall clock and a nine-in-the-morning photograph in Asakusa becomes 00:26 — and every frame shot before 09:00 local falls into the previous day’s leg. Since the routes on this site are grouped by date, that silently reshuffled the whole sequence. Nothing errored; the dates were simply wrong.

What gets shipped

AVIF is the delivery path. WebP exists only for browsers that cannot read it, so it is baked at two widths instead of four — at equal quality it runs close to twice the size, and spending a third of the repository on a fallback for a sliver of traffic is a bad trade. Widths stop at 2048: a 3:4 portrait filling a 16-inch display needs 1675 pixels, and going to 2560 costs half again as many bytes for detail the viewer never shows.

FormatFilesSizeShare
avif27033.2 MB48%
webp13519.1 MB28%
mp45016.8 MB24%

The repository carries 4.6 MB more that this table deliberately leaves out: one JPEG per frame that no browser ever requests. It exists because the renderer behind the link preview cards reads only PNG and JPEG, and everything above is AVIF and WebP. That makes it a build ingredient rather than a delivery format, and counting it as shipped would turn the rows above into a lie.

Measured

Lighthouse, desktop preset, production build, WebGL layer active, 7 August 2026. Cumulative layout shift is 0 everywhere. These are a snapshot rather than a promise — the useful part is the shape of them: nothing blocks, nothing shifts, and the largest image arrives in well under a second.

RoutePerfA11yBestSEOLCPTBT
/991001001000.9s0ms
/c/*1001001001000.8s0ms
/p/*1001001001000.6s0ms
/colophon1001001001000.6s0ms

The accessibility score was not 100 to begin with. The muted grey used for labels sat at 2.5:1 against white — quiet to look at, and below the 4.5:1 that makes text readable. The palette now has three steps and all of them clear it.

One caveat worth stating, since measurement is easy to fake by choosing the right machine: run the same build under a software rasteriser and performance falls to the seventies. Script evaluation nearly triples, because the CPU is doing the GPU’s job and everything else queues behind it. That number says something about the harness, not about the page — but a colophon that only prints the flattering run is not measurement, it is marketing.

DOM first, canvas second

Every photograph here is a real <img> in a box with a fixed aspect ratio. Layout, reading order, alt text and layout shift are settled before any motion work begins. A single WebGL canvas then reads those elements’ positions each frame and draws planes in exactly the same place.

In exactly the same place was a claim, not a fact. The canvas built its world from window.innerWidth, but a position: fixed element sized at 100% covers the layout viewport, which excludes the scrollbar. World 1280, box 1265 — a scale of 0.988 that nothing corrected. The right-hand column was drawn 11.7 pixels left of its own frame, and slid there the instant the canvas took over. It now measures the canvas rather than the window, so the two coordinate systems are the same one.

That was worse on a phone than the number suggests. Mobile Safari collapses its address bar as you scroll, so window.innerHeight changes continuously while the fixed canvas does not. The vertical scale was drifting for the whole length of every scroll — which is what “the photographs will not sit still” actually was.

It also takes its pixels from the DOM. The <img> has already chosen a width from its srcset and downloaded it, so that element becomes the texture — no second request, and no guessing at a size the browser had already worked out. The upshot is that the canvas ships zero photo data to the client.

The handover happens one photograph at a time. A single global switch would hide every<img> the moment the canvas woke up, including the ones it could not draw yet — holes in the page for as long as the textures took. Instead each frame is handed over only once its texture is ready, and handed straight back if the context is lost.

The fallback is not a degraded version. It is the site, with a layer removed. No WebGL, reduced motion, data saver, two cores, a small maximum texture size, a thrown exception, a lost context — each of those is a normal outcome, and each one lands on a page that was already finished.

One number, wrong three times

The canvas skews and smears photographs in proportion to how fast they are moving. That one number was wrong in three different ways, and each was invisible in a screenshot.

Wrong source. It came from the window’s scroll position, which works on the index and does nothing at all on a route page — those scroll a container sideways while the window sits perfectly still. Measured, the shader velocity there was exactly zero; every effect built for that screen had never once run. It now measures what actually moved: each photograph’s own position, frame to frame. Window scroll, sideways container scroll and the cursor all arrive through the same path, because movement on screen was always the thing the shader cared about.

Wrong unit. It counted pixels per frame and never divided by time, so the same scroll produced a larger distortion whenever a frame ran long. Driving the page at a fixed 900 px/s and throttling only the CPU:

dt  8.6ms → 0.01240
dt 41.6ms → 0.01222   one frame runs long
dt  8.3ms → 0.01508   the next spikes 22%

steady 60fps  0.0081 – 0.0104   (1.3×)
under load    0.0078 – 0.0208   (2.7×)

The physical speed never changed. Everything in that range is the frame rate leaking into the picture, and on a phone — where frame times vary far more — it read as a photograph that would not settle. Velocity is now normalised to a 60 fps frame before anything sees it, so a dropped frame changes when the value arrives and never how large it is.

Wrong place. The archive index is for reading, and something that ripples the entire time you scroll it is at war with that. Scroll now drives distortion only inside the route strip, where travelling sideways is the subject. On the index the motion belongs to your hand: the photograph under the cursor takes its velocity from yours, settles three pixels into its own frame, and leans up to three pixels after you — bounded by construction, so it can never cross the hairline the DOM drew around it. It stops at the edge of the viewer. Open a photograph full size and it holds perfectly still; three pixels of lean is a suggestion in a thumbnail and an unsteady picture once it fills the screen. Measured, moving the cursor across an opened frame was driving it at half the velocity a real scroll produces. Browsing and looking are different jobs.

The blur that rides on that velocity was wrong twice before it was right. The first pass smeared across nine percent of the frame using five samples, which is not motion blur but a row of ghosts. Real movement produces a velocity around 0.04; a drag of roughly five pixels there is where it stops looking like an effect and starts looking like a shutter held open a moment too long. Anything faster is clamped, because a flick across the screen is half a viewport in one frame and would tear the picture in half.

A plain mouse has no horizontal wheel, so the route pages were, until now, only passable with a trackpad. Vertical wheel is translated to sideways travel — and released again at either end, because eating events you cannot act on makes a page feel stuck.

The motion is the subject

Fifty of the sixty-eight frames are Live Photos — each carries about three seconds of video shot around the shutter. For most of this site’s life that sat behind a play button on the detail page, which is a strange place to keep the only moving thing you own.

It is now what the gallery moves with. Rest a cursor on a frame and that photograph comes alive for three seconds and settles back; on a phone the frame that lands in the middle of the screen plays, after scrolling stops. One at a time, never looping. A grid where everything moves is not motion, it is noise — and motion that arrives while you are still scrolling is the exact thing this site spent a week removing.

The restraint is mechanical, not stylistic. A single <video> element moves between frames rather than fifty sitting in the document; nothing loads until the cursor has stayed for 120 milliseconds, so sweeping across the grid does not pull eighteen megabytes; reduced motion and data saver skip the layer entirely. While a frame is breathing the WebGL layer stops distorting it — that photograph already has its motion and does not need a second one.

The opening moves the chrome, not the photographs. A hairline draws across the header, the wordmark and navigation settle, the title follows — around 700 ms, all of it transform and opacity. Animating sixty-eight photographs into place would have been the obvious version and it would have cost the thing this site is actually good at: largest contentful paint lands because the first screen of pictures is painted immediately, and a fade would spend that. Measured before and after, layout shift stayed at zero and long tasks did not grow.

Drive it yourself

The same shader, compiled from the same source the site uses. Push the velocity and watch the frame shear, bend at the edges, split its channels by about a pixel, and drag along its own direction of travel.

A red handrail throws a ladder of shadows across a paved slope below a concrete retaining wall.

What one photograph weighs

Every frame is baked into a ladder of widths at build time. Move the slider and the browser fetches that exact file — no srcset, no guessing, just the bytes that would go over the wire.

A red handrail throws a ladder of shadows across a paved slope below a concrete retaining wall.
WidthAVIFWebPRatio
32013 KB
64043 KB67 KB1.54×
1280162 KB248 KB1.53×
2048384 KB

WebP runs close to twice the size at matching quality, which is why it is only baked at the two widths a browser without AVIF would actually use. The ladder stops at 2048px: a 3:4 portrait filling a 16-inch display needs 1675 pixels, and the step above costs half again as many bytes for detail the viewer never shows.

Opening a photograph without leaving the page

Clicking a frame opens it over the index rather than replacing it. The URL still changes to /p/…, so the address remains shareable; refresh it and the full page renders instead. That split comes from intercepting and parallel routes — the modal is a route, not a piece of component state, so the back button closes it and a pasted link never opens a dialog with nothing behind it.

Two things resisted. The canvas is a single fixed element covering the viewport, so it cannot live inside the modal — it has to sit between the veil and the modal’s controls, and while a photograph is open the layer draws that one frame and hands the rest of the grid back to the DOM. And interception keys off the destination, not the origin: paging between two full photo pages was still being intercepted, stacking a modal on top of a viewer. Moving the route into its own group did not help, because route groups are not segments. The fix was to stop pretending — on the shareable page the neighbour links are plain anchors that load a document.

<dialog> would have supplied the focus trap, the Escape key and the backdrop for free, but it renders in the top layer, above every z-index, which would bury the canvas drawing the photograph. So the shell is hand-built and the platform still does the hard part: inert on the background means focus cannot leave, without a single line of trap logic.

On a phone the way to move between photographs was a 32-pixel arrow you had to hit exactly. That is not how anyone turns a page. The photograph now takes the gesture: push sideways to move through the sequence, pull down to dismiss. Thresholds are a fraction of the viewport rather than a pixel count, so the same flick means the same thing on a phone and a tablet, and a short fast one passes on speed where a long slow one passes on distance. Upward does nothing on purpose — bind dismissal to both directions and lifting the picture to look closer throws it away. The decision and the follow-along are pure functions with unit tests, because a threshold you tune by feel is a threshold you should be able to shake without a phone in your hand. Mouse pointers are ignored: dragging with a mouse fights clicking and selecting, and the desktop already has arrow keys.

Decisions worth arguing with

There is no dark theme. A white cube is the concept, not a default — the photographs are the only saturated thing on screen and a dark version cancels half of that. Committing to one world beats supporting two badly.

Sequence comes from the camera, not from taste. Routes are trips, legs are days, and a frame’s place is decided by when it was taken. No hand-kept ordering to drift out of date when new photographs arrive.

The index was scattered before it was tidy, and scattered was wrong. Every frame here is exactly 3:4, so the first version varied the widths and nudged each picture down the page — on the theory that sixty-eight identical rectangles would be dull. It bought variety by spending every alignment line on the page, and what was left read as spillage rather than a collection. Uniform tiles are not a compromise for material like this; they are what it wants. The variety now comes from section structure and type, and the distortion under your cursor is easier to see, not harder, because there is finally a straight edge to distort.

The route plot is a real map, baked at build time. Sixty-seven of sixty-eight frames carry coordinates, so the routes can be drawn from the photographs themselves. The first version drew only that — points, lines, a scale bar, no basemap — on the argument that map tiles drag a whole visual world in with them and an external service besides. It was consistent and it was unreadable: without a coastline nobody could tell Tokyo from anywhere, which is a strange result for a drawing whose entire job is to say where. Consistency lost that argument.

The map is still not a dependency. A script fetches the tiles once, stitches them, crops them to exactly the box each sheet covers and commits eleven images totalling half a megabyte; a visitor gets one <img> and no map library, no tile requests, nothing to be down. Switching to Web Mercator was not optional after that decision — the marks have to land where the tile server thinks they land, so the drawing and the picture beneath it now come out of the same projection and the same call.

Each day gets its own sheet at its own scale. Putting a route on one sheet failed on its own honesty: the day trip to Lake Ashi is seventy kilometres out, so it set the scale and crushed the thirty-eight Tokyo frames into a corner — 200 m for an afternoon in Yokohama and 10 km for the Romancecar cannot share a ruler. Ganghwa is a single coordinate twice over, so it gets a mark and no line; drawing one would be inventing a journey.

22 frames were cut and the reasons kept. Duplicates, a museum wall label that was somebody else’s work, a picture at one twelfth the resolution of the rest. The list lives in the repository so the question does not get relitigated.

Made with

Next.js and React, TypeScript, CSS Modules with hand-written tokens. Type is Archivo, carrying a variable width axis, set against DM Mono for anything the camera recorded. Images through sharp, metadata through exifr, Live Photos through ffmpeg. Vitest for the pure logic, Playwright for the browser. 3 routes, 9 legs, 14mm ×1, 24mm ×24, 26mm ×2, 48mm ×15, 77mm ×25.

jinhyuk9714@gmail.comGitHubInstagramShot on iPhone 15 Pro