TL;DR
- After a week at a backcountry cabin, I turned about 1,109 photos, videos and clips into a 94-page hardcover for my trip buddy, in about six days, as a build pipeline instead of a layout session.
- The stages: a media catalog, local speech-to-text, vision records for every item, sharp-frame harvesting from video, 1,229 fact claims run through verifier and skeptic agents, an equal-billing balance check, WeasyPrint to render, a custom
pikepdffinisher to hit PDF/X-3, and a 28-check preflight. - Final preflight: 0 failures, 13 warnings, each warning with a written reason.
- The interesting bugs were all data bugs: a camera clock 54 minutes fast, Whisper hallucinating text over wind, iCloud photos that were not actually on disk, and an AI that badged a fish off a receipt.
- If you can express a book as data plus a build, then review, regression tests and reproducibility come for free. That is the whole idea.
The brief
The trip is covered in another post, which was about the live page that ran during it. This one is about what came after. I wanted to give my buddy something physical, and I wanted it to feel like a bound volume of an old geographic magazine, not a photo-book template.
The book is called No Moose, No Rain, No Regrets. It is a 13 x 11 inch landscape hardcover, styled as an invented geographic society’s bound volume. It has long-form articles, field guide plates, maps, and a “notes on sources” section at the back. There is a preflight check that fails the build if the name of the real magazine it riffs on appears anywhere: text, metadata, outlines or file name. It is a parody of a style, not a counterfeit of a brand.
I started three days after we got home. I finished the print package about six days later. That only worked because I treated it like a release.
Stage 1: a catalog of everything
Source material came from three places: two phones, a compact zoom camera, and a shared album. I pulled it out of Apple Photos with osxphotos into a catalog: one record per item with its source, clock, GPS, grouping and a status per stage. The count was 1,109 trip items, all covered.
Then a vision pass produced 1,947 records. Agents worked in batches of 24 items, each batch with its own brief: the journal prose for that day, the wildlife sightings list, the GPS-derived place name, and cues about who was in frame. Each record assigned a role:
| Role | Count |
|---|---|
| hero | 152 |
| support | 423 |
| field guide | 72 |
| callout | 39 |
| reject | 251 |
Earlier, an older local model had captioned the photos for the trip page. I deliberately did not feed those captions into the book pass, to avoid anchoring, and used them only to flag disagreements for review.
Stage 2: video, locally
Video gave me two things: speech and stills.
Speech. mlx_whisper with the large-v3-turbo model ran locally on the Mac, producing transcripts for 109 files. A transcript records words, not speakers, so speaker attribution is not inferred. I confirm speakers myself at review.
Stills. A frame grab from a video is only useful if it is sharp and not a duplicate of the last one. The harvest script decodes at 4 frames per second into small grey frames, scores sharpness with Laplacian variance over tiles, dedupes with a perceptual hash (dHash), and keeps the sharpest frame per 3-second window. There is also a hard physical rule: a 1080p frame prints at most about 6.4 inches wide at 300 ppi. So video stills only go in at true size, never as full-page heroes.
Stage 3: the problems that were not code problems
The clock was 54 minutes and 20 seconds fast
Photos from two devices need to interleave correctly, because the book places an event across cameras. The zoom camera’s clock ran fast. I measured the offset by finding moments both cameras caught: a bear, some mergansers, otters. The offset (minus 3,260 seconds) went into a small YAML file the catalog applies to everything from that camera. Applying it once, in the catalog, meant no page had to know about it.
Whisper invents text over wind
On a windy lake, a speech model will happily transcribe the wind. It produced plausible-sounding sentences over wind and paddle noise. The fix is an unglamorous filter list of the stock phrases it produces over noise (“thank you”, “thanks for watching”), which get dropped before anything reaches a page.
iCloud “Optimize Storage”
With Optimize Storage turned on, a photo’s path in the Photos library is None until the original is downloaded. My first export silently skipped 187 originals. A “supplement” source that forced the downloads recovered them, and 185 of those made it into the catalog. The lesson is that “I have 1,109 items” has to be a number the catalog proves, not one it assumes.
Sideways photos and 10-bit video
Photos that looked rotated were EXIF orientation not applied. I baked orientation into thumbnails once and stopped thinking about it.
The video one was nastier. The phone shoots High 10 H.264. libx264 matches the source bit depth, and browsers silently refuse to play 10-bit H.264, so a derived clip could look fine on disk and still not play on the review page. The fix was to re-encode to 8-bit High 4.1, handle HLG and PQ HDR sources, and add a verification gate that proves each derived file actually plays before it counts.
The AI badged a fish off a receipt
Photos get small species badges: a fish icon, a bird icon. The vision model got over-eager. It badged fish from a store receipt (it said “Fish & Wildlife Sales”), from a tackle photo (“crankbaits resembling small fish”), from a mounted head, and from a moose crossing sign. The guard rails became rules:
- No name without evidence in the frame.
- Tracks and calls go to a checklist, not a plate.
- The umbrella word has to appear in the caption.
A related bug: the model’s reasoning text leaked into the flora labels, so a plant caption read like someone thinking out loud. That got a fix and a regression test.
Stage 4: 1,229 claims, checked by agents that disagree
The book has history and nature pages: fur trade, fire towers, logging, wolves, moose, fungi, cribbage, fishing regulations. That means a lot of factual statements, and an LLM writing that copy will cheerfully sound sure about things it half remembers.
So the rule is that every nature or history claim has to resolve to a record in facts.yaml: wording, source URL, quote, source tier and a verdict. Two kinds of agents produce those verdicts. A verifier drafts the support. A separate skeptic tries to refute it. It is the same adversarial idea I used for a rival-model merge gate, applied to prose instead of diffs.
The tally across 1,229 claims:
| Verdict | Count |
|---|---|
| Upheld (the source supports the exact wording) | 887 |
| Weakened (partial support, so reword to attribute) | 233 |
| Derived (computed from other facts) | 108 |
| Refuted (contradicted by an authoritative source) | 1 |
1,181 are printable and 48 are not. The 233 “weakened” claims are the useful ones: the process catches a statement that is about 80% true and forces a rewrite to match what the source actually says. The book prints numbered notes on sources at the back. Public-domain images for the history pages (31 of them) were each licence-checked.
Two editorial rules came out of this. Invented titles and committees get purged (“no invented titles or committees”). And one story about a keeper’s sign, which I could not confirm, got replaced by a sourced tradition about fire-tower workers, with the standing instruction: embellish the people, never the nature.
Stage 5: equal billing, enforced in code
My one editorial rule was that the book should not feel like my adventure or my friend’s. It had to feel like ours. A rule like that is easy to agree with and easy to violate in the third draft.
So there is a balance check. It counts, per person: name mentions, attributed quotes, and placed photos where that person covers at least 5% of the frame. Within 45 to 55 percent it passes cleanly, between 40 and 60 it warns, and outside that range the build fails. It is a crude instrument. It still caught drift that I would not have seen by flipping pages.
Stage 6: render, finish, preflight
The layout is Jinja2 templates plus CSS, rendered by WeasyPrint. Copy and photo picks live in about two dozen YAML fragments, one per run of pages, and a script assembles them into a single generated spec. You edit the fragments and never the generated file. Fonts are vendored open-licence families, all embedded.
The finisher
The print service wants PDF/X-3:2002 on PDF 1.3. WeasyPrint writes X-3:2003 on PDF 1.4. A custom finisher built on pikepdf bridges the gap:
- Strip transparency keys that are no-ops.
- Save as
%PDF-1.3with a classic xref table. - Add an sRGB output intent.
- Set TrimBox and BleedBox.
- Remove link annotations.
There is a Ghostscript fallback at 720 dpi if the primary path is rejected.
Geometry
Page and cover sizes were checked against the print service’s own calculator rather than trusted from memory. Pages are 12.625 x 10.875 inches (trim 12.5 x 10.625 plus 0.125 bleed). The cover is 26.681 x 11.625 inches, and the spine is 0.569 inches at 94 pages. The spine width changes with page count, and the count grew from 88 to 92 to 94 as material earned its place, so the geometry had to be rechecked each time.
Preflight
preflight.py runs 28 checks. The final result:
- 0 failures, 13 warnings, 15 passes
- 10 fonts embedded, no Type 3 fonts
- 284 image placements, all sRGB
- 20,473 text runs, none under 6 pt
- 41,591 words, all inside the safe area
- 66 bleed edges covered
The 13 warnings are images at 203 to 233 ppi instead of 250 or better. Almost all are scanned public-domain images and 1080p video stills, which cannot be better than they are. Every warning has a written reason, which is the point. A warning with a reason is an accepted risk; a warning without one is a bug waiting for the printer.
Final sizes: pages PDF 179.3 MB, cover PDF 7.0 MB, both far under the upload limits. The local build directory was 15 GB of derivatives, proxies and stills, which I would warn anyone about before they start.
Review gates
A pipeline does not remove the need for judgement. It tells you where to apply it. I used five gates:
- G1: catalog, species and quotes
- G2: the look, via a 32-page specimen
- G3: flatplan lock
- G4: soft-proof rounds, a series of review PDFs
- G5: final approval
The test suite grew alongside it: roughly 480 test functions across 26 files, covering page geometry, species rules, balance and fact resolution. When a bug was found in review, the fix came with a test. That is how a book earns the word “build”.
What I would take from this
- Put the book in version control. Page counts, captions and photo picks are data. Data can be diffed, and a reviewer can read a diff.
- Make checks fail the build. Equal billing, forbidden trade names, minimum type size and unsourced claims were all things I would have missed by eye.
- Pair every generator with a skeptic. The 233 weakened claims are the proof.
- Budget for source-data bugs. The clock offset, hallucinated transcripts, cloud-only originals and 10-bit video cost more time than the layout did.
- Give every warning a reason. If it cannot be fixed, write down why.
The book is also a repo. If I find a mislabeled bird next year, I fix one YAML line and rebuild, which is the part I like best.