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
83 changes: 72 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,78 @@ forward. No router library: the app already routed on
`window.location.hash`, so this stays dependency-free (see
[`frontend/src/lib/hash-route.ts`](frontend/src/lib/hash-route.ts)).

**How many photos, and how long.** Live generation is one image-to-video call
per photo, run sequentially, measured at **~314s per photo** on the deployed
service. The app polls a submitted job for at most **12 minutes**
(`REEL_JOB_MAX_POLL_MS`). Holding back 45s for sending the photos up,
stitching, storing and sealing, that leaves 675s of generation, so
`floor(675 / 314)` = **2 photos** per reel here. The cap is derived from those
numbers rather than typed in
([`frontend/src/lib/reel-budget.ts`](frontend/src/lib/reel-budget.ts)), the
UI shows the resulting estimate before and during the run, and a larger
selection is never silently shortened. It is a limit of this demo's waiting
window, not of the pipeline, which accepts up to 60 photos server-side.
**How many photos, and how long.** A reel holds up to **5 photos** here. The
generation calls run **concurrently**, up to
`MAX_CONCURRENT_GENERATIONS` (5) at a time: they were always independent, one
photo in and one clip out, and a chapter bridge is generated from the
neighbouring chapters' *photos* rather than from a generated clip, so nothing
in a reel waits on anything else in it. A reel is therefore one wave of calls
rather than one call per photo.

Measured live on the deployed service (2026-08-03), sequentially: 1 photo took
**325 s** end to end and 2 photos took **626 s**, so a call is ~310 s and fixed
overhead is ~15 s. The app polls a job for at most **12 minutes**
(`REEL_JOB_MAX_POLL_MS`).

```
sequential 5 x 314 + 45 = 1615 s about 27 minutes does not fit
concurrent 1 x 314 + 45 = 359 s about 6 minutes fits, with 6 min spare
```

That is why 5 photos could never finish before. The cap is a decision, not a
derivation, and `capFitsTheWindow()` in
[`frontend/src/lib/reel-budget.ts`](frontend/src/lib/reel-budget.ts) is the
check that it still fits; a test fails if the cap rises, the concurrency drops,
or the measurement gets worse. A cross-language contract test
(`tests/integration/test_budget_contract.py`) pins the concurrency the estimate
assumes to the concurrency the backend actually runs. The UI shows the
resulting estimate before and during the run, and a larger selection is never
silently shortened. The cap is a limit of this demo's waiting window, not of
the pipeline, which accepts up to 60 photos server-side.

**Order does not depend on who finishes first.** Generation and storage are
separate phases: the calls overlap, then every artifact is written and sealed
on one thread in spec order. So the write sequence, the edit and the sealed
manifest are identical to a fully sequential run, and a test asserts exactly
that by running the same reel at concurrency 1 and 5 and comparing the sealed
steps.

**Rate limiting.** The provider's real concurrency limit is not published to
us, so 5 is a chosen conservative number rather than a tuned one, and it is not
hoped away: a rate-limited call backs off (exponential, jittered) and retries
up to `RATE_LIMIT_MAX_ATTEMPTS`. Only a rate limit is retried, because it is
the only failure that answers to waiting; a dead balance or a rejected request
fails identically however many times it is asked. Setting
`CINEMORY_MAX_CONCURRENT_GENERATIONS=1` restores the old sequential behaviour
without a deploy.

### What a run costs

Every reel reports what it burned, and it is readable per job long after the
fact, not only in logs: `GET /reels/jobs/{job_id}` returns it inside the job's
`result.usage` (see [`src/cinemory/usage.py`](src/cinemory/usage.py)).

| field | what it is |
|---|---|
| `provider_calls`, `provider_calls_by_model` | generation calls, broken down by model |
| `duration_ms` | wall clock for the whole run |
| `provider_seconds` | time summed across calls; diverges from wall clock exactly by what concurrency saved |
| `calls[]` | per call: model, start, finish, duration, bytes in, bytes out, attempts |
| `input_bytes_to_provider`, `output_bytes_from_provider` | bytes sent and received |
| `objects_written`, `bytes_written` | writes to B2, the number that predicts the Class B transaction ceiling |
| `max_concurrency` | how many calls were allowed to overlap, so the durations are interpretable |

