Self-hosted companion server for Pictaria Frame, the photo-frame app. One container, one Node process, zero npm dependencies, works with your Immich library.

Pictaria Server bundles everything the frame needs on the server side — and a dashboard that helps you understand your whole library:

  • Insights — understand your collection (/insights.html): photos per year with a person/place/tag lens, a "where you were" timeline with auto-detected trips, a people constellation (faces linked by shared photos), records (busiest day, home base, furthest from home), and leaderboards for people, places, cameras, and tags. Every number is clickable — browse the photos behind it, open the same view in Immich, or turn it into an album. Everything is computed by Pictaria from a periodic Immich sweep; optional Geoapify place naming sends coordinates to the provider you configured.
  • Enrich — AI photo classification into a controlled taxonomy (/enrich.html). Works with operator-hosted Ollama or LM Studio, or through the cloud with OpenAI, OpenRouter, Venice, or Ollama's cloud models.
  • Curate — the human review workflow that decides what your frame shows (/curate.html). Same-moment photos collapse into Stacks with a suggested keeper: a silver ★ from frame-worthiness scores, or a gold ★ when the optional AI referee has compared the group side by side.
  • Smart Albums — saved Immich searches (people, tags, places, dates, cameras, or free-text ranked search) that keep real Immich albums up to date on a schedule (/albums.html). A free-text rule can run in Best of mode: each search hit is double-checked against your own enrichment data and the keepers are ranked by your Curate decisions and photo scores — a "Top 50" that is your best 50, not Immich's first 50. Each run re-syncs the album to its rule, and deleting a rule never deletes the Immich album itself. (Rules, Best of, the sync.)
  • Frame Remote — see what each frame is showing and control it live from your phone (/remote.html). Multiple frames run off one server: each device reports under its own name, and a device picker appears when more than one is known so commands reach only the frame you chose. Retired or re-onboarded devices are cleaned up under Settings → Devices.
  • Voice — intent parsing, photo Q&A ("where was this taken?"), "what's interesting about this photo", one-shot general Q&A ("tell me …"), photo show-search, and TTS for Pictaria Frame. Try-it testers live in Settings → Voice TTS.
  • Frame Metrics — what your frames have actually shown and which voice commands get used (/metrics.html, the "Frame" nav item). Counts only; voice transcripts are never stored.
  • Settings — nearly all configuration is editable at runtime from the web UI (/settings.html); changes apply immediately, no restart.
  • Activity — a local chronological history of Enrich, Curate, Frame, voice, settings, and server events (/activity.html), with filters and bounded JSON/CSV downloads. Sensitive content such as prompts, transcripts, captions, job logs, and provider errors is never projected into the feed.
  • Ambient — weather and location display strings for the frame's ambient screen.

See Pictaria Server

These are the same privacy-scrubbed product views published on the Pictaria Server page. Select any image to open it at full size.

Pictaria Server Insights page with collection statistics and an expanded photo timeline
Insights — collection statistics, people and places, and the timeline behind them.
Pictaria Server Curate page with stacks of related photos and suggested keepers
Curate — related shots become stacks with a suggested keeper and a human decision.
Pictaria Server Smart Albums page with scheduled rule-based Immich albums
Smart Albums — saved rules keep real Immich albums synchronized on a schedule.
Pictaria Server Frame Metrics page with a display ledger and most-shown photos
Frame Metrics — a local display ledger shows what each Pictaria Frame has presented.

Quick start (Docker)

Production installs should select an explicit reviewed release. The source tag and matching versioned GHCR image keep the deployment inputs aligned.

PICTARIA_RELEASE=v1.0.0 # replace with the release you are installing
mkdir pictaria-server && cd pictaria-server
curl -fsSLO \
  "https://raw.githubusercontent.com/pictaria-ai/pictaria-server/${PICTARIA_RELEASE}/docker-compose.yml"
curl -fsSL -o .env \
  "https://raw.githubusercontent.com/pictaria-ai/pictaria-server/${PICTARIA_RELEASE}/.env.example"
chmod 600 .env
# edit .env: set IMMICH_BASE_URL, IMMICH_API_KEY, and a non-empty APP_PASSWORD
# for off-machine Docker backups, also follow docs/BACKUP.md to mount the
# destination and set BACKUP_DIR to its container path
docker compose config --images # must name the selected numeric release, not latest
docker compose pull
docker compose up -d

Open http://your-host:4080, enter your app password, and you're in.

