File size: 17,967 Bytes
e94fa19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# Building Sozai: one process, one page, and a watercolour darkroom

> A developer's tour of how Sozai actually works β€” from the moment a visitor
> hits the landing page to a shared, real-time watercolour scrapbook.
>
> This is the **flow-oriented** companion to [`architecture.md`](architecture.md).
> Where that doc drills into the FLUX.2 img2img pipeline and the ZeroGPU pickle
> boundary, this one walks the **whole website** end to end.

---

## What Sozai is

Sozai (素材, *"material / source"*) is a collaborative photo-journaling app. You
drop in your photos, it captions and titles them for you by *looking* at them,
"develops" them into watercolour prints in a darkroom animation, and lets you pin
them onto a 2D or 3D map of where they happened β€” alone, or live with friends in
a shared room.

The whole thing is deliberately small in moving parts:

- **One frontend**, shipped as two hand-written HTML files (`landing.html` +
  `index.html`) that compile React in the browser with Babel. No build step, no
  bundler, no `node_modules`.
- **One backend**, a single `app.py` (~3k lines) running **FastAPI + Gradio** in
  one CPU process.
- **Heavy models on demand**, run on Hugging Face **ZeroGPU**, where CUDA may
  only be touched inside an ephemeral `@spaces.GPU` worker.

That's the entire system. Everything below is how those three pieces talk.

---

## The 10,000-foot view

```mermaid
flowchart LR
  subgraph Browser["Browser β€” single-file React SPA, no build step"]
    SPA["landing.html Β· index.html<br/>screens Β· MapLibre 2D Β· Cesium 3D Β· Collab over SSE"]
  end

  subgraph Server["app.py β€” FastAPI + Gradio, one CPU process"]
    HTTP["routes /  /app  /room/ID<br/>REST + Gradio API + SSE rooms"]
    GW["lazy model gateways<br/>nsfw Β· autocaption Β· ASR Β· img2img"]
    DB["SQLite β€” rooms Β· participants Β· room_state"]
    PH["Phoenix tracing, in-process"]
  end

  subgraph GPU["ZeroGPU worker β€” @spaces.GPU, ephemeral"]
    W["CUDA inits ONLY here<br/>MiniCPM-V Β· NeMo ASR Β· NSFW Β· FLUX.2-klein"]
  end

  Hub["HF Hub β€” weights cache"]
  Ext["Browser-side: Stadia tiles Β· Cesium ion Β· OSM geocode"]

  SPA -->|fetch / SSE / Gradio| HTTP
  SPA -.->|tiles / geocode| Ext
  HTTP --> GW
  HTTP --> DB
  GW -->|"@spaces.GPU"| W
  GW --> PH
  Server -->|download in MAIN process| Hub
  W -->|read disk cache| Hub
```

The single invariant worth burning into memory: **CUDA only ever initializes
inside a `@spaces.GPU` worker.** The main process stays CPU-only, which is why
model *downloads* happen in the main process (it owns the cache) while the worker
only *reads* them. The rest of the design falls out of that one rule.

---

## Part 1 β€” Two HTML files, three routes

There is no client-side router framework. The server hands out exactly three HTML
responses:

| Route | Serves | Purpose |
|-------|--------|---------|
| `GET /` | `landing.html` | the splash: make a room / join by code / go solo |
| `GET /app` | `index.html` | the app, **solo** (no room) |
| `GET /room/{room_id}` | `index.html` | the app, **in a room** (same file) |

`/app` and `/room/{id}` return the *identical* document. The app decides whether
it's collaborative purely by reading `location.pathname` at runtime:

```js
function roomId() {
  const m = location.pathname.match(/^\/room\/([^\/?#]+)/);
  return m ? decodeURIComponent(m[1]) : null;
}
```

If there's a room id in the URL, collaboration turns on. If not, every collab
call quietly no-ops. One codebase, two modes, zero branching at the route level.

One backend detail worth calling out: before `index.html` is served, the server
**inlines MapLibre GL** directly into the page (`_inline_maplibre`), replacing a
`<!--MAPLIBRE_INLINE-->` marker with the cached library source. This sidesteps
every CDN/MIME/proxy issue that can break a map on Hugging Face Spaces; if the
library can't be cached, the marker is left alone and an in-page CDN loader takes
over.

---

## Part 2 β€” The screen state machine

Once `index.html` boots, the entire app is a `<Root>` that renders one `<Page>`,
and `<Page>` is a tiny state machine keyed off a single string, `screen`:

