Skip to content

feat(frontend): make a reel survive the tab, and say how long it takes - #54

Merged
upgradedev merged 1 commit into
mainfrom
feat/resumable-links-and-honest-timing
Aug 3, 2026
Merged

feat(frontend): make a reel survive the tab, and say how long it takes#54
upgradedev merged 1 commit into
mainfrom
feat/resumable-links-and-honest-timing

Conversation

@upgradedev

Copy link
Copy Markdown
Owner

Four things a visitor could not learn from the app itself. All four were raised after using the live app.

1. Losing the tab lost the work

A generation takes minutes. The whole in-flight run lived in one tab's memory, so a refresh, an accidental close or a browser reload threw it away, even though the work carried on server-side and GET /reels/jobs/{id} could still be polled.

URL shape: #reel/<job_id>. Written with history.replaceState the moment the job is submitted, not when it finishes, because the minutes in between are exactly when someone refreshes. Replace rather than push, so Back still means "leave the page" instead of walking back through every reel started in this tab. No router library: the app already routed on window.location.hash (#create), so this stays dependency-free. "reel" rather than "job" because it is a visitor's address bar.

Reopening the link resumes the run in progress, or shows the finished reel, today or tomorrow, because the job's stored status keeps its sealed result (jobs/<id>/status.json in B2).

Unknown, expired or malformed:

link what happens
malformed id (wrong alphabet, absurd length, a stray % that makes decodeURIComponent throw) answered on the spot, no request goes out
well-formed but unknown or expired one poll, 404, done
any other hash, including the skip link's #main-content not a route, normal landing page

Both failure cases land on the same plain screen: "We could not find that reel", one sentence, one "Start a new reel" button, which also clears the link so a refresh does not reopen it. Never a spinner that never stops.

The 404 half needed a change in usePollReelJob: a 404 is now terminal, not a transient blip. It used to burn the three-retry budget first, so someone with a stale link waited ~16 extra seconds to be told the same thing. The transient budget stays for what it was built for, network blips and 5xx during a live render.

The id-shape check is deliberately loose ([A-Za-z0-9_-]{4,128}, against real ids of 24). The asymmetry matters: too loose costs one pointless 404 round trip, too tight refuses a link that would have worked.

2. The estimate, and the photo cap arithmetic

Live generation is one image-to-video call per photo, strictly sequential (pipeline.py runs a plain nested for chapter: for photo: self._step(...), each call blocking). So n photos cost n x per-photo wall clock.

budget            720 s   REEL_JOB_MAX_POLL_MS, the poll ceiling
fixed overhead   - 45 s   upload, input hosting, stitch, storage, sealing
                 ------
left to generate  675 s
per photo        / 314 s  measured on the deployed service
                 ------
photos             2.14   -> floor -> 2

Measured, not guessed. A live 1-photo run on the deployed service today finished in 325 s end to end (provider: genblaze, provider_degraded: false), against 314 s of generation, so real overhead was ~11 s and the 45 s held back is conservative. Three photos need ~987 s, which cannot fit a 720 s window on this code path at all.

The cap is derived in lib/reel-budget.ts, not typed in, so if the ceiling or the measurement moves the cap moves with it, and a test asserts exactly that arithmetic.

  • The estimate is computed from the photo count, never a fixed phrase: "1 photo usually takes about 6 minutes." / "2 photos usually take about 11 minutes." Shown on the occasion step (before) and on the generating step (during).
  • The cap is enforced in the store, the one place photos enter the app, and never silently: a bigger selection triggers a role="alert" saying how many were left out. A dropped non-image is not blamed on the cap, because it was never a candidate photo.
  • The screen says the cap is a limit of this demo's waiting window, not of the product, which accepts 60 photos server-side.
  • SAMPLE_PHOTO_COUNT now follows the cap, so the one-click demo path yields exactly a full reel and never trips its own overflow notice.

Note: the owner has since decided the cap should be 5, which needs the per-photo calls to run concurrently. That is a follow-up PR, together with usage accounting. This PR ships the cap that is honest for the code as it stands.

3. A provider billing failure looked like slowness

The GMI account ran out of credit and the backend logged GMICloud submit failed (402): Insufficient credits. The app degraded honestly to the offline provider, which is correct, but said nothing about why, so the owner had to read Cloud Logging.

_degrade_kind now classifies a live failure into one coarse category (credit, busy, timeout, unavailable, refused, unknown). That category is the only thing that crosses the wire. lib/degrade.ts maps it to a sentence.

What a visitor sees when the credit runs out, on the result screen, next to the existing offline badge:

