Turn a set of photos into a scored, cinematic video reel — generated with Genblaze, stored on Backblaze B2, and sealed with verifiable SHA-256 provenance on every output.
Built for the Backblaze Generative Media Hackathon.
Canonical repo: github.com/upgradedev/cinemory (local working copy:
repos/cinemory). Feature roadmap, opt-in connectors, show-stoppers and go-live steps:ROADMAP.md.
- Cloud Run: https://cinemory-595784992266.europe-west1.run.app — the API
(
/health,/occasions,POST /reels) plus the React UI, running in live mode (/healthreports the effective backends:mode:"live",provider:"genblaze",storage:"B2Storage"). Real live generation is proven (2026-07-22): 8 completed Kling renders (~242s avg) and a real h264 720p reel on B2, including a real generation on the live box's upload path — seedemo/STATE.md+deploy/DEPLOYED.md. - Firebase mirror: https://upgradegr-cinemory.web.app — the identical app.
- Demo video:
demo/cinemory-demo.mp4(2:17). YouTube link: TODO(owner): paste the URL after upload.
Every reel is sealed with a SHA-256 manifest, and the seal is not a "trust us" claim — you can recompute it yourself, offline or in the browser.
Offline, from the CLI (no credentials needed):
pip install -r requirements-dev.txt && pip install -e .
python -m cinemory.cli --name demo --chapters 3 --per-chapter 2 --bridges --out out...
verify manifest: True
verify asset: True
verify embedded: True
Three independent checks, computed locally from the bytes written to disk: the sealed manifest hash, the reel file's own SHA-256, and the manifest re-extracted from inside the reel's video container.
In the running app: open a reel's Provenance panel and click Verify —
one click runs two independent checks in parallel. The browser re-fetches the
manifest and recomputes its SHA-256 itself with WebCrypto, so the seal isn't
taken on the server's word. At the same time, GET /reels/{name}/verify
re-fetches every stored artifact server-side and re-runs each named check,
returning a content-addressed receipt (its own digest seals the result). Two
different trust boundaries, same answer.
Cinemory began as a personal project: a video gift, made from photos, for a wedding anniversary — memories turned into a short film scored to music. This repository generalizes that idea into a production-shaped generative-media app.
Privacy first, by construction. The original anniversary content is private
and stays private. This public repo and its demo operate on synthetic demo
memories only — images generated programmatically at runtime
(synthetic.py). No real personal photo or datum
is read, generated, or committed anywhere in this project. See
PII safety.
Given a set of (synthetic) memories organised into chapters:
- Photo → clip — each photo is animated into a short video via an image-to-video model (Genblaze step).
- Chapter bridges — first-last-frame transitions smoothly connect scenes.
- Music-driven cuts — scene changes can be planned onto musical beats.
- Stitch — clips are assembled into one reel (deterministic offline, or a real ffmpeg cinematic colour-grade).
- Store on B2 — every input, clip, the final reel, and the run manifest are written to Backblaze B2 under content-addressed keys.
- Provenance — a SHA-256-sealed manifest records provider, model, prompt,
params, timestamps and every asset hash — and cites each generated clip back
to its source photo's SHA-256 (
source_sha256s), so every output traces to the exact input it came from. It is persisted to B2 and embedded into the reel container, and can be re-verified at any time.
flowchart TD
REQ["POST /reels · /reels/upload · CLI<br/>(ReelSpec: chapters · photos · occasion)"] --> RUN["ReelPipeline.run(spec)<br/>src/cinemory/pipeline.py"]
RUN --> P1["1 · Persist input photos<br/>kind = photos/"]
P1 --> P2["2 · Photo → clip (I2V)<br/>MediaProvider.generate<br/>model: Kling-Image2Video-V2.1-Master<br/>occasion pacing/tempo sealed into step params"]
P2 --> P3["3 · Chapter bridges (FLF2V)<br/>inputs: last frame of ch N + first frame of ch N+1<br/>model: seedance-2-0-260128"]
P3 --> P4["4 · Stitcher.stitch(clips) → reel.mp4<br/>FakeStitcher (deterministic) | FfmpegStitcher (cinematic grade)"]
P4 --> P5["5 · build_manifest → SHA-256-sealed manifest.json<br/>embed() → reel.provenance.mp4"]
P1 -- "put" --> B2
P2 -- "put clip, then get bytes back<br/>(storage round-trip)" --> B2
P3 -- "put + get" --> B2
P4 -- "put reel" --> B2
P5 -- "put manifest + embedded reel" --> B2
B2[("StorageBackend<br/>content-addressed keys:<br/><reel>/<kind>/<sha2>/<sha256>/<name><br/>+ durable index.jsonl run catalogue")]
P5 --> RES["ReelResult → API response<br/>reel playback URL · reel_sha256 · manifest_hash<br/>provider · provider_degraded"]
flowchart LR
FB["Firebase Hosting mirror<br/>upgradegr-cinemory.web.app<br/>rewrites /health · /occasions · /reels/** → Cloud Run"] --> CR
subgraph CR["Cloud Run — one container (Dockerfile)"]
UI["React SPA (frontend/, Vite build)<br/>served as static files"] --- API["FastAPI — src/cinemory/api.py"]
end
API --> PIPE["ReelPipeline<br/>depends on ports only"]
API -. "GET /reels/{name}/video — playback:<br/>302 to a fresh presigned URL (live)<br/>or streamed bytes (offline)" .-> SB
PIPE --- MP{{"MediaProvider port"}}
PIPE --- SB{{"StorageBackend port"}}
PIPE --- ST{{"Stitcher port"}}
MP -->|"CINEMORY_MODE=live<br/>+ genblaze SDK + GMI_API_KEY"| GEN["GenblazeMediaProvider<br/>Genblaze Pipeline step → GMI Cloud<br/>ObjectStorageSink → genblaze_s3.for_backblaze<br/>per-asset SHA-256 manifest (verify_hash)"]
MP -->|"otherwise — CI / offline demo"| FMP["FakeMediaProvider<br/>deterministic bytes"]
SB -->|"live + boto3 + B2 creds<br/>(bucket · endpoint · key id · key)"| B2S["B2Storage — boto3, S3-compatible<br/>+ index.jsonl (multi-instance safe)"]
SB -->|"otherwise"| FS["FakeStorage — in-memory,<br/>identical index surface"]
ST -->|"CINEMORY_STITCH=ffmpeg<br/>+ ffmpeg present"| FF["FfmpegStitcher"]
ST -->|"otherwise"| FST["FakeStitcher"]
GEN -. "live generation fails mid-request:<br/>re-run same spec on FakeMediaProvider + FakeStitcher,<br/>SAME storage → provider_degraded: true + degrade_reason" .-> FMP
GEN --> B2[("Backblaze B2 bucket")]
B2S --> B2
API endpoints: GET /health · GET /occasions · POST /reels (synthetic
demo) · POST /reels/upload (real photos, base64 JSON) ·
POST /reels/upload-multipart (real photos, multipart) · GET /reels/{name}
(sealed manifest) · GET /reels/{name}/video (playback: 302 to a fresh
presigned B2 URL in live mode, streamed bytes offline — the stable
playback_url every reel response carries) · GET /reels/{name}/verify
(re-verification receipt: re-fetches the manifest and every stored artifact and
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) · 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, and a one-click "Try with sample photos" path — before the four-step wizard (Photos → Occasion → Generate → Result + Provenance).
The orchestrator depends only on ports (MediaProvider, StorageBackend,
Stitcher). The real adapters wrap Genblaze and B2; the fakes implement the
same protocols with no network. The same pipeline code — including the real
hashing and provenance — runs in both modes, so CI is green with zero
credentials while the live path is a one-line adapter swap. In live mode the
real backends are used only when their credentials are present; otherwise the
API degrades transparently to the offline path, so POST /reels never 500s.
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/jobsruns the same ingest validation asPOST /reels/upload, writes aqueuedstatus object, starts the real generation in a background thread, and returns 202 with ajob_idright away.GET /reels/jobs/{job_id}polls that status until it reaches a terminal value:queued,running,done, orfailed. Adonejob carries the finished, sealed reel in itsresultfield, 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
and 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.
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). 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). 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). 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).
- Every artifact is persisted to B2: synthetic input photos, each generated
clip, chapter bridges, the final reel, the embedded-provenance reel, and the
manifest.json. - Content-addressed layout (
KeyStrategy.HIERARCHICAL):<reel>/<kind>/<sha2>/<sha256>/<name>— identical bytes deduplicate by hash. - Data orchestration: the storage backend keeps a queryable run index (JSONL catalogue of every object + size + content-type), the analogue of Genblaze's Parquet index sink — a catalogue you can query over your whole media library.
- B2 is S3-compatible, so the adapter (
b2_storage.py) is a thin boto3 client; credentials come only from the environment.
Genblaze is load-bearing, not a dumb byte source. On the live path the
adapter (genblaze_provider.py)
lets Genblaze own generation and durable storage and provenance for every
generated asset:
- Generation is expressed as a real Genblaze
Pipelinestep (Pipeline("cinemory-step").step(provider, model=, prompt=, modality=).run(...)) — image-to-video, first-last-frame bridge, audio — behind theMediaProviderport. - Storage + provenance done by Genblaze: the adapter attaches Genblaze's
own
ObjectStorageSinkover agenblaze_s3.S3StorageBackend.for_backblaze(...)backend, so Genblaze downloads the model output, content-addresses it, persists it to Backblaze B2, and seals a SHA-256 provenance manifest for the run (result.manifest.verify_hash()). - Provenance chaining: Cinemory reads the durable bytes back through the same
backend and verifies them against Genblaze's sealed SHA-256, then folds
that hash into its own reel-level manifest (
provenance.py). Genblaze owns per-asset provenance; Cinemory owns the composed-reel provenance. - Verified against the real SDK. The adapter's every call and result shape is
contract-tested against the actual published Genblaze SDK using its own
shipped mock provider (
genblaze_core.testing), so API drift fails CI — (tests/integration/test_genblaze_contract.py).genblaze-coreis installed in CI (pure-Python, no credentials); only the live GMICloud generation and B2 writes need keys. - Provider port; GMI Cloud live today — generation sits behind the
MediaProviderport. Live generation currently supports the GMI Cloud provider via Genblaze (config.pygates live readiness on it); other Genblaze providers (OpenAI, Google, Runway, Luma) are scaffolded in config and on the roadmap — the port design lets them slot in without touching the pipeline.
| Role | Default model (via Genblaze) | Provider |
|---|---|---|
| Photo → video (I2V) | Kling-Image2Video-V2.1-Master |
GMI Cloud |
| Chapter bridge (FLF2V) | seedance-2-0-260128 |
GMI Cloud |
| Still generation (optional) | seedream-5.0-lite |
GMI Cloud |
Models are configurable per pipeline. Live generation currently runs through GMI Cloud; further Genblaze providers are on the roadmap.
Beyond the core pipeline, Cinemory adds distribution and personalization — the
core stays offline/PII-safe; the connectors are opt-in and consent-gated
(never in CI or the default demo). Full detail + go-live steps in
ROADMAP.md.
- Occasion themes (
occasions.py) — six config-driven presets (anniversary, graduation, birthday, wedding, year-in-review, business-event/award-ceremony) that adjust scene labels, prompt direction, music mood, pacing and aspect ratio. Select via--occasion,POST /reelsorGET /occasions; recorded in the sealed manifest. Add a theme = add one dict entry. - Web Share + export (
frontend/src/lib/share.ts) — native OS share sheet (navigator.share({files})) to Instagram / Facebook / LinkedIn / YouTube with no platform API review, plus a download button and per-platform deep-links. - Google Photos Picker (
connectors/google_photos.py) — OAuth consent → Picker session → user hand-picks in Google's UI → poll → download picked bytes. (Library auto-curation is impossible since Google removed the read-scopes; the pick flow is the sanctioned path.) - YouTube upload (
connectors/youtube.py)- LinkedIn share (
connectors/linkedin.py) — implemented where the API allows; account-type/audit caveats inROADMAP.md.
- LinkedIn share (
Every connector runs through an injectable HTTP transport seam, so the
multi-step flows are unit-tested offline with a fake transport — no network, no
credentials, no third-party import in CI. Enable the live path with
pip install 'cinemory[connectors]'.
Not built (show-stoppers, see ROADMAP.md): an Apple/iCloud server
connector (no third-party API exists — only a native iOS PhotoKit app; the
mobile <input type=file> already streams iCloud originals), and personal
Instagram/Facebook auto-posting (Graph API is Business/Creator-only — the
share-sheet covers it).
pip install -r requirements-dev.txt
pip install -e ".[server]" # [server] brings uvicorn for the API step below
# Generate a reel end-to-end from synthetic photos and verify provenance:
python -m cinemory.cli --name demo --chapters 3 --per-chapter 2 --bridges --out out
# Run the API:
uvicorn cinemory.api:app --reload
# POST http://localhost:8000/reels {"name":"demo","chapters":3,"per_chapter":2}
#
# Generate from REAL photos (mobile/web sends actual pixels):
# POST /reels/upload base64 JSON {"name","occasion","chapters",
# "photos":[{"filename","content_base64"}]}
# POST /reels/upload-multipart multipart/form-data files=@a.jpg files=@b.jpg …Reel generation always works with no credentials: in
livemode the API uses the real Genblaze/B2 backends only when their credentials are present, and otherwise degrades transparently to the offline path (GET /healthreports the effectiveprovider/storage), soPOST /reelsnever 500s. If the live provider fails mid-request, the reel is re-run on the offline provider against the same real storage and the response is labelledprovider_degraded: true+degrade_reason— the manifest records the provider that actually generated the assets.
cp .env.example .env # fill B2 + provider credentials
pip install -e ".[live]"
export CINEMORY_MODE=live
export CINEMORY_STITCH=ffmpeg # optional real cinematic grade (needs ffmpeg)
python -m cinemory.cli --name demo --chapters 3 --per-chapter 2docker build -t cinemory .
docker run -p 8000:8000 cinemory # offline by defaultA full testing pyramid runs offline (fakes for Genblaze + B2, no creds):
| Layer | Location | Proves |
|---|---|---|
| Unit | tests/unit/ |
provenance hashing/verify/tamper-detection, key strategy, synthetic photos, beat-cut planning, occasion presets, fakes |
| Integration | tests/integration/ |
pipeline wiring (photos→clips→bridges→reel), FastAPI routes (incl. /occasions, the /reels/upload ingest routes, and the credential-free live-mode degrade path), opt-in connector flows via a fake HTTP transport, real ffmpeg stitch (skipped if ffmpeg absent) |
| 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):
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.
pytest # whole pyramid
pytest tests/unit # or a single layerscripts/readiness.py is a machine-checkable submission gate: it scores the repo
against the four challenge criteria (Real-World Utility · Production Readiness ·
B2 Storage & Orchestration · Use of Genblaze) plus our own fifth,
Application Security, with real-evidence checks —
each one drives the actual code path (the API via TestClient, the pipeline,
the real B2 adapter against an in-memory S3 stub, the real Genblaze SDK), never a
file-existence stub. Each check is pass / fail / user-gated (a lift that
needs a human-held credential — a write-entitled B2 key, a GMI_API_KEY, a live
redeploy). It prints a per-criterion report, emits readiness.json, and exits
non-zero when the automatable completeness drops below 95% — so the readiness
CI job fails on any regression. User-gated items are excluded from the automatable
% and listed as the remaining live-credential lifts.
python scripts/readiness.py # human report + readiness.json (exit 1 if < 95%)The gate is itself covered end-to-end in tests/e2e/test_readiness_gate.py
(run out-of-process, as CI runs it).
- Pen-test suite —
tests/security/, apen-testCI job of real application-security assertions against the live FastAPI app / pipeline / adapters: authorization & abuse limits, injection / path-traversal into content-addressed B2 keys, provenance forgery / tamper-evidence, sensitive-data exposure (including the credential-free offline-degrade path), and SSRF / upload magic-byte validation. Mirrored as the gate's Application Security criterion. - gitleaks v8.18.4 — secret scan, fail-fast before build (
--redact). - CodeQL — SAST for
python+javascript-typescript. - SCA/CVE gate —
pip-audit --strict(Python) +npm audit(frontend + web). - ruff — lint.
pytest tests/security # the pen-test layer, offline, no credsThis is a hard rule of the project:
- The only input source is
synthetic.py— deterministic, programmatically drawn images. - No real personal media is read, generated, or committed. The private anniversary content that inspired Cinemory is not in this repo.
.gitignoreblocks common photo formats, aprivate/directory, and.env.- CI runs a gitleaks secret scan on every push/PR.
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).
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 and
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.
src/cinemory/
models.py domain types (ReelSpec, Chapter, Bridge, Asset, ...)
ports.py MediaProvider · StorageBackend · Stitcher protocols
pipeline.py ReelPipeline orchestrator
provenance.py SHA-256 manifest: build · verify · embed · extract
keys.py content-addressed key strategies (B2 layout)
stitch.py FakeStitcher (offline) · FfmpegStitcher (real grade)
music.py beat-cut planning (pure) + optional librosa analysis
synthetic.py PII-safe synthetic photo generation
ingest.py build a ReelSpec from real uploaded photos (upload routes)
occasions.py config-driven occasion presets (themes)
config.py offline/live adapter selection (credential-aware degrade)
api.py FastAPI app
cli.py end-to-end CLI
adapters/
fake_provider.py · fake_storage.py offline
genblaze_provider.py · b2_storage.py live
connectors/ opt-in, consent-gated live integrations
_http.py injectable HTTP transport seam
google_photos.py OAuth + Photos Picker flow
youtube.py · linkedin.py upload / share
frontend/ React SPA (Vite · TS) — the product UI; served by Firebase
Hosting AND the Cloud Run container (Dockerfile builds it)
web/ legacy TS browser client — Web Share reference impl; still
type-checked/built in CI, not served by the container
tests/ unit · integration · e2e
ROADMAP.md features · show-stoppers · connector go-live steps
MIT — see LICENSE. Genblaze is MIT; Cinemory (the founder's own product/brand) is reused here by concept and pattern, with synthetic data only.

