Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**,
Expand All @@ -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/<job_id>/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 <token>` header; a verified
token scopes that request to a `tenants/<uid>/` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

```
Expand Down
46 changes: 44 additions & 2 deletions demo/STATE.md
Original file line number Diff line number Diff line change
@@ -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/<uid>/` 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
Expand Down Expand Up @@ -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 |

Expand Down
25 changes: 19 additions & 6 deletions demo/SUBMISSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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
Expand Down
37 changes: 23 additions & 14 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <Firebase ID
token>` 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`).
Expand Down
Loading