# Technical Decisions

Decisions made upfront to keep agents aligned and avoid rework.

## Stack

| Layer | Choice | Rationale |
|-------|--------|-----------|
| UI | Vanilla JS + ES6 modules | Matches all other geo.camera apps. No build step. |
| Styling | CSS custom properties + acequia-tokens.css | Consistent with acequia design system |
| Networking | acequia.js (Group, Event Bus, Shared State) | Already built, handles WebRTC/WS fallback |
| Persistent storage | WebDAV via acequia virtual filesystem (/acq/) | Authenticated, persistent, cross-peer accessible |
| Local media storage | OPFS (Origin Private File System) | No serialization overhead, no blob size limits, filesystem semantics |
| Local metadata | IndexedDB (via localforage) | Small key-value lookups: manifest index, settings, peer state |
| Camera | getUserMedia API | No native dependency, works in all mobile browsers |
| Upload | `<input type="file">` | Native file picker, multi-select, camera roll access |
| Video recording | MediaRecorder API | Browser-native, no transcoding needed |
| Video seeking | MediaSource API + SourceBuffer | Byte-range playback from any offset |
| Maps | Leaflet (CDN) | Lightweight, mobile-friendly, no API key needed |
| QR codes | Canvas-based (qr.js or similar tiny lib) | One small dependency for group sharing |

## No Build Step

Like other geo.camera apps, this runs as plain ES6 modules loaded via `<script type="module">`. No bundler, no transpiler. This keeps the development loop fast (edit, refresh) and deployment simple (WebDAV PUT).

## OPFS vs IndexedDB

OPFS handles all binary media (photos, videos, thumbnails). IndexedDB handles only small structured metadata.

**Why OPFS for media:**
- No structured-clone serialization: blobs are stored as raw files, not serialized into IDB records
- No practical size limits: IDB has per-blob limits (~500MB on some browsers) and storage pressure issues with large video files
- Synchronous access in workers via `createSyncAccessHandle()`: the service worker can read media without async overhead
- Natural directory hierarchy: mirrors the WebDAV path structure (`/field-observer/{group}/thumb/`, `/full/`, `/clip/`)
- Better eviction control: we manage our own storage budget rather than relying on IDB's opaque quota

**Why IndexedDB for metadata:**
- Key-value queries (lookup media entry by ID, list all entries for a group)
- Small records (~200 bytes per media entry)
- Well-supported query patterns via localforage or raw IDB

**OPFS layout:**
```
field-observer/
  {groupId}/
    thumb/{mediaId}.jpg       # 400px thumbnails (20-50KB each)
    full/{mediaId}.jpg        # Full resolution photos
    clip/{mediaId}.webm       # Video clips
    index/{mediaId}.json      # Keyframe byte-offset index (for long videos)
    pending/                  # Upload queue (not yet synced to WebDAV)
```

**OPFS access pattern:**
```javascript
const root = await navigator.storage.getDirectory()
const group = await root.getDirectoryHandle('field-observer', { create: true })
const thumbDir = await group.getDirectoryHandle('thumb', { create: true })
const file = await thumbDir.getFileHandle('abc123.jpg', { create: true })
const writable = await file.createWritable()
await writable.write(blob)
await writable.close()
```

## Media Formats

- **Photos (captured):** JPEG via `canvas.toBlob(cb, 'image/jpeg', 0.85)`
- **Photos (uploaded):** Stored as-is (JPEG, PNG, HEIC). No re-encoding.
- **Thumbnails:** Always JPEG 400px wide via canvas resize. Aim for 20-50KB.
- **Video (captured):** Whatever MediaRecorder produces (WebM/VP9 on Chrome/Firefox, MP4/H.264 on Safari)
- **Video (uploaded):** Stored as-is. Playback via native `<video>` or MediaSource API.

## Media IDs

Use `crypto.randomUUID()` (available in secure contexts) for media IDs. Short, unique, no collision risk.

## WebDAV Path Convention

```
/field-observer/{groupId}/
  manifest.json           # Array of media metadata objects
  thumb/{mediaId}.jpg     # 400px thumbnails
  full/{mediaId}.jpg      # Full resolution photos
  clip/{mediaId}.webm     # Video clips
  index/{mediaId}.json    # Keyframe byte-offset index (long videos only)
```

The manifest is the source of truth for what media exists. It gets synced via Group Shared State across peers and persisted to WebDAV.

## Event Bus Topics

| Topic | Payload | Purpose |
|-------|---------|---------|
| `media/new` | `{ id, type, authorName, thumbBlob, size, duration }` | New media captured/uploaded, includes thumbnail for instant display |
| `media/delete` | `{ id }` | Media removed by author |
| `media/caption` | `{ id, caption }` | Caption added/updated |
| `media/mirrored` | `{ id, deviceId }` | A peer finished mirroring this media |
| `peer/joined` | `{ displayName }` | Peer joined the group |
| `peer/left` | `{ displayName }` | Peer left the group |

## Shared State Schema

```json
{
  "media": {
    "<mediaId>": {
      "type": "photo|video",
      "source": "capture|upload",
      "author": "<deviceId>",
      "authorName": "display name",
      "timestamp": 1711300000,
      "location": { "lat": 0, "lon": 0, "alt": 0 },
      "caption": "",
      "size": 1234567,
      "duration": null,
      "hasIndex": false,
      "mirrors": ["<deviceId>", "<deviceId>"]
    }
  },
  "peers": {
    "<instanceId>": {
      "displayName": "name",
      "joinedAt": 1711300000,
      "isMirror": true
    }
  }
}
```

Thumbnails and full-res files are referenced by convention (`/thumb/{id}.jpg`) rather than stored in shared state, keeping the state object small.

## Byte-Offset Video Index Format

For videos longer than ~30 seconds, a keyframe index enables seeking without downloading the whole file:

```json
{
  "mediaId": "abc123",
  "duration": 187.4,
  "codec": "video/webm; codecs=vp9,opus",
  "containerFormat": "webm",
  "keyframes": [
    { "time": 0.000, "byte": 0 },
    { "time": 2.034, "byte": 84992 },
    { "time": 4.001, "byte": 171008 }
  ]
}
```

**How it works:**
1. At capture/upload time, scan the container for keyframe positions
   - WebM: parse EBML to find Cues element (byte offsets of Clusters)
   - MP4: parse moov atom to find stss (sync sample table) + stco/co64 (chunk offsets)
2. Store as `{mediaId}-index.json` in OPFS and WebDAV
3. On seek: binary-search index for nearest keyframe <= target time
4. Request bytes from that offset (HTTP Range to WebDAV, or WebRTC route to mirror)
5. Feed into MediaSource API SourceBuffer for playback

**WebDAV Range request (automatic with `<video>` or explicit):**
```
GET /field-observer/{group}/clip/{id}.webm
Range: bytes=171008-
```

**WebRTC byte-range route (for mirror peers):**
```
GET /groups/{group}/{instanceId}/media/{id}/range?start=171008&end=256000
```

## Multi-Device Backup

Any peer can opt in as a mirror via a toggle in the UI.

**Replication rules:**
- Mirrors replicate full-res media (not just thumbnails) into their own OPFS
- Replication is opportunistic: only on WiFi (check `navigator.connection.type`)
- Mirrors advertise availability via `mirrors` array in shared state
- Eviction: oldest media first when OPFS approaches quota (`navigator.storage.estimate()`)

**Fetch priority when a peer requests full-res:**
1. Local OPFS (instant)
2. WebRTC from nearest mirror (fast, no server)
3. WebDAV server (reliable, but slower)

## Security Boundaries

- Camera/location permissions: requested on first capture, not on page load
- File picker: user explicitly selects files (no filesystem scanning)
- No credentials leave the browser (acequia handles JWT internally)
- Media is scoped to the group; no cross-group access
- Display names are self-asserted (no identity verification needed for this use case)
- OPFS is origin-scoped (no cross-origin access)

## Browser Support

Target: Mobile Safari 16.4+, Chrome Android 109+. These cover >90% of phones in the field.

Key APIs required:
- `getUserMedia` (camera capture)
- `MediaRecorder` (video recording)
- `navigator.storage.getDirectory()` (OPFS) — Safari 15.2+, Chrome 86+
- `FileSystemWritableFileStream` (OPFS writes) — Safari 16.4+, Chrome 86+
- `MediaSource` (seekable video) — Safari 13+, Chrome 23+
- `crypto.randomUUID()` (IDs) — Safari 15.4+, Chrome 92+
- ES6 modules
- WebRTC (via acequia)