```mermaid
flowchart TD
  start([Visitor]) --> L["Landing β€” landing.html<br/>GET /"]
  L -->|make a room| MR["POST /api/rooms<br/>creates share code + room_id"]
  L -->|join by code| JC["POST /api/join-by-code<br/>resolves code to room_id"]
  L -->|continue without a room| SOLO["GET /app"]
  MR --> ROOM["GET /room/ID"]
  JC --> ROOM

  SOLO --> BOOT["index.html SPA boots<br/>React + Babel in the browser"]
  ROOM --> BOOT

  BOOT --> CFG["GET /api/config<br/>feature flags + tokens"]
  BOOT --> CONNECT{in a /room/ URL?}
  CONNECT -->|yes| RT["Collab.connect β€” SSE<br/>RoomPersistence loads saved scrapbook"]
  CONNECT -->|no| SOLOMODE["solo: collab no-ops, nothing persists"]

  BOOT --> SCREENS

  subgraph SCREENS["Root β€” single screen state machine"]
    ADD["screen = add<br/>AddToAlbum + DetailsForm<br/>upload Β· EXIF Β· NSFW Β· auto caption/title/tags Β· mic"]
    DARK["screen = darkroom<br/>DarkroomDeveloper<br/>baths animation then shake-to-reveal"]
    MAP["screen = map<br/>MapOfMyTime β€” scrapbook Β· MapLibre 2D Β· Cesium 3D"]
    PETS["screen = pets<br/>PetsScreen"]
    TRACE["screen = trace<br/>TraceScreen β€” embedded Phoenix"]
  end

  ADD -->|all photos added| DARK
  DARK -->|reveal all / skip| MAP
  ADD -.-> PETS
  ADD -.-> TRACE
  PETS -.-> ADD
  TRACE -.-> ADD
  MAP -->|export| PDF["printable scrapbook<br/>browser print, no upload"]
```

The mainline journey is **add β†’ darkroom β†’ map**. `pets` (a customizable desktop
companion) and `trace` (an embedded Phoenix LLM-trace viewer) are side branches
reachable from the top bar. The whole thing is driven by:

```js
function Page({ screen, setScreen, ... }) {
  if (screen === "add")      return <AddToAlbum onAdded={() => setScreen("darkroom")} ... />;
  if (screen === "darkroom") return <DarkroomDeveloper onDone={() => setScreen("map")} onSkip={() => setScreen("map")} ... />;
  if (screen === "pets")     return <PetsScreen onBack={() => setScreen("add")} />;
  if (screen === "trace")    return <TraceScreen onBack={() => setScreen("add")} />;
  return <MapOfMyTime ... />;  // "map"
}
```

In a room, screen changes are **broadcast** β€” peers who have "Follow" enabled get
mirrored to the same screen (more on that in Part 6).

### A note on the source of truth

Photos live in two stores, and the split is deliberate:

- **`stackPhotos`** (React state in `<Page>`) β€” the working list while you're in
  the **add** flow. Each photo carries its own `title/caption/date/time/lat/lon`,
  so the form fields can never desync from the photo list.
- **`window.SozaiPhotos`** β€” a small global pub/sub store that the **map** reads
  from (pins, transforms, etc.).

Crucially, `SozaiPhotos` is **never** persisted to `localStorage`. Solo mode is
intentionally ephemeral (refresh = empty), and room photos live on the *server*
(`/api/rooms/{id}/state`), pulled in on join. Keeping a local copy would leak old
or deleted photos into a room you later joined β€” so the in-memory store plus the
server-side room state are the only two homes a photo ever has.

---

## Part 3 β€” Getting photos in (the `add` screen)

`AddToAlbum` + `DetailsForm` is where the AI earns its keep. When you add a
photo, several things happen, all backed by the same lazy-model pattern:

1. **NSFW gate** β€” `POST /api/nsfw` runs `Falconsai/nsfw_image_detection`. If the
   model or its deps are missing, the gate is **open** (score `0.0`) so a missing
   optional dependency can never block an upload. Threshold is `0.80` by default.
2. **Auto caption / title / tags** β€” each "✨ Auto" button calls the Gradio API
   endpoint `autocaption(kind=...)`. One function serves all three: `kind`
   selects the instruction, reply directive, generation length, and trace-span
   name. The model (`MiniCPM-V`, a vision-language model) **looks at the pixels**
   β€” the filename and alt text are only used as a fallback hint when no image can
   be resolved.
3. **Voice notes** β€” the mic button records audio and `POST /api/transcribe`
   turns it into text via NVIDIA **NeMo** streaming ASR, so you can dictate a
   caption instead of typing.

Every model call is wrapped in `traced(...)`, which opens a Phoenix span with the
model id, prompt, invocation params, and output β€” so the **trace** screen shows a
live timeline of every LLM call the app makes. On any failure, autocaption
returns a lightweight fallback derived from the alt text, so the button *always*
does something.

---

## Part 4 β€” The Darkroom (img2img watercolour develop)

When you've added your photos, **add β†’ darkroom** drops you into `DarkroomDeveloper`,
which is the most theatrical part of the app. It has two phases:

