Local-first dashboard to manage photo galleries and batch‑edit or generate images with AI — for free through ChatGPT's web app (no API costs), or through Higgsfield. It indexes a folder of originals, keeps multiple versions per photo, lets you craft prompts visually, mark favorites, and export the final set. A built‑in MCP server lets you drive the whole thing from Claude.
Darkroom started as a tool to finish post‑production on a few hundred travel photos without paying per‑image API fees. It edits images by automating the same ChatGPT web app you already pay for as a human.
- Culling (RAW) — the step before editing: from a few thousand frames pick the few hundred worth working on. Keyboard-first (1-5 stars, 6-0 colour labels, X rejects,
\puts it back in the queue), burst grouping, and XMP sidecars written next to the RAWs so Lightroom sees your choices. A native Rust engine reads the JPEG the camera already wrote inside the RAW instead of re-demosaicing it: 2.1 ms per frame against the 1859 ms ofsips— 16 seconds instead of an hour on two thousand frames. Build it once withbun run core:build. - Gallery management — index a folder of originals, browse a grid, filter by state (no versions / no favorite / with favorite).
- Batch editing — queue every photo with one click; the worker runs them one at a time in the background.
- Generate from scratch — text‑to‑image with no source photo; results land in the gallery like any other item.
- Versioning — every render is a numbered version with the exact prompt + settings used; pick a favorite per photo.
- Visual prompt builder — compose looks from labeled controls (film stock, time of day, bloom, skin tones…) with on‑disk previews. Options name the failure they prevent rather than an adjective: neutral-strict white balance says wood and whites must not stay amber under tungsten, strict food says raw fish must not go grey and yolks must not look rotten.
- Pluggable backends — ChatGPT web (default, free), Codex CLI, or Higgsfield (optional, paid).
- MCP server — list galleries, enqueue edits/generations, check jobs, set favorites, export — all from Claude.
- Local color grade — one uniform look for the whole set (3D
.cubeLUT + robust AWB + pink pop + a sky step that can drain postcard-cyan skies without touching greens or blue signage), previewed live in the grid and baked into the full‑res export. Drop your LUTs indata/luts/. - Pipeline & run browser — run the whole chain (regenerate favorites → color grade → promote → export) as one action, and filter the grid by generation batch ("run") from a dropdown.
- Pick (♥) — one click on a grid tile marks a photo as a keeper. Deliberately separate from the favorite star (which picks which render of a photo is the good one) and from collections: first you say what is worth keeping, then you group. Filter the grid by "Mi piace" / "Da guardare" to work through the set.
- Collections (posts) — group the gallery into the units you actually publish: each collection is an ordered subset (a post or carousel) with a title and a caption. Group the grid by "Post", assign a selection in bulk, rename or dissolve a post from its header, drag tiles to set the running order (slide 1 is the cover, numbered in place), and filter by "Non assegnate" to work through what is not published yet. Membership is exclusive, so a photo can't silently go out twice.
- Collage slides (off by default) — merge photos of a post into one carousel slide: hero, mosaic, grid, stacked or split, all full-bleed. Only earns its keep when the photos are the same subject repeated; enable with
localStorage.darkroom.collage = "1". - Post colour reference — every AI render picks its own colour in isolation, so a carousel shot in one afternoon drifts frame to frame. Rather than filtering it back into line afterwards, each post nominates a reference photo: it is attached to every generation in that group as a second image, with an explicit "match its colour treatment, never its subject" clause. The coherent colour is rendered, not corrected. A deterministic
matchgrade step (Lab chroma transfer, luminance untouched) remains available for sets that cannot be regenerated. - Export — copy all favorites into a clean
final/folder (graded full‑res JPGs when the grade is on). - Quality checks — measure every render against a catalogue of known failure modes: free pixel checks (blown highlights, crushed blacks, near-duplicate re-renders) plus yes/no questions for a local vision model (garbled signage, deformed anatomy, watermarks). The gate flags and suggests a favourite; it never deletes or promotes on its own. What it keeps catching can be turned into a clause in the prompt's "Do not" block.
- Storyboard — turn a beat sheet into ordered panels (one generation each), give them durations, scene labels and a pinned cast whose reference images ride along with every generation, then export to Storyboarder's native format for 3D blocking and print.
- Engine — a Rust crate (
core/) with no OS-specific API in it: RAW previews, the four-level pyramid, XMP sidecars, perceptual signatures. Full RAW decoding runs in a separate process, because the system decoder kills the process on a corrupt file instead of returning an error — and, at a warm pipeline, a reused decoder hands back the previous photo on an unreadable one, which does not look like a fault at all. - Backend — Bun + Hono +
bun:sqlite(port 3535) - Frontend — Vite + React 19 + Tailwind v4 (port 5173, proxied to the backend)
- Worker — a Python subprocess (
scripts/edit_batch.py) that talks to a dedicated Chrome over CDP, or the Codex CLI, or the Higgsfield HTTP API
- Bun ≥ 1.3 —
brew install oven-sh/bun/bun(or see bun.sh) - Python 3 with
websockets— for the ChatGPT‑web backend:pip install websockets - Google Chrome / Chromium — only for the ChatGPT‑web backend. Darkroom launches a dedicated instance with its own profile; you log in to chatgpt.com once and it persists.
- ffmpeg + Python
numpy/Pillow— only for the optional local color grade (scripts/color_grade.py):brew install ffmpeg && pip install numpy pillow. - Rust — for the native engine:
bun run core:build. Without it RAW files are not indexed and thumbnails fall back tosips, which is 800× slower. - libraw (
brew install libraw) — only to decode a RAW past the size of its embedded preview. On a Sony ARW that preview stops at 1616 px, so the viewer needs it; on a Nikon NEF it is as large as the frame and you never pay for it.
git clone https://github.com/zorahrel/darkroom
cd darkroom
bun install
cp .env.example .env # optional — defaults store everything under ~/Darkroom
bun run db:init # create schema + seed the global prompt
# put your originals in ~/Darkroom/data/RAW/ (or set GALLERY_RAW_DIR)
bun run import # index originals
bun run server/cli.ts stats # see what's in the DBEverything Darkroom stores lives under GALLERY_ROOT (default ~/Darkroom). Nothing is hardcoded — see server/config.ts and .env.example.
bun run dev # backend + frontend, auto-picks free ports
# or separately:
PORT=3535 bun run server
bun run clientOpen the URL printed by bun run dev (default http://localhost:5173).
bun run jobs:watch # follow the queue until it drains, one line per job
bun run jobs:watch --once # just print the current state and exitjobs:watch exits non-zero if any job failed, so it chains: queue a batch, wait
for it, and only run the next step if everything landed.
- Pick a backend. Default is ChatGPT web. On first use, click the “Browser offline” banner (or
POST /api/browser/launch) — a dedicated Chrome opens; log in to chatgpt.com once. - Grid — filter “No versions” and click Generate missing to queue every unedited photo with the current prompt.
- Generate from scratch — open the Generate panel, type a prompt and a count; new items appear in the gallery.
- Photo detail (
/photo/:id) — original on the left, render carousel on the right.←/→switch version ·Ffavorite ·Gnew render ·[/]prev/next photo- Tune the per‑photo prompt and Save override when the global look isn't enough. Each version records the exact prompt used.
- Export favorites — copies every favorite into
<root>/data/final/<photo_id>.png.
Two floors, and the header shows both. Strumenti (the home) is what Darkroom can do; Progetti is what you are doing it to.
The home lists every capability with the one line that says whether it is what you need, whether it works right now on this machine — and if not, the thing that is missing, phrased as the gesture that fixes it — and how to begin: open it inside a project you already have, make a new project already switched to the right view, or just use it (type a prompt and generate, point a folder at a new gallery, turn a beat sheet into a storyboard, run the quality pass, export).
That list is not written in the UI. It comes from a catalogue in
server/tools.ts that the MCP server reads too (list_tools), so a
new capability shows up in both the day it exists — and a test ties every entry to routes that
are actually mounted and MCP tools that actually exist, in both directions, so the catalogue
can't drift into a brochure.
Darkroom runs several local projects side by side. A project is a folder with its own SQLite DB and data dirs; the Studio page is the list of them, and the header tells you which one you are in.
- New project — a name is all you type. The id is derived from it and the
folder is created under
~/Darkroom/projects/<name>(override withDARKROOM_PROJECTS_DIR). "Bring your own folder" is one click away for an existing directory. - What it's for — photo (a gallery to finish) or storyboard (panels in sequence). It only decides which views the UI offers: generation, colour grade and quality checks are the same pipeline for both, which is the point — a storyboard is drawn by the same engine that edits photos.
- Photos — point a project at a folder and choose per folder: link (index the files where they are, nothing copied — the default) or copy (bring them into the project, for a card or a downloads folder you'll empty). Add more folders any time from Foto del progetto; "Rileggi" picks up files added since.
- Removing — a project can be taken out of the list; its folder, database and renders stay on disk. Darkroom never deletes your work.
Open a photo, then the Qualità panel in the pipeline dock (or drive it from Claude). Two kinds of check:
- pixel — histogram and perceptual-hash measurements. Free, instant, need only ffmpeg: blown highlights, crushed blacks, and re-renders that came out nearly identical to a sibling.
- vision — a yes/no question put to a local vision model (the
Moondream CLI; set
MOONDREAM_BINif it is not on PATH) for what needs semantics: garbled signage, deformed anatomy, watermarks.
Two rules the gate sticks to: it reports, it never deletes — the favourite stays a human choice, suggested at most — and an unsure answer counts as a pass, because a model that hedges is not evidence of a defect.
The catalogue also feeds generation: every failure mode can carry a negative
clause that joins the prompt's "Do not" block, so what the checks keep catching
becomes what the next render is told to avoid. Add your own with
POST /api/verify/modes (or the add_failure_mode MCP tool) — a recurring
complaint becomes a check and a prompt clause instead of a note nobody reads.
A storyboard is an ordinary project whose photos are panels: same versioning, same queue,
plus an order, a duration and an optional cast. Open /p/:pid/storyboard.
- Write the beats — one shot per line. Each becomes a panel, appended in order, with its generation already queued. Thumbnails fill in as the worker gets to them.
- Cast — create a character with a reference photo. That image is attached to every generation the character appears in, which is what keeps the same face across panels.
- Board — drag to re‑order, set per‑panel duration and scene label; the running time is shown on each panel. Existing gallery photos can be pulled in too.
- Export — writes
<root>/data/final/storyboard/<project>/<project>.storyboarderplus animages/folder, in Storyboarder's native format. Open it there for 3D shot blocking, hand‑drawn fixes and print/PDF — the half Darkroom deliberately does not reimplement.
Everything above is also drivable from Claude over MCP (create_panels, set_sequence,
set_character, export_storyboard), which is the point: describe the story in chat, let the
queue draw it.
| Backend | Cost | Setup |
|---|---|---|
| ChatGPT web (CDP) — default | Free (uses your ChatGPT plan) | Chrome + one‑time login |
| Codex CLI | Per your Codex plan | WORKER_BACKEND=codex, CODEX_BIN=… |
| Higgsfield | Paid (credits) | Complete the OAuth flow, or HIGGSFIELD_ENABLED=1 |
Select with WORKER_BACKEND=cdp|codex. Higgsfield runs alongside as a per‑job choice and is off unless a token file exists or HIGGSFIELD_ENABLED=1. None of these are affiliated with Darkroom — you bring your own account.
Darkroom ships an MCP server that wraps the local API. Start the backend, then register the server:
Start with list_tools: it returns the capability catalogue — what each tool is for,
which project views it lives in, whether it is usable right now on this machine (and if not,
what is missing), and how to start it. start_tool then does the thing in one call, creating
the project it needs if it needs one. That catalogue is the same one the home page renders, so
Claude and the UI can never disagree about what Darkroom can do.
Under it, one tool per operation: list_photos, get_photo, edit_photo, generate_image,
generate_missing, list_jobs, set_favorite, set_global_prompt, color_grade,
add_photos, rescan_photos, list_collections, assign_photos, export_favorites, the
storyboard set (list_storyboard, create_panels, set_sequence, update_panel,
list_characters, set_character, export_storyboard), the quality set (check_photo,
verification_summary, list_failure_modes, add_failure_mode), the video set (video_shots,
video_cuts, video_judge, video_pin, video_duration, video_forcings, video_rebuild,
video_rebuild_status, video_check, video_generate, video_generations) and the project set
(list_projects, add_project, update_project, status). See mcp/README.md.
| Verb | Path | Description |
|---|---|---|
| GET | /api/health |
Backend + ChatGPT browser status |
| POST | /api/browser/launch |
Launch the dedicated Chrome (CDP) |
| GET | /api/photos?filter=all|no_versions|no_favorite|with_favorite |
Gallery grid |
| GET | /api/photos/:id |
Photo + versions + effective prompt |
| PUT | /api/photos/:id/favorite |
{version_id|null} |
| PUT | /api/photos/:id/prompt |
{prompt: string|null} (null reverts to global) |
| DELETE | /api/photos/:id/versions/:vid |
Remove a version (never the original) |
| POST | /api/photos/:id/generate |
Enqueue one edit job |
| POST | /api/generate-missing |
Enqueue every photo with 0 versions |
| POST | /api/generate-new |
Generate a brand‑new image from a prompt |
| GET/PUT | /api/settings/global-prompt |
Global prompt |
| GET | /api/jobs |
Queue snapshot |
| POST | /api/export-favorites |
Copy favorites into final/ |
| GET | /api/storyboard |
Panels (in order) + cast + board settings |
| POST | /api/storyboard/panels |
{beats: [{description, duration_ms?, scene_label?, character_ids?}]} |
| PUT | /api/storyboard/sequence |
{ids} → panels 0..N‑1, in that order |
| PATCH | /api/storyboard/panels/:id |
Duration, scene label, pinned cast |
| POST | /api/storyboard/characters |
Create/update a character |
| POST | /api/storyboard/export |
Write the .storyboarder file + images/ |
| GET/POST | /api/verify/modes |
The failure-mode catalogue |
| POST | /api/verify/photos/:id |
Check every render of a photo + favourite suggestion |
| POST | /api/verify/batch |
Background pass over unchecked renders |
| GET | /api/verify/summary |
What gets flagged, how often, and the trend |
| GET | /api/tools |
The tool catalogue + what is ready on this machine |
| POST | /api/tools/:id/start |
Start a tool (creating its project if needed) |
| GET/POST | /api/studio/projects |
List projects · create one from a name |
| DELETE | /api/studio/projects/:pid |
Forget a project (files stay) |
| GET/POST | /api/sources |
Photo folders of the active project (link/copy) |
| POST | /api/sources/rescan |
Re-read them for new files |
All settings are environment variables with sane defaults — see .env.example. Highlights:
GALLERY_ROOT— where data + DB live (default~/Darkroom)PORT— backend port (default 3535)WORKER_BACKEND—cdp(default) orcodexCHROME_BIN— override Chrome auto‑detectionCHATGPT_CDP_PORT— debugging port for the dedicated Chrome (default 19223)
- “Browser offline” banner — click it, or
curl -X POST http://localhost:3535/api/browser/launch. Log in to chatgpt.com in the window that opens. - Jobs stuck
failed— click the photo id in the Jobs panel for the error. The Python worker saves a screenshot tologs/fail_*.pngwhen the page breaks. - Session expired (~7 days) — if several jobs time out in a row, log in again in the dedicated Chrome window.
- Throughput — the queue runs one job at a time. A few hundred photos at ~60s each is a multi‑hour batch; leave it running.
Darkroom never reverse‑engineers any private API. The ChatGPT‑web backend automates the public web app over the Chrome DevTools Protocol: it opens a new chat, uploads (or skips, for generation), sends your prompt, waits for the rendered image, and downloads it — exactly the steps a human would take. You are responsible for using it within the terms of whatever account you connect.
MIT — see LICENSE.