Pictaria Server v1 requires Immich 2.0 or newer. It has been tested with Immich 2.7.5 and 3.1.0; other versions may work but are not explicitly validated. See Immich compatibility before upgrading Immich.

Quick start (bare Node)

Requires Node 22.16+ on the 22 line, or 23.8+ beyond it (node:sqlite and its online-backup API; earlier builds fail at boot — engines says ^22.16.0 || >=23.8.0). The production container and CI suite use Node 22; newer supported versions work as well.

PICTARIA_RELEASE=v1.0.0 # replace with the release you are installing
git clone --branch "$PICTARIA_RELEASE" --depth 1 \
  https://github.com/pictaria-ai/pictaria-server.git
cd pictaria-server
cp .env.example .env   # edit as above
chmod 600 .env
npm start

Development preview

To evaluate unreleased work, clone the moving main branch and build it locally. This path can change between releases and is not the production install or upgrade procedure:

git clone https://github.com/pictaria-ai/pictaria-server.git
cd pictaria-server
cp .env.example .env   # edit as above
chmod 600 .env
docker compose up -d --build

For every later production update, follow Upgrading and select the release you intend to run.

Either way, continue with the first-run checklist — connect Immich, install the app, and switch on the optional features one by one.

Configuration

Only a handful of values have to be environment variables: the listen address/port, the data paths, and APP_PASSWORD. Everything else — the Immich connection, AI providers and models, voice, weather, backups — can be set (and changed at any time, no restart) from the web UI. Provider connections and model identifiers live under Settings → AI Providers; choose the provider used for new enrichment runs on Enrich, where the selection is remembered across visits and restarts. Every UI-editable field also has an env-var form for infrastructure-as-code setups; a value saved in the UI overrides the environment until cleared.

See docs/CONFIGURATION.md for the complete reference and .env.example for an annotated starter file. The short version:

Variable Required What it does
IMMICH_BASE_URL yes (env or Settings) Server-reachable Immich address over HTTP or HTTPS, e.g. http://immich.local:2283 or https://photos.example.com; omit trailing /api
IMMICH_API_KEY yes (env or Settings) An Immich API key for your account — full access not required; see the least-privilege checklist in Getting started
APP_PASSWORD strongly recommended Protects the API and web UI (env-only). Blank or missing refuses startup unless ALLOW_INSECURE_OPEN=true.
ALLOW_INSECURE_OPEN no Dangerous explicit opt-in to run without authentication. Anyone who can reach the server can browse photos and perform Immich-backed mutations.
OPENAI_API_KEY / LMSTUDIO_* / OPENROUTER_* / OLLAMA_* / VENICE_* for Enrich Provider connections and vision-model identifiers; configure the same values under Settings → AI Providers
DEFAULT_PROVIDER no Initial/infrastructure fallback for Enrich; choosing a provider on the Enrich page saves the active choice for future runs
TTS_PROVIDER + keys for Voice openai or elevenlabs text-to-speech
WEATHER_DEFAULT_LOCATION for Ambient Default weather place (any city name or US zip)
GEOCODING_PROVIDER + GEOAPIFY_API_KEY optional Reverse geocoding for nicer location labels

Both HTTP and HTTPS Immich connections are supported. Publicly trusted HTTPS certificates work normally; a private/home CA must be trusted by the Node process inside the container. Addresses without a scheme default to HTTP, so enter the full https:// address when you intend to use TLS. See Configuration for a private-CA example and the distinction between the server-reachable IMMICH_BASE_URL and browser-facing IMMICH_PUBLIC_URL.

All state lives in one directory (./data, or the /data volume in Docker): enrichment.sqlite, smart-albums.json, frame.db, settings.json, insights.sqlite, custom models under wake-word-models/, and automatic snapshots under backups/.

Privacy

Pictaria Server has no telemetry, analytics, or Pictaria-operated cloud service. It does make the connections needed for features you choose:

  • Your Immich server supplies library data and images and receives the album, tag, description, or location writes you explicitly enable.
  • Enrich sends the selected image rendition to its selected model. The spoken Interesting command independently sends an Immich preview plus the photo's date, location, and filename to the voice-answer model you selected. A local model keeps those requests within infrastructure you operate; a cloud model receives them through your own provider account.
  • Weather sends the requested city or US ZIP to Open-Meteo for geocoding and the resulting coordinates for the forecast. Optional Geoapify place naming sends photo or calculated home coordinates to your configured account.
  • A selected TTS provider receives the answer text it turns into speech.

Insights statistics and app metrics are stored locally. Voice usage counters record only a command label and timestamp; transcripts are never stored.