The live model could not run because our generation credit ran out. This reel was made with the built-in fallback instead, and it is labelled as such. Storage and provenance are real.

No provider name, no cloud name, no status code, no exception text, no exception class name. A test pins all of that, including on a category string crafted to look like an upstream message. An unrecognised or absent category falls back to the honest general sentence, so an older backend still reads as a complete explanation rather than a blank.

The operator's copy is unchanged in detail and better in navigation: the same _log.exception now carries degrade_kind=..., so "it told me the credit ran out" greps straight back to the real upstream failure.

This extends the existing degrade disclosure and sealed manifest rather than inventing a parallel story.

4. Credits are back

Verified, not assumed. A live 1-photo job on the deployed service: status: done, provider: genblaze, provider_degraded: false, 325 s. A live probe of an unknown job id returns 404, which is the answer the resume path is built on. Multitenancy is off on this deployment (GET /reels/jobs/{id} with a bogus Bearer returns 404, not 401), so the terminal-404 change cannot race a token restore.

Mobile, at 375px

Measured in Chromium, not asserted from class names:

element box note
"Start a new reel" (only control on the not-found screen) 261 x 44 meets 44px
"Choose an occasion" CTA 257 x 56
"Generate my reel" CTA 327 x 56
cap note 275 x 48 text
estimate (occasion / generating) 327 x 16 / 327 x 60 text
degrade note 327 x 48 text

Horizontal overflow at 375px: 0 on all five screens.

Tests

New: hash-route.test.ts (every malformed shape included), reel-budget.test.ts (the cap arithmetic itself), degrade.test.ts (with a leak guard), ReelLinkNotFound.test.tsx, e2e/resume.spec.ts (5 real-browser specs: the link appears mid-render, a genuine page.reload() resumes, a mistyped link makes no request, an unknown one answers in seconds, the fresh start at 375px).

Extended: App resume + broken link + non-route hash, GenerateReel resume / 404 / failed-resume, the poll's terminal 404, the store's cap and overflow, PhotoUpload's cap copy, OccasionPicker's estimate, ReelResult's classified note. Backend: 13 classification cases including the verbatim log line from the incident, plus a response-leak assertion.

Green locally: 336 vitest (97.2% lines / 91.8% branches, gate 90/85), 25 Playwright including axe and 375px, full pytest 97.2% (gate 90), ruff clean, readiness gate PASS (100% automatable).

demo/ untouched.

Four things a visitor could not learn from the app itself.

Losing the tab lost the reel. A generation takes minutes, and the whole
in-flight run lived in one tab's memory, so a refresh or an accidental
close threw it away even though the work carried on server-side and the
job could still be polled. The job id now goes into the address bar as
`#reel/<id>` the moment there is something to come back to, not when it
finishes. Reopening that link picks the run back up, or shows the
finished reel, today or tomorrow. `history.replaceState`, so Back still
means leave. No router library: the app already routed on
`window.location.hash`.

A link that leads nowhere now says so. A malformed id is answered on the
spot with no request at all; an unknown or expired one 404s, and the poll
treats a 404 as a final answer rather than a blip to retry, which used to
cost 16 more seconds of spinner before saying the same thing. Both land
on one plain screen with a way forward.

Nobody was told how long a reel takes, and five photos could never
finish. Live generation is one image-to-video call per photo, run
sequentially, measured at ~314s per photo on the deployed service; the
poll ceiling is 12 minutes. Holding back 45s for upload, stitch, storage
and sealing leaves 675s, so floor(675 / 314) = 2 photos fit. The cap is
derived from those numbers rather than typed in, the estimate shown
before and during the run is computed from the photo count rather than a
fixed phrase, and a larger selection is never silently shortened: the
upload step says what was left out. The screen says the cap belongs to
this demo's waiting window, not to the reel maker, which takes 60.

A provider billing failure looked like slowness. When the account ran out
of credit the app degraded correctly and said nothing about why, so the
owner had to read Cloud Logging to find out. Live failures are now
classified into one coarse category and that category, alone, crosses the
wire: no provider name, no credentials, no upstream text. The result
screen turns it into a sentence. The full detail keeps going to the log,
now stamped with the same category so a report is greppable back to the
real failure.

Verified on the deployed service: live generation works again with
credits topped up (1 photo, 325s, provider genblaze, not degraded), and
an unknown job id answers 404 as the resume path expects. Multitenancy is
off on this deployment, so the terminal-404 change cannot race a token
restore.
@upgradedev
upgradedev merged commit c584df8 into main Aug 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant