feat(frontend): make a reel survive the tab, and say how long it takes - #54
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withhistory.replaceStatethe 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 onwindow.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.jsonin B2).Unknown, expired or malformed:
%that makesdecodeURIComponentthrow)#main-contentBoth 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.pyruns a plain nestedfor chapter: for photo: self._step(...), each call blocking). So n photos cost n x per-photo wall clock.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.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.SAMPLE_PHOTO_COUNTnow follows the cap, so the one-click demo path yields exactly a full reel and never trips its own overflow notice.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_kindnow 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.tsmaps it to a sentence.What a visitor sees when the credit runs out, on the result screen, next to the existing offline badge:
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.exceptionnow carriesdegrade_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:
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 genuinepage.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.