Exposing beyond your LAN

Pictaria Server is built as a LAN tool: it speaks plain HTTP and uses a single shared password. If you want to reach it from outside your network, don't port-forward it directly — put it behind an HTTPS reverse proxy (Caddy, nginx, Traefik) or a VPN/tunnel like Tailscale or WireGuard, and keep the server itself listening only where the proxy can reach it.

If Docker publishes the port directly on a Tailscale or other VPN-owned host address, read the VPN boot-order guidance. The default 4080:4080 mapping does not have that address dependency.

When it is only ever reached through HTTPS, also set SESSION_COOKIE_SECURE=true so the browser session cookie is never sent over plain HTTP (leave it unset for normal LAN use — it breaks plain-http:// login). For a public custom domain, set BROWSER_ALLOWED_HOSTS to its exact host or host:port (comma-separated for more than one). IP addresses, single-label LAN names, .local, .home.arpa, and Tailscale .ts.net names are accepted automatically. This allowlist keeps an arbitrary DNS-rebinding hostname from serving the login UI, becoming its own cookie/Origin trust anchor, or reaching the API in deliberate open mode.

Configure the proxy to preserve the browser-facing Host header: Pictaria compares that host and port with the browser's Origin and rejects foreign browser Fetch Metadata to prevent cross-origin API requests, including in deliberate open mode. Native and programmatic clients that send no browser headers remain supported. TLS may terminate at the proxy; the schemes do not need to match. nginx does not preserve the public host by default, so its Pictaria location needs this explicit directive:

proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Whatever proxy you use, confirm that the upstream Host remains the public host and port the browser requested and that the same authority is in BROWSER_ALLOWED_HOSTS. To keep one device's failed logins from locking out every user behind the proxy, set TRUSTED_PROXY_IPS to the proxy's exact address or narrow network (comma-separated IPs/CIDRs). Pictaria ignores X-Forwarded-For unless the direct peer is trusted, and evaluates trusted chains from right to left; never configure a catch-all such as 0.0.0.0/0. Leave this unset for direct LAN/VPN access. The auth model stays a single shared password with bounded concurrent password work, a ~1s failure delay, per-client lockout, and a higher global failure ceiling; there are no per-user accounts or roles, so treat access as all-or-nothing when deciding who gets the URL.

API

Everything is under /api/ and requires auth when APP_PASSWORD is set: programmatic callers send the password as an X-App-Password header or Authorization: Bearer; the web UI logs in once (POST /api/session) and rides an HttpOnly session cookie for 30 days — the browser never stores the password itself. Login bodies are limited to 4 KiB and must arrive within 5 seconds. Cookie-authenticated and deliberate-open browser API requests reject foreign/null origins and cross-/same-site Fetch Metadata; native clients without browser headers remain supported. JSON endpoints also require an application/json content type. Session signatures combine the current password with a random per-installation secret stored in the data directory, so they survive ordinary restarts, immediately expire when the password changes, and cannot be used to test password guesses by themselves. Upgrading an older install to this session format asks each browser to log in once. /api/health always answers, but tells unauthenticated callers only { ok, service, time, authRequired } — configuration details need credentials. Feature prefixes: /api/review, /api/enrich, /api/taxonomy, /api/albums, /api/frame (including wake-word models), /api/voice, /api/photos, /api/assets, /api/weather, /api/insights, /api/activity, /api/settings, /api/support, /api/backup.

Text-to-speech uses authenticated POST /api/voice/tts with Content-Type: application/json and a body shaped as { "text": "What to say" }. A successful response is the provider's audio payload; the X-TTS-Provider and optional X-TTS-Voice response headers describe it. GET /api/voice/tts is not supported and never dispatches provider work.

Documentation

Development

npm test   # node --test; no dependencies to install

The suite includes browser-level smoke tests (test/browser/) that drive the real admin pages — login gate, Insights lens search, Curate, Smart Album create/validate — in headless Chrome over raw CDP, still with zero npm dependencies. They use a system Chrome/Chromium if one is installed (set PICTARIA_CHROME_BIN for a non-standard path) and skip cleanly when none is found.

Design rules

  • Zero npm dependencies; Node built-ins only.
  • The taxonomy file is configuration, not code: tags, thresholds, and review-bucket policy live in taxonomy/v1.json so users can tune behavior without touching source.
  • Human decisions always win over AI output.
  • SQLite is the local source of truth; Immich is synced to, never trusted as the record of review decisions.
  • One password, one data directory, one process: setup should never be the hard part.