**There is no money in it.** Reliable per-call pricing for these models is not
available to us, and a made-up euro figure would look authoritative and be
wrong. What is reported is units burned. The same numbers also go out as one
greppable log line per run.

Usage rides on the run's *result*, not inside the sealed manifest. The manifest
already seals the per-step facts it is built from (provider, model,
`started_at`, `finished_at`, output `size_bytes`); this rollup is
observability, and putting mutable bookkeeping inside a hashed artifact would
change the manifest hash of every reel ever made for a reason that has nothing
to do with provenance.

**Honest limitation.** Polling can be answered from anywhere, but the
generation itself still runs in-process, on the one instance that accepted
Expand Down
54 changes: 53 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
Expand Down Expand Up @@ -133,3 +133,55 @@ describe("<App /> — reopening a reel from its link", () => {
).toBeInTheDocument();
});
});

describe("<App /> — a reel link pasted into an already-open tab", () => {
it("switches to the pasted reel without a reload", async () => {
// Changing only the fragment navigates nothing, so without a hashchange
// listener the pasted link would sit there doing nothing until the visitor
// thought to reload.
const getJobSpy = vi
.spyOn(cinemoryApi, "getReelJob")
.mockReturnValue(new Promise(() => {}));
renderApp();
expect(
screen.getByRole("button", { name: /create your reel/i }),
).toBeInTheDocument();

await act(async () => {
window.location.hash = "#reel/PEYsghoylVNUrc2rNAdHJa6_";
window.dispatchEvent(new HashChangeEvent("hashchange"));
});

expect(
await screen.findByRole("heading", { name: /rolling/i }),
).toBeInTheDocument();
expect(getJobSpy).toHaveBeenCalledWith("PEYsghoylVNUrc2rNAdHJa6_");
});

it("says so when the pasted link is malformed", async () => {
renderApp();
await act(async () => {
window.location.hash = "#reel/nope!";
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(
screen.getByRole("heading", { name: /couldn.t find that reel/i }),
).toBeInTheDocument();
});

it("ignores the skip link firing hashchange while a reel is open", async () => {
// Every keyboard visitor sets #main-content. Tearing down the reel they
// are watching because they pressed Tab would be an unpleasant surprise.
vi.spyOn(cinemoryApi, "getReelJob").mockReturnValue(new Promise(() => {}));
window.location.hash = "#reel/PEYsghoylVNUrc2rNAdHJa6_";
renderApp();
expect(await screen.findByRole("heading", { name: /rolling/i })).toBeInTheDocument();

await act(async () => {
window.location.hash = "#main-content";
window.dispatchEvent(new HashChangeEvent("hashchange"));
});

expect(screen.getByRole("heading", { name: /rolling/i })).toBeInTheDocument();
});
});
36 changes: 26 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,33 @@ export default function App() {
// that cannot exist
// Anything else, including the skip link's own #main-content, is not a route
// and lands on the normal landing page.
//
// Applied on load AND on every later hash change. The listener is not
// decoration: pasting a reel link into a tab that already has this app open
// changes only the fragment, so the browser fires `hashchange` and navigates
// nothing. Without this the pasted link sat there doing nothing until the
// visitor thought to reload, which is the opposite of the point.
useEffect(() => {
const route = parseHashRoute(window.location.hash);
if (route.kind === "create") {
setStarted(true);
} else if (route.kind === "reel") {
setResumeJobId(route.jobId);
goTo("generate");
setStarted(true);
} else if (route.kind === "broken") {
setBrokenLink(true);
}
const apply = () => {
const route = parseHashRoute(window.location.hash);
if (route.kind === "create") {
setStarted(true);
} else if (route.kind === "reel") {
setResumeJobId(route.jobId);
setBrokenLink(false);
goTo("generate");
setStarted(true);
} else if (route.kind === "broken") {
setResumeJobId(null);
setBrokenLink(true);
}
// `none` is deliberately inert: the skip link sets #main-content on
// every keyboard visitor, and clearing the reel we are watching because
// someone pressed Tab would be an unpleasant surprise.
};
apply();
window.addEventListener("hashchange", apply);
return () => window.removeEventListener("hashchange", apply);
}, [goTo]);

return (
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/components/steps/GenerateReel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { GenerateReel, newReelName } from "./GenerateReel";
import { useReelStore } from "@/store/useReelStore";
import { MAX_REEL_PHOTOS } from "@/lib/reel-budget";
import { REEL_JOB_POLL_INTERVAL_MS } from "@/lib/queries";
import { ApiError, cinemoryApi, type Occasion, type ReelResponse } from "@/lib/api";

Expand Down Expand Up @@ -126,7 +125,7 @@ describe("<GenerateReel /> — real-photo async job path", () => {
expect(submitSpy).toHaveBeenCalledWith(
expect.objectContaining({ occasion: "anniversary", chapters: 2 }),
);
expect(submitSpy.mock.calls[0]?.[0].files).toHaveLength(MAX_REEL_PHOTOS);
expect(submitSpy.mock.calls[0]?.[0].files).toHaveLength(2);
expect(getJobSpy).toHaveBeenCalledWith("job-1");
expect(uploadSpy).not.toHaveBeenCalled();
expect(createSpy).not.toHaveBeenCalled();
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/steps/OccasionPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ describe("<OccasionPicker /> — the wait, said before it starts", () => {
});

it("moves with the count, so it is never a fixed string", () => {
// Two photos still cost one wave of concurrent calls, so the wait is the
// same as one photo. The SENTENCE still has to agree with the count.
useReelStore.getState().addPhotos([imageFile("a.png"), imageFile("b.png")]);
useReelStore.getState().setOccasion("wedding");
renderPicker();
expect(
screen.getByText(/2 photos usually take about 11 minutes\./i),
screen.getByText(/2 photos usually take about 6 minutes\./i),
).toBeInTheDocument();
});

Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export const ReelResponseSchema = z.object({
// never rendered. Optional so a response from an older backend still parses
// and simply reads as the general "was unavailable" case.
degrade_kind: z.string().optional(),
// What this run burned: provider calls per model, wall clock per call and
// for the run, bytes to and from the provider, objects written to storage
// (see cinemory/usage.py). Passed through as an opaque record: the app does
// not render it, the OWNER reads it back per job from
// GET /reels/jobs/{job_id} to say exactly what a demo consumed. Declared
// here so the response still validates rather than being rejected as an
// unexpected shape.
usage: z.record(z.unknown()).optional(),
});
export type ReelResponse = z.infer<typeof ReelResponseSchema>;

Expand Down
67 changes: 44 additions & 23 deletions frontend/src/lib/reel-budget.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,66 @@
import { describe, expect, it } from "vitest";
import {
LIVE_SECONDS_PER_PHOTO,
MAX_CONCURRENT_GENERATIONS,
MAX_REEL_PHOTOS,
REEL_JOB_MAX_POLL_MS,
RUN_OVERHEAD_SECONDS,
capFitsTheWindow,
estimateSentence,
estimatedRenderLabel,
estimatedRenderSeconds,
photoCountLabel,
renderWaves,
} from "./reel-budget";

describe("MAX_REEL_PHOTOS", () => {
it("is DERIVED from the budget, not typed in", () => {
// The whole point of the cap: every photo that fits must actually finish
// inside the window the app is willing to wait. This is the arithmetic
// itself, so it fails the moment the ceiling or the measurement moves
// without the cap moving with it.
const budget = REEL_JOB_MAX_POLL_MS / 1000 - RUN_OVERHEAD_SECONDS;
expect(MAX_REEL_PHOTOS).toBe(Math.floor(budget / LIVE_SECONDS_PER_PHOTO));
it("is 5, and a full reel still finishes inside the window", () => {
expect(MAX_REEL_PHOTOS).toBe(5);
expect(capFitsTheWindow()).toBe(true);
// The whole point of checking rather than deriving: if someone raises the
// cap, drops the concurrency, or the measurement gets worse, this fails
// instead of a visitor finding out by waiting.
expect(estimatedRenderSeconds(MAX_REEL_PHOTOS)).toBeLessThan(
REEL_JOB_MAX_POLL_MS / 1000,
);
});

it("holds at today's measured numbers: 720s budget, 314s per photo, 2 photos", () => {
it("holds at today's measured numbers", () => {
expect(REEL_JOB_MAX_POLL_MS).toBe(720_000);
expect(LIVE_SECONDS_PER_PHOTO).toBe(314);
expect(MAX_REEL_PHOTOS).toBe(2);
expect(RUN_OVERHEAD_SECONDS).toBe(45);
expect(MAX_CONCURRENT_GENERATIONS).toBe(5);
});

it("a full reel fits in the window and one more photo would not", () => {
expect(estimatedRenderSeconds(MAX_REEL_PHOTOS)).toBeLessThan(
REEL_JOB_MAX_POLL_MS / 1000,
it("could not have held 5 photos sequentially, which is why concurrency exists", () => {
const sequential = MAX_REEL_PHOTOS * LIVE_SECONDS_PER_PHOTO + RUN_OVERHEAD_SECONDS;
expect(sequential).toBeGreaterThan(REEL_JOB_MAX_POLL_MS / 1000);
// One wave instead of five: 359s against 1615s.
expect(estimatedRenderSeconds(MAX_REEL_PHOTOS)).toBe(
LIVE_SECONDS_PER_PHOTO + RUN_OVERHEAD_SECONDS,
);
const oneMore =
(MAX_REEL_PHOTOS + 1) * LIVE_SECONDS_PER_PHOTO + RUN_OVERHEAD_SECONDS;
expect(oneMore).toBeGreaterThan(REEL_JOB_MAX_POLL_MS / 1000);
});
});

describe("renderWaves", () => {
it("packs photos into concurrent waves", () => {
expect(renderWaves(1)).toBe(1);
expect(renderWaves(MAX_CONCURRENT_GENERATIONS)).toBe(1);
expect(renderWaves(MAX_CONCURRENT_GENERATIONS + 1)).toBe(2);
// Never zero waves, however odd the count.
expect(renderWaves(0)).toBe(1);
expect(renderWaves(-3)).toBe(1);
});
});

describe("estimatedRenderSeconds", () => {
it("scales with the photo count, because generation is one call per photo", () => {
expect(estimatedRenderSeconds(1)).toBe(LIVE_SECONDS_PER_PHOTO + RUN_OVERHEAD_SECONDS);
expect(estimatedRenderSeconds(2)).toBe(2 * LIVE_SECONDS_PER_PHOTO + RUN_OVERHEAD_SECONDS);
it("costs one wave, not one photo at a time", () => {
// Every count up to the cap is a single wave, so the estimate is flat.
for (let n = 1; n <= MAX_REEL_PHOTOS; n += 1) {
expect(estimatedRenderSeconds(n)).toBe(
LIVE_SECONDS_PER_PHOTO + RUN_OVERHEAD_SECONDS,
);
}
});

it("never estimates less than one photo, or more than a reel can hold", () => {
Expand All @@ -52,11 +73,11 @@ describe("estimatedRenderSeconds", () => {
describe("estimatedRenderLabel", () => {
it("says the wait in whole minutes", () => {
expect(estimatedRenderLabel(1)).toBe("about 6 minutes"); // 359s
expect(estimatedRenderLabel(2)).toBe("about 11 minutes"); // 673s
expect(estimatedRenderLabel(5)).toBe("about 6 minutes"); // same wave
});

it("never says 'about 0 minutes'", () => {
expect(estimatedRenderLabel(1)).not.toMatch(/\b0 minutes\b/);
expect(estimatedRenderLabel(1)).not.toMatch(/0 minutes/);
});
});

Expand All @@ -71,14 +92,14 @@ describe("photoCountLabel", () => {
describe("estimateSentence", () => {
it("is a whole sentence whose verb agrees with the count", () => {
expect(estimateSentence(1)).toBe("1 photo usually takes about 6 minutes.");
expect(estimateSentence(2)).toBe("2 photos usually take about 11 minutes.");
expect(estimateSentence(5)).toBe("5 photos usually take about 6 minutes.");
});

it("stays free of developer vocabulary and em-dashes", () => {
for (const n of [1, 2]) {
for (let n = 1; n <= MAX_REEL_PHOTOS; n += 1) {
const sentence = estimateSentence(n);
expect(sentence).not.toMatch(/—/);
expect(sentence).not.toMatch(/\b(job|poll|API|render|queue)\b/i);
expect(sentence).not.toMatch(/(job|poll|API|render|queue)/i);
}
});
});
Loading
Loading