- **`baths`** β€” a top-down view of developing trays; the prints "soak" while the
  backend turns each photo into a watercolour.
- **`hang`** β€” the prints hang on a line, and you **shake each one** (drag it) to
  reveal the developed watercolour, complete with water-ripple SVG filters and a
  safelight flicker.

Under the hood, each print posts to `POST /api/img2img`, which runs
**FLUX.2-klein-4B** plus a watercolour-scene **LoRA** (trigger token `WTRCLR8`)
inside a `@spaces.GPU` worker. This is the flow the original
[`architecture.md`](architecture.md) covers in depth β€” including the **pickle
boundary** (send the *data*, never the *model*), the lazy ~24 GB background
download, the warm/cold cache layers, and why the first develop is slow but
repeats land in ~12 s. If you only read one section of that doc, read Β§2–3.

The frontend is forgiving here too: if the model is still downloading, the API
returns a 500 and the print simply keeps the original photo. The shake-to-reveal
just swaps the `<img>` once a developed version is available.

---

## Part 5 β€” The Map of My Time

**darkroom β†’ map** lands on `MapOfMyTime`, which has three view variants the user
can switch between (and persists in `localStorage`):

| Variant | Component | Tech |
|---------|-----------|------|
| `scrapbook` | `DevelopScroll` | a scrolling, scrapbook-style layout |
| 2D map | `WatercolorMap` | **MapLibre GL** + Stadia watercolor raster tiles |
| 3D globe | `CesiumMap` | **Cesium ion** (only if a token is configured) |

Photos with EXIF GPS auto-pin; everything else sits in a draft **tray** you can
drag onto the map. Each pinned photo carries its own canvas transform (position,
rotation, scale), and the whole arrangement is part of the room state that gets
synced and saved. From here you can **export** the scrapbook β€” `exportScrapbook`
builds a clean, self-contained printable HTML document and opens the browser's
print dialog. No server round-trip, so it works offline and never uploads your
images.

Browser-side services (Stadia tiles, Cesium ion, OSM/Nominatim geocoding) are
called **directly from the browser** β€” they never round-trip through `app.py`.
`/api/config` hands the frontend the tokens (e.g. Cesium ion) it needs so they
can be rotated server-side in one place.

---

## Part 6 β€” Real-time collaboration

This is the part that makes "build memories *together*" literal. The transport is
deliberately humble: **Server-Sent Events inbound + plain POST outbound**, not
WebSockets. Why? Hugging Face Spaces' proxy drops custom WebSocket upgrades, so
the app uses the same SSE+POST channel Gradio itself uses on Spaces. EventSource
also auto-reconnects on its own, so there's no manual reconnect loop.

```mermaid
sequenceDiagram
  autonumber
  participant A as Guest A β€” host
  participant B as Guest B β€” joiner
  participant S as app.py β€” Manager
  participant DB as SQLite

  A->>S: POST /api/rooms
  S->>DB: insert room + share code
  S-->>A: room_path /room/ID
  A->>S: GET /api/rooms/ID/stream  β€” SSE
  S-->>A: init β€” you + participants

  B->>S: POST /api/join-by-code
  S-->>B: room_path /room/ID
  B->>S: GET /api/rooms/ID/stream  β€” SSE
  S->>A: join_request β€” admit or decline
  S-->>B: join_pending
  A->>S: POST /send admit_join
  S-->>B: init + presence_update
  S->>A: presence_update

  Note over A,B: live edits flow both ways
  B->>S: POST /send object_updated Β· photo_pinned Β· cursor
  S->>A: broadcast, excluding the sender
  A->>S: POST /send screen_changed
  S->>B: broadcast β€” followers mirror the view

  Note over S,DB: durable layer, debounced
  A->>S: PUT /api/rooms/ID/state
  S->>DB: upsert room_state, versioned
  B->>S: GET /api/rooms/ID/state on join
  S-->>B: saved scrapbook
```

A few design choices worth highlighting:

- **Host approval.** A joiner is *pending* until the host (the owner if connected,
  else the earliest-connected participant, so approval never deadlocks) admits
  them. Every SSE connection gets a queue up front β€” keyed by `session_id` β€” so a
  pending joiner can still receive `join_pending` / `join_declined` before they're
  a full participant.
- **Presence & "what are you touching."** Beyond cursors, the app emits throttled
  `interaction` pings on button presses, field focus, and typing (captured
  app-wide in the capture phase), so collaborators literally *see* labels float
  near each other's cursors β€” "πŸ”˜ create room", "✏️ caption".
- **Follow vs. roam.** Screen and map-variant changes are broadcast; a per-user
  "Following" toggle decides whether you mirror a peer's view or roam
  independently. Accessibility prefs live in `localStorage` and are *never*
  broadcast β€” everyone keeps their own a11y settings while everything else syncs.
- **No echo loop.** The shared store never re-broadcasts events it receives, so
  applying a peer's `photo_moved` doesn't bounce back out.
- **Two layers of state.** Live edits flow over SSE for immediacy;
  `RoomPersistence` is the *durable* layer β€” a debounced (1.2s), version-tracked
  `PUT /api/rooms/{id}/state` that lets a returning user (or a late joiner) load
  the scrapbook exactly as it was left. On a version conflict, the client adopts
  the server's newer state.
- **Notifications without a server.** Chimes are synthesized in-browser with the
  Web Audio API (themed note sequences), because the Python `chime` library would
  only make sound on the machine running Python β€” useless when every participant
  needs to hear it in their own browser.

---

## Part 7 β€” The AI plumbing

Four models, four endpoints, one pattern. Every model is loaded **lazily** and
**cached**, runs inference inside a `@spaces.GPU` worker, and degrades gracefully
if it's missing.

```mermaid
flowchart LR
  subgraph BROWSER["Browser"]
    U1["upload a photo"]
    U2["edit a field, hit Auto"]
    U3["hold the mic"]
    U4["enter the Darkroom"]
  end

  subgraph MAIN["app.py main process β€” CPU only"]
    NSFW["/api/nsfw"]
    CAP["autocaption β€” Gradio API"]
    ASR["/api/transcribe"]
    I2I["/api/img2img"]
    TR["traced -> Phoenix span"]
    DL["bg snapshot_download<br/>writes HF cache"]
  end

  subgraph GPU["ZeroGPU worker β€” @spaces.GPU, CUDA only here"]
    GN["_nsfw_infer β€” Falconsai"]
    GC["_caption_infer β€” MiniCPM-V"]
    GA["_asr_run β€” NeMo"]
    GI["_img2img_infer β€” FLUX.2-klein + LoRA"]
  end

  U1 --> NSFW --> GN
  U2 --> CAP --> GC
  U3 --> ASR --> GA
  U4 --> I2I --> GI
  CAP --> TR
  ASR --> TR
  I2I --> DL
  GI -. reads cache .-> DL
```

| Model | Job | Endpoint | Worker fn |
|-------|-----|----------|-----------|
| `Falconsai/nsfw_image_detection` | upload safety gate | `POST /api/nsfw` | `_nsfw_infer` |
| `MiniCPM-V` (GGUF, Q4_K_M) | caption / title / tags | `autocaption` (Gradio) | `_caption_infer` |
| NVIDIA NeMo streaming ASR | voice β†’ text | `POST /api/transcribe` | `_asr_run` |
| `FLUX.2-klein-4B` + scene LoRA | photo β†’ watercolour | `POST /api/img2img` | `_img2img_infer` |

`/api/config` reports which of these are available at runtime, so the UI can show
the active caption model, hide Cesium when there's no ion token, etc. **Phoenix**
runs in-process and is proxied at `/phoenix` (and embedded in the `trace` screen),
so every captioning/ASR call is observable without running a separate service.

---

## Part 8 β€” Lifecycle & housekeeping

Rooms are cheap and self-cleaning. State lives in SQLite (`rooms`,
`participants`, `room_state`) and a background daemon sweeps every 5 minutes:

1. Rooms idle > **1 hour** are flagged inactive (can't be joined).
2. Rooms idle past **1h10m** are deleted outright (keyed on `last_activity`, so
   the DB tracks live usage, not creation time).
3. A hard cap deletes anything older than **24 hours** regardless.
4. Orphaned `participants` / `room_state` rows are cascaded away, and an hourly
   `PRAGMA incremental_vacuum` returns freed pages to the OS so the heavy
   `room_state` blobs don't bloat the file.

The cleanup is keyed on activity, not age, which is exactly what you want for
ephemeral collaborative rooms: an actively used room stays alive; an abandoned one
disappears within the grace window.

---

## The shape of it

If you remember four things about Sozai's architecture:

1. **One page, one process, three routes.** `/` is the splash; `/app` and
   `/room/{id}` are the *same* SPA in solo vs. collaborative mode.
2. **The screen is a string.** `add β†’ darkroom β†’ map` is the whole user journey,
   driven by one piece of state.
3. **CUDA lives only in the worker.** Downloads happen in the CPU main process;
   inference happens behind `@spaces.GPU`; only plain data crosses the boundary.
4. **Live + durable are two layers.** SSE+POST for immediacy, a debounced
   versioned `room_state` PUT for "load it back exactly as we left it."

Everything else β€” the watercolour develop, the floating interaction labels, the
synthesized chimes, the 3D globe β€” is detail hung on that frame.

*For the deep dive on the FLUX.2 img2img pipeline, the pickle boundary, and the
ZeroGPU cache lifetimes, see [`architecture.md`](architecture.md).*