diff --git a/README.md b/README.md index 487e90d..6a8abd6 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,12 @@ re-runs each named provenance check from those bytes — the seal, the reel / provenance-reel / per-clip hashes, embedded-manifest equality, source-photo citations, and provider/model — returning an aggregate `verify_all` receipt whose own digest content-addresses the receipt; the React ProvenancePanel renders it -live in the browser). +live in the browser) · `POST /reels/jobs` (submit a generation as a +background job, 202 + `job_id`, see "Async generation" below) · +`GET /reels/jobs/{job_id}` (poll a submitted job's status) · +`GET /me/library` (a signed-in tenant's own reels, 401 for guest) · +`DELETE /me/data` (erase a signed-in tenant's own data, 401 for guest; see +"Optional accounts" below). **Product UI.** The React client opens on a landing page built for first-time comprehension — a **How it works** walkthrough, a self-contained **example reel**, @@ -167,6 +172,81 @@ API degrades transparently to the offline path, so `POST /reels` never 500s. --- +## Async generation (submit + poll) + +A real live render takes minutes (Kling I2V averages about 242 seconds per +clip), longer than the Firebase Hosting proxy allows and close to Cloud +Run's own request ceiling. Holding one HTTP request open that long is +fragile, so the API also exposes a non-blocking pair: + +- `POST /reels/jobs` runs the same ingest validation as `POST + /reels/upload`, writes a `queued` status object, starts the real + generation in a background thread, and returns **202** with a `job_id` + right away. +- `GET /reels/jobs/{job_id}` polls that status until it reaches a terminal + value: `queued`, `running`, `done`, or `failed`. A `done` job carries the + finished, sealed reel in its `result` field, the exact same shape the + synchronous routes return. + +Status lives in the same storage backend as everything else (B2 in live +mode, the in-memory fake offline), under `jobs//status.json`, so a +poll can be answered by any instance, not just the one that took the +submission. The frontend now submits and polls: every real-photo generation +goes through this pair (see [`src/cinemory/jobs.py`](src/cinemory/jobs.py) +and [`frontend/src/lib/queries.ts`](frontend/src/lib/queries.ts)). The +synthetic demo path (`POST /reels`, no uploaded photos) has no async +counterpart and stays a single blocking call. + +**Honest limitation.** Polling can be answered from anywhere, but the +generation itself still runs in-process, on the one instance that accepted +the submit. There is no external queue and no separate worker fleet. A +client that keeps polling keeps that instance warm, so in practice the job +finishes, but if that specific instance were ever scaled down mid-job, the +in-flight generation would stop advancing with no automatic retry. This is +an accepted tradeoff for a demo, not a production job queue. A production +version would hand the work to a durable queue consumed by a Cloud Run +*Job* instead of a request-serving instance, so the work outlives any one +instance. + +--- + +## Optional accounts (Google sign-in) + +Sign-in is off by default and entirely opt-in. It turns on only when four +public build-time variables are set on the frontend (`VITE_FIREBASE_API_KEY`, +`VITE_FIREBASE_AUTH_DOMAIN`, `VITE_FIREBASE_PROJECT_ID`, +`VITE_FIREBASE_APP_ID`) and `FIREBASE_PROJECT_ID` is set on the backend. +Leave them unset, which is the current default for the live deployment, and +the app behaves exactly as it always has: no sign-in button renders, no "My +reels" library exists, and the Firebase SDK is never even fetched by the +browser (see [`frontend/src/lib/auth.ts`](frontend/src/lib/auth.ts)). Both +sets of variables are public configuration, the same values already visible +in any Firebase client bundle or browser network tab, never secrets. + +When enabled, a visitor can sign in with Google. The backend verifies the +resulting Firebase ID token itself, against Google's public signing +certificates, with no Firebase Admin SDK and no service-account secret to +manage (see [`src/cinemory/auth.py`](src/cinemory/auth.py)). Every reel +route accepts an optional `Authorization: Bearer ` header; a verified +token scopes that request to a `tenants//` storage prefix, so a +signed-in visitor's uploads and reels are isolated from every other visitor +and from guest data. The isolation is structural, not a check that could be +forgotten: a tenant's own index can only ever contain rows under its own +prefix, so a cross-tenant lookup finds nothing to return rather than being +rejected after the fact (see `_TenantScopedStorage` in +[`src/cinemory/api.py`](src/cinemory/api.py)). The tenant id itself always +comes from the verified token, never from anything the caller supplies. + +Two routes give a signed-in visitor control over their own data: +`GET /me/library` lists everything they have generated, and `DELETE +/me/data` erases all of it in one call. Both return 401 for a guest. A +concurrency test drives several tenants (and guests) creating reels at the +same time and asserts the isolation still holds afterward, not just when +requests happen to be sequential +([`tests/integration/test_load.py`](tests/integration/test_load.py)). + +--- + ## How Backblaze B2 is used - **Every artifact is persisted to B2**: synthetic input photos, each generated @@ -327,6 +407,15 @@ A full testing pyramid runs offline (fakes for Genblaze + B2, no creds): | **E2E** | `tests/e2e/` | synthetic memories → reel → B2 → reload manifest → **assert on real SHA-256 the provenance layer recomputes** | | **Pen-test** | `tests/security/` | app-security suite driving the real app: authZ/abuse (bounds → 4xx, never 5xx), injection/path-traversal into B2 keys, provenance forgery/tamper-evidence, sensitive-data exposure (incl. the offline-degrade path), SSRF/upload magic-byte validation | +Measured on the latest green CI run on `main` (commit `a07c0a3`, 2026-07-29, +[run 30448211424](https://github.com/upgradedev/cinemory/actions/runs/30448211424)): +backend **314 passed + 4 skipped** across the `python` job's three tiers +(unit 156 passed + 1 skipped, integration 99 passed + 3 skipped, e2e 59 +passed; the skips are environment-gated, optional-dependency or +live-credential tests that do not run without creds), pen-test suite (its +own `pen-test` CI job) **62 passed**, frontend **280 vitest tests across 39 +files**. + ```bash pytest # whole pyramid pytest tests/unit # or a single layer @@ -388,6 +477,44 @@ This is a hard rule of the project: --- +## Your photos and your data + +The rule above covers this repo's own demo. A judge or user running the +live app for real, with their own photos, is a different question worth +answering plainly. + +**What leaves the browser.** Uploaded photos are stored in the project's +private Backblaze B2 bucket, not public, and nothing is fetchable without a +signed URL. When the app is running in live mode (the deployed app is: +`GET /health` reports `storage:"B2Storage"`), the generation provider does +not receive raw photo bytes over the wire. Each source photo is persisted to +B2 first, and the provider fetches it itself through a presigned URL that +expires in an hour (`_external_inputs` in +[`src/cinemory/adapters/genblaze_provider.py`](src/cinemory/adapters/genblaze_provider.py)). +Running the offline quickstart, which is the default and needs no +credentials, never talks to B2 or any external provider at all: every +adapter is an in-memory fake. + +**What does not happen.** No vision or text model looks at a photo to +describe it, and there is no captioning or image-understanding step +anywhere in the pipeline. The prompt behind each clip comes entirely from +the chosen occasion preset: a fixed rotation of camera-movement phrases +blended with that occasion's style +([`src/cinemory/ingest.py`](src/cinemory/ingest.py) and +[`occasions.py`](src/cinemory/occasions.py)), never from anything the +picture shows. + +**Control.** A signed-in visitor can list everything they have generated +(`GET /me/library`) and delete all of it in one action (`DELETE /me/data`); +see "Optional accounts" above. Guests get the identical generation with no +account and nothing to sign into. + +**Retention.** What the generation provider keeps afterward is governed by +that provider's own policy, not by this app. This project does not control +that and makes no guarantee about it. + +--- + ## Project layout ``` diff --git a/demo/STATE.md b/demo/STATE.md index 8f38a97..5f41179 100644 --- a/demo/STATE.md +++ b/demo/STATE.md @@ -1,9 +1,51 @@ # Cinemory — submission state -_Last updated: 2026-07-24. Deadline: 2026-08-03 5:00pm EDT. $10k. Greece-eligible._ +_Last updated: 2026-07-29. Deadline: 2026-08-03 5:00pm EDT. $10k. Greece-eligible._ + +## 2026-07-29: async generation, optional accounts and per-user isolation shipped; counts reconciled (canonical) + +> Authoritative current counts, from the green CI run on `main` (commit +> `a07c0a3`, run +> [30448211424](https://github.com/upgradedev/cinemory/actions/runs/30448211424)), +> now identical across `README.md`, `demo/SUBMISSION.md` and this file. Five +> PRs landed this week (#33 through #38); this entry supersedes the +> 2026-07-24 entry below. + +- **Async generation (submit + poll):** `POST /reels/jobs` returns 202 + + `job_id` and starts the render in a background thread; `GET + /reels/jobs/{job_id}` polls `queued` / `running` / `done` / `failed`. + Exists because a real live render (about 242s average) outlives the + Firebase Hosting proxy's timeout. The frontend now submits and polls for + every real-photo generation. Honest limitation: the worker still runs + in-process on the instance that accepted the submit, so this is not a + durable queue yet (see `src/cinemory/jobs.py`). +- **Optional Google sign-in:** gated on four public `VITE_FIREBASE_*` + frontend vars plus a backend `FIREBASE_PROJECT_ID`. Unset, which is + today's live default, the app is guest-only, byte-identical to before, + and the Firebase SDK is never fetched. The backend verifies the ID token + itself against Google's public certs: no Admin SDK, no service-account + secret. +- **Per-user isolation + data control:** a signed-in visitor's reels live + under their own `tenants//` prefix, isolated by construction (a + tenant's index scan cannot enumerate another tenant's rows). + `GET /me/library` lists a tenant's own reels; `DELETE /me/data` erases + all of it. A new concurrency test drives several tenants plus guests + creating reels at once and asserts isolation holds under real load, not + only when requests happen to be sequential + (`tests/integration/test_load.py::test_concurrent_multitenant_isolation_under_load`). +- **Backend: 314 passed + 4 skipped in CI** (`python` job, unit 156 · + integration 99 · e2e 59); the 4 skips are environment-gated, + optional-dependency or live-credential tests that do not run without + creds. Pen-test suite (`tests/security/`, its own `pen-test` job): 62 + passed. +- **Frontend: 280 vitest tests across 39 files** (`frontend` job). ## 2026-07-24 — test counts reconciled to CI (canonical) +> **Superseded 2026-07-29:** the suite grew again this week (backend 211 → +> 314; frontend 169 → 280) and five features shipped; see the entry at the +> top of this file. Kept for history. +> > Authoritative current counts, from the green CI run on `main` (2026-07-24) — > now identical across `README.md`, `demo/SUBMISSION.md` and this file. They > supersede the interim per-date figures further down: the suite grew as features @@ -182,7 +224,7 @@ in CI. See `feat/genblaze-adapter-contract` (PR). | Criterion | Before | After | Note | |---|---|---|---| | Real-World Utility | 8.5/10 | 8.5/10 | consumer + B2B event wedge; unchanged | -| Production Readiness | 8/10 | 9/10 | +SDK contract test; 211 backend passed + 4 skipped + 169 frontend across 31 files (CI on `main`, 2026-07-24); credential-free live-degrade + real-photo ingest; playable reels via the stable `/reels/{name}/video` route + a `/reels/{name}/verify` re-verification receipt; drift guarded | +| Production Readiness | 8/10 | 9/10 | +SDK contract test; 314 backend passed + 4 skipped + 280 frontend across 39 files (CI on `main`, 2026-07-29); credential-free live-degrade + real-photo ingest; async job submission + optional per-user isolation shipped; playable reels via the stable `/reels/{name}/video` route + a `/reels/{name}/verify` re-verification receipt; drift guarded | | B2 Storage & Orchestration | 8.5/10 | 9/10 | two real B2 write paths (Genblaze sink + cinemory) + a real queryable `index.jsonl` run index on both fake and B2 adapters | | Use of Genblaze | 6/10 | 8.5/10 | load-bearing (gen+sink+manifest); sink→store→readback path covered offline, SDK-verified | diff --git a/demo/SUBMISSION.md b/demo/SUBMISSION.md index fe57b8c..4e8d468 100644 --- a/demo/SUBMISSION.md +++ b/demo/SUBMISSION.md @@ -42,7 +42,7 @@ | About — how we built it | section "How we built it" below | | B2 usage | section "How Backblaze B2 is used" below | | Genblaze usage + AI models | section "How Genblaze is used" + models table below | -| Built with | python · fastapi · react · typescript · genblaze · backblaze-b2 · gmi-cloud · ffmpeg · cloud-run · firebase-hosting | +| Built with | python · fastapi · react · typescript · genblaze · backblaze-b2 · gmi-cloud · ffmpeg · cloud-run · firebase-hosting · firebase-auth | | Try it out links | repo + live app + mirror (top of this doc) | | Video URL | `TODO(owner): paste YouTube URL` | @@ -176,11 +176,24 @@ GMI Cloud; further Genblaze providers are on the roadmap. pen-test, plus the SDK-boundary Genblaze contract test — which drives a **real** Genblaze `Pipeline` + `ObjectStorageSink` (over an in-memory backend) so the live sink→store→readback→sha256-chain path is genuinely - exercised, not just the offline fakes. **Backend: 211 passed + 4 skipped in - CI** (unit 90 · integration 62 · e2e 59); the 4 skips are environment-gated — - optional-dependency / live-credential tests that do not run without creds. - **Frontend: 169 vitest tests across 31 files.** (Counts from the CI run on - `main`, 2026-07-24.) + exercised, not just the offline fakes. **Backend: 314 passed + 4 skipped in + CI** (unit 156 · integration 99 · e2e 59); the 4 skips are environment-gated, + optional-dependency or live-credential tests that do not run without creds. + Pen-test suite (its own `pen-test` CI job, `tests/security/`): **62 + passed**. **Frontend: 280 vitest tests across 39 files.** (Counts from the + CI run on `main`, 2026-07-29, commit `a07c0a3`, + [run 30448211424](https://github.com/upgradedev/cinemory/actions/runs/30448211424).) +- **Async job submission + optional per-user accounts (shipped 2026-07-27 to + 2026-07-29, PRs #33 to #38):** + `POST /reels/jobs` submits a generation as a background job (202 + a job + id) so a multi-minute live render never blocks one request past the + Firebase Hosting proxy's timeout; `GET /reels/jobs/{job_id}` polls it to + completion. Google sign-in is optional and off by default; when enabled, a + signed-in visitor's reels live under their own storage prefix, isolated + from every other tenant and from guest data by construction, with a + self-service library (`GET /me/library`) and one-action delete (`DELETE + /me/data`). A concurrency test proves the isolation holds under real + load, not only when requests happen to be sequential. - **Readiness gate:** `python scripts/readiness.py` scores the repo against the judging criteria with real-evidence checks. As of 2026-07-22: automatable completeness **100.0% (17/17) PASS**; full completeness **85.6%** with 3 diff --git a/frontend/README.md b/frontend/README.md index 979f9f3..d74c725 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -11,19 +11,21 @@ serves; the FastAPI backend on Cloud Run provides the API. A four-step cinematic wizard (landing → studio): -1. **Hero** — dark filmic identity (grain, letterbox, gold-on-black), one CTA. -2. **Photos** — drag-drop + file picker, reorderable thumbnail storyboard, remove, +1. **Hero**: dark filmic identity (grain, letterbox, gold-on-black), one CTA. +2. **Photos**: drag-drop + file picker, reorderable thumbnail storyboard, remove, tasteful empty state. The selected photos' **actual bytes** are the input to the reel; the count shapes how many chapters it spans. -3. **Occasion** — presets from `GET /occasions` as evocative, keyboard-navigable +3. **Occasion**: presets from `GET /occasions` as evocative, keyboard-navigable cards (music style, pacing, aspect ratio) with per-occasion gradients. -4. **Generate** — the real photo bytes are streamed to - `POST /reels/upload-multipart` (multipart/form-data) so the pipeline animates, - stitches, stores and seals *your* pixels; if no photos were selected it falls - back to the synthetic `POST /reels` path. Either way the UI shows a rich, - honest pipeline progress (photos → clips → bridges → music-cuts → stitch → B2 - → provenance) with full error/retry handling. -5. **Result** — video player (graceful cinematic poster when the offline/degrade +4. **Generate**: real photos are submitted as a background job + (`POST /reels/jobs`, base64 JSON) and polled (`GET /reels/jobs/{job_id}`) + until the render finishes, so a multi-minute live render never blocks one + request past the edge proxy's timeout; if no photos were selected it + falls back to the synthetic `POST /reels` path instead, which has no + async counterpart and stays a single blocking call. Either way the UI + shows a rich, honest pipeline progress (photos → clips → bridges → + music-cuts → stitch → B2 → provenance) with full error/retry handling. +5. **Result**: video player (graceful cinematic poster when the offline/degrade path returns a `b2://` URL), a **Provenance** panel (SHA-256 manifest seal, storage badge, per-step Genblaze hashes from `GET /reels/{name}`), and Share/Download + platform deep-links. @@ -70,10 +72,17 @@ npm run build # tsc + vite build -> dist/ ## How the API is wired -- Typed client in `src/lib/api.ts` — every response is validated at runtime with - **Zod**; `POST /reels` is synchronous (no polling), `GET /reels/{name}` returns - the sealed manifest (both the offline fake and the live B2 adapter keep a - queryable run index), and any lookup miss is treated as `null`. +- Typed client in `src/lib/api.ts`, every response validated at runtime with + **Zod**. Real-photo generation goes through the async submit-and-poll pair + (`POST /reels/jobs` + `GET /reels/jobs/{job_id}`, see `usePollReelJob` in + `src/lib/queries.ts`); the synthetic demo path (`POST /reels`) stays a + single blocking call. `GET /reels/{name}` returns the sealed manifest + (both the offline fake and the live B2 adapter keep a queryable run + index), and any lookup miss is treated as `null`. +- A signed-in request attaches a fresh `Authorization: Bearer ` header (see `withAuth()` in `src/lib/api.ts` and `getIdToken()` in + `src/lib/auth.ts`). A guest request, or any build with sign-in disabled, + sends the exact same request it always did, with no extra header. - Data fetching via **React Query** hooks (`src/lib/queries.ts`). - Base URL = `import.meta.env.VITE_API_BASE ?? ""`. Empty in production → relative paths → Firebase rewrites to Cloud Run (see repo-root `firebase.json`).