· MIT · Node 18.17+ · one dependency
A self-hosted photo wall. Point it at a folder of photographs and it builds a single static page: every photo in its own frame on one big wall you pan and zoom, laid out so nothing overlaps and nothing is cropped.
Open the live demo — 105 photographs, every frame captioned with the camera data read out of the file.
Named after the Hungarian word for frame. It began as the engine of a personal photo wall and is now a standalone tool anyone can run.
- Static output. A folder of HTML, images and crawler files. No database, no server-side runtime, no client framework.
- Originals are never published. Every photo is re-encoded on the way out: EXIF, including GPS, is dropped and orientation is baked in.
- One photo, one frame. Frames keep the photo's real aspect ratio: nothing is cropped to fit a grid.
- Built to survive a phone. Pan, pinch, double-tap, fit - this engine was debugged on real iOS hardware after a few hundred photos started killing the tab.
- Your typeface. Geist Mono by default; Geist Sans, Geist Pixel, Crimson Text (serif)
or Crafty Girls (handwritten) with one key (
fontinwall.config.json); both are copied into the build and served from your domain. - Yours, credited. A "Powered by Keret" line you can rewrite or remove, and a photographer card in the corner for your own contact details.
- No JavaScript required to read it. Without JS you get a plain list of the photos; the wall is an enhancement.
# 1. get the tool
git clone https://github.com/nomorenamesstudio/keret.git
cd keret
npm install # sharp, the only dependency
# 2. build the bundled demo wall and look at it
npm run demo
npm run demo:serve # http://127.0.0.1:3100/Building your own wall:
mkdir -p ~/pictures/wall/photos
cp ~/photos/*.jpg ~/pictures/wall/photos/
cd ~/pictures/wall
node ~/Code/keret/gen.js # writes ./dist
npx --yes serve dist # or any static file serverNo config file is needed to start: on a terminal the first run asks a few questions
(title, description, site URL, photographer) and writes wall.config.json; every
later run offers to keep it. --yes turns the questions off, --setup brings them
back, and without a terminal the defaults build ./photos into ./dist. Copy
wall.config.example.json when you would rather edit
it by hand.
dist/ is a folder of static files, so it goes anywhere: GitHub Pages, Cloudflare
Pages, Netlify, or your own server behind nginx or Caddy. All four, with the
custom domain steps, are in docs/HOSTING.md. Setting siteUrl
before the final build is the one step that matters everywhere, because the
canonical link, the social tags and the sitemap are built from it.
node gen.js is the tool this project started as, and the one for scripts and
build servers. web/ is for everyone else, live at
https://keret.nomorenames.studio: a static page that runs the same build in
the tab - drop a folder of photographs, watch it lay the wall out, preview it,
download the finished site as a zip. No install, no server, nothing uploaded. It
is not a fork: web/vendor/keret-core.js is generated from src/.
The zip carries wall.config.json and author.json back with it - keret's own
settings files, the ones the command line reads - so unzip it, drop in more
photographs and build again to get the same wall back. The one trade-off is the
encoder: the browser's own, so its output is not byte-identical to node gen.js.
npm run web:serve serves the app locally on http://127.0.0.1:3287/.
- Node 18.17 or newer (Node 20+ recommended).
sharp, installed bynpm install. It is not optional: it is what strips metadata and bakes in EXIF orientation. The build refuses to run without it rather than publishing originals.
| Command | What it does |
|---|---|
node gen.js |
builds ./photos into ./dist |
node gen.js --check |
lays the wall out, writes nothing |
node gen.js --help |
every option |
npm run demo |
rebuilds the bundled demo wall (demo/dist) |
npm run demo:serve |
serves the demo on port 3100 |
npm run demo:check |
structural check of a built wall |
npm run seed |
writes sample images into ./photos |
npm test |
the suite (node's own runner) |
Sizes, EXIF orientation, capture date, camera and exposure are read straight from the file headers with zero dependencies, so the layout can be computed before anything is written and each frame matches exactly how a browser renders the photo.
Captions are the exposure line and the camera. Both can be switched off, and a
sidecar (photos/captions.json) supplies your own words per photo, taking over a
line when a photo has no EXIF:
{
"harbour-morning.jpg": {
"title": "Harbour, first light",
"credit": "Photo: Julia Caesar"
}
}Everything lives in wall.config.json in the directory you run the generator
from: title, description, site URL, caption rules, the credit line, the geometry
of the wall (column width, gutters, mats, margins), how large the served copies
are, and an optional analytics snippet. Every key is documented in
docs/API.md; the defaults live in
src/config.js.
Unknown keys are a hard error: a typo fails the build instead of being ignored.
demo/ is a complete wall: 105 photographs, their captions and the built site,
committed so the demo builds offline and can be served by GitHub Pages. It is
live at https://nomorenamesstudio.github.io/keret/.
Every photograph is a Wikimedia Commons original that still carries its camera EXIF, so every frame shows the date, exposure and camera the engine read straight out of the file. They are committed at demo size (1000px, about 12 MB in total), and no frame was chosen to show a person. What the build publishes never carries that metadata: the served copies are re-encoded without it.
Every photograph is CC0 1.0 or public domain. Photographs by Jebulon (8), GlacierNPS (7), Wilfredor (6), DimiTalen (5), Alex Liivet from Chester, United Kingdom (4), blmcalifornia (4), Mertbiol (4), Roc0ast3r (4), Perituss (2), Peter Cooper Jr. (2), Unknown (not credited) (2), plus 57 photographers with one frame each.
The full list, one row per file with its license and a link to the source page, is in demo/CREDITS.md.
sharpis mandatory, so published copies never carry EXIF, GPS or embedded thumbnails.- The GPS IFD in the source photos is never even parsed.
- The build wipes its output directory. Before it does, it refuses to run when
the output would be the photos folder, the project root, or a non-empty
directory that does not look like a previous build (unless
--force). - A photo that cannot be published fails the build; the page is never written with a frame pointing at a missing file.
Details in SECURITY.md.
Start at docs/INDEX.md. The short version: SETUP.md to run it, API.md for the CLI and every config key, HOSTING.md to put it online, ARCHITECTURE.md for how the build and the engine fit together.
Keret is hand-written and AI-enhanced. Krisztian owns the specification, the design decisions and the review; the code was written with DeepSeek V4.1 Flash in DeepSeek Harness, under a documented method: every claim in these docs is backed by a command in the repository, the tests are the contract, and the review that drove the extraction from the original site is preserved in the git history and docs/CHANGELOG.md - so a reader can see which parts came from where.
Issues and pull requests are welcome. CONTRIBUTING.md has the ground rules and the checks to run before opening one; the project follows the code of conduct.
MIT. See LICENSE.


