A mobile-first, installable (PWA) AI image studio: edit photos across many image models, or copy EXIF metadata between images, all from one small self-hosted app.
- Multi-provider image editing. One UI in front of Gemini (direct), WaveSpeed, and OpenRouter, with 14+ image models (Nano Banana 2 / Pro, Seedream, GPT Image, Grok, …).
- Model-aware controls. Each model exposes only its real aspect ratios, resolutions (2K/4K), multi-image limits, output formats and extras (quality, web/image search, OpenRouter FLEX), with a live per-image price estimate and the estimated output resolution in pixels.
- Text-to-image, image-to-image and re-edit. Up to 5 reference images, inline prompt templates, and a one-tap "edit this result again" loop.
- Keep your results. Download or share each generation (Web Share where available), copy the exact prompt used, and browse a session history of the images you made.
- EXIF metadata copier. Copy full EXIF, GPS only, GPS + time, or a custom field set from a source JPEG onto a target image, with optional pre-clear and PNG to JPEG conversion.
- Mobile-first PWA. Installable, offline app shell, safe-area aware, no zoom for an app-like feel, HEIC uploads converted on the fly.
- Self-hosted and private. Keys stay on the server (see below); a single unlock password gates usage.
On first run the app asks for the unlock password (
APP_PASSWORD). It is remembered for 90 days, so you rarely re-enter it. On mobile, use your browser's "Add to Home Screen" to install it as a standalone app.
- On the Editor tab (the default), optionally add up to 5 reference images with Add Photo (HEIC is converted automatically). Pure text-to-image models need no image.
- Describe the edit in the prompt box, or pick a ready-made style template (upscale, cinematic, restore, style transfer, and more).
- Choose a Provider (Gemini / WaveSpeed / OpenRouter) and a Model. The estimated price per image is shown next to each model name.
- Set Resolution (2K/4K) and Ratio (or Auto to match your reference). The estimated output size in pixels and the price update live underneath. Extra Options appear for models that support them (quality, output format, web/image search, OpenRouter FLEX for roughly 50% cheaper but slower).
- Tap Generate. When it finishes you can Download, Share, copy the prompt, or Edit this image again to keep iterating on the result.
To carry the source photo's GPS/time onto the output, enable Keep Original Metadata (it appears once a reference image is added).
- Switch to the Metadata tab.
- Add a Source photo (the JPEG whose metadata you want) and a Target photo (the image that should receive it).
- Pick what to copy: All EXIF, GPS only, GPS + Time, or a Custom field selection. Optionally pre-clear the target's metadata first, or force the output to JPEG.
- Tap Copy Metadata, then download the result.
The Archives tab keeps the images you generated during the current session, so you can revisit or re-download them. Clear All History empties it.
The browser does not call the AI providers directly. nginx runs a small
server-side reverse proxy under /api/<provider>/ that injects the provider API
keys, so the keys live only on the server and never reach the browser. Every
/api request must carry the unlock password (APP_PASSWORD), which nginx
verifies, so neither the models nor the keys are reachable without it. The rest
of the app is a static bundle that nginx serves.
Browser ──/api/gemini/…──► nginx (adds the key) ──► provider
◄── image ──────────────────────────────────┘
password checked by nginx on every /api request
- React 19 + Vite 8 (Rolldown) + TypeScript 6
- Tailwind CSS v4 (local build, no CDN)
vite-plugin-pwa(installable, offline app shell)- nginx (static serving + the
/apiproxy), built onnode:24-alpine, deployed behind Traefik
.
├── docker-compose.yml # deploy: app (nginx) + optional Cloudflare tunnel
├── .env.example # copy to .env (API keys, password, tunnel token)
├── docs/ # logo / docs assets
└── app/ # the whole frontend + its build/runtime
├── Dockerfile # multi-stage: node build → nginx
├── nginx.conf # static serving + the /api proxy includes
├── docker/render-config.sh # generates /config.js + the /api proxy at start
├── scripts/ # icon generator + master art
├── App.tsx, index.tsx, index.css, types.ts, config.ts
└── components/ services/ public/
All configuration comes from environment variables. At container start,
app/docker/render-config.sh generates the nginx
/api proxy (with the keys injected server-side) plus a /config.js that only
tells the browser which providers are configured (booleans, never the keys).
Changing a key needs a container restart, no rebuild.
| Variable | Purpose |
|---|---|
GEMINI_API_KEY |
Google Gemini (direct) image models |
WAVESPEED_API_KEY |
WaveSpeed models (Seedream, Nano Banana, GPT Image, MAI, Recraft, …) |
OPENROUTER_API_KEY |
OpenRouter-routed models |
APP_PASSWORD |
Unlock password; nginx checks it on every /api call. The real access gate, set something strong |
CLOUDFLARE_TUNNEL_TOKEN |
Optional: expose the app via a Cloudflare Tunnel (leave empty to disable) |
Copy .env.example to .env and fill it in. Any unset provider
key simply disables that provider in the UI.
cp .env.example .env # then edit .env
docker compose up -d --buildThe compose file assumes an external Traefik network named proxy and a host
route for photo-shot.example.com (change it to your domain in the Traefik
labels). It also defines an optional cloudflared service for public access via
a Cloudflare Tunnel;
set its public hostname to http://photo-shot:80 and leave
CLOUDFLARE_TUNNEL_TOKEN empty if you don't need it.
cd app
npm install
npm run dev # http://localhost:3000There is no /api proxy in dev, so image generation only works against the
deployed container. The dev server treats all providers as available and accepts
any unlock password. npm run build produces app/dist/; npm run preview
serves it.
The favicon / PWA icons in app/public/ are generated from the master art at
app/scripts/icon-source.png:
cd app
npm install --no-save sharp png-to-ico
node scripts/generate-icons.mjs- Keys are server-side and gated by
APP_PASSWORD; set a strong one. - nginx rate-limits
/api/authto slow password brute-forcing;robots.txtand anoindexmeta keep the app out of search engines. - For public exposure, the Cloudflare Tunnel hides your origin IP. You can layer Cloudflare WAF / rate-limiting / Access on top if you want stricter gating.


