Summary
A patch's unlock celebration — the app's Ember Surge reveal, escalated by
the patch's rarity tier — recorded as a 1080×1920 MP4 that can be posted to
TikTok, Snapchat or anywhere that takes a vertical video. The animation is a
web page, scout-patches.com/celebrate/<patchId>, ported from the design lab
(mobile/research/celebration-rarity-lab.html, variant A). The server records
that page frame by frame with headless Chromium, encodes it with ffmpeg, and
keeps one video per patch in S3.
There is one video per patch, not per user: everyone who collects Dry Tortugas shares the same clip. It carries nothing personal.
Status
Shipped in v1.1.99 (91964fb1; in every store build since, including the current 1.1.114), and the
backend is deployed. The screen has since gained two paths that do not touch the Share menu — a push from an admin unlock opens the celebration (764c84db, 2026-10-19) and the store review ask fires on Continue after a live unlock (1457fee8, 2026-10-27). Built 2026-10-02:
- The web page
/celebrate/:patchIdexists on the landing app and the backend route table. The app's Copy link is the only thing that links to it. - The render endpoint
GET /api/celebration-video/:patchIdworks end to end (verified locally against the shared S3 bucket: Isle Royale, Rare, rendered in 22s and uploaded). Prod has ffmpeg and Chromium installed (DEPLOYMENT_GUIDE.md § Celebration videos), and the backend carrying it is deployed. - The app's Share menu (Share video / Share image / Copy link,
mobile/src/components/celebration/share/CelebrationShareMenu.tsx) is JS only and shipped in v1.1.99. It needs the backend above, or Share video reports that it couldn't make the video.
No feature flag gates any of it. Share video shows only on the Ember Surge stage (the default); Card Pull offers image and link only.
User-facing surfaces
-
The celebration's Share button (
mobile/app/celebration.tsx) opensCelebrationShareMenu(mobile/src/components/celebration/share/CelebrationShareMenu.tsx):- Share video — "Making your video… the first one can take a minute"
while it waits, then the OS share sheet with
<patch-name>-scout.mp4(TikTok, Snapchat, Instagram, Messages, Save Video — whatever the phone has). On failure: "Couldn't make the video. Tap to try again." "Stop making video" cancels. - Share image — the still 9:16 card, now a ~1080×1920 JPEG named
<patch-name>-scout.jpg(it was a 3240×5760, 11–13 MB PNG named by UUID on a 3× iPhone — see Edge cases). Since 2026-10-13 its footer prints the sharer's invite code — "Invite code K7M2QX" overscout-patches.com/i/K7M2QX— where it printedscout-patches.com/get; a guest's keeps/get. A first share mints the code before the image mounts, once: the image and its text are built from that one answer, so a mint slower than the 2.5s budget gives a code-free image AND text after one budget, never a mix. - Copy link —
<api origin>/celebrate/<patchId>?ref=<code>&src=celebrateon the clipboard (noreffor a guest). The row reads "Link copied" (and the menu closes 1.2s later) only onceClipboard.setStringAsynchas resolved — the link can first wait up to 2.5s on a first share's mint, and the row shows a spinner meanwhile. A failed write shows "Couldn't copy the link · Tap to try again." and the menu stays open (LinkCopyState,CelebrationShareMenu.tsx). - Every share's text (iOS; Android's
expo-sharingcarries none) is "I just unlocked <name> on Scout:" and that page's link, taggedsrc=celebrate-video/celebrate-image, with the sharer's code asref(board ce3991e9; see referral-and-invite.md).
- Share video — "Making your video… the first one can take a minute"
while it waits, then the OS share sheet with
-
/celebrate/:patchId(SPA,landing/src/App.tsx:32): plays the reveal in the browser, centred and scaled to fit, with a Replay button and the App Store / Google Play badges (Actions, shared with/discover/:patchId). Honoursprefers-reduced-motionby landing on the final frame, with no Replay (landing/src/pages/CelebratePage.tsx:72,130,player.tsfinish()). With?ref=<code>the badges go to the sharer's/i/<code>?src=<src>page instead (it copies the code on iPhone, and on Android puts it in Play's install referrer); without one they link the stores directly, tagged with the link'ssrc(defaultcelebrate) — ruling R-010, so a desktop visitor never lands back on the site (celebrateInstall,landing/src/lib/store-links.ts). -
The link preview of a pasted
/celebrate/:patchId(added 2026-10-13): the server swaps the shell's generic preview tags for the patch's before sending the page (backend/src/celebrate-card/celebrate-meta.ts, called from the SPA fallback inmain.ts). Title "I just unlocked <name> on Scout"; description the obstacle note + "Scout is free on iPhone and Android." for Rare and up, else "<city, state>. Collect it on Scout, free on iPhone and Android." (celebrate-card.content.ts).og:imageis/api/celebrate/:patchId/card.png: a 1200×630 card, variant A ofmobile/research/celebrate-card-lab.html(text left, patch art right under a glow in the tier's colour, same layout as/og-card.png), composed with sharp in Fraunces / Inter Tight shipped inbackend/assets/fonts/(celebrate-card.render.ts, on the shared link cardbackend/src/common/link-card.ts, which the/u/:slug/card.pngprofile card uses too).Cache-Control: public, max-age=86400. An unknown patch,?render=1, or a shell whose tags no longer match gets the generic page unchanged. -
/celebrate/:patchId?render=1: the bare 405×720 screen, driven by the renderer. Not meant for people; harmless if visited (nothing plays). -
The video (once rendered): a public, immutable S3 URL under
celebration-videos/<patchId>/<hash>.mp4.
How it works
-
The page (
landing/src/components/EmberSurge.tsx:56) draws the reveal on a fixed 405×720 CSS-px screen (9:16). Its choreography is data:emberSurgePlan(rarity)(landing/src/celebrate/timeline.ts:42) returns every beat — rise, the held breath, Legendary's lights-out, the charge, the reveal (burst, rings, flash, shake, ember recolour), name, sweep, pips, note, idle — then holds the settled frame forTAIL_MS(1.8s,timeline.ts:28). Clip lengths: Common/unjudged 4.8s, Rare 5.2s, Epic 5.4s, Legendary 6.3s. The tier numbers and colours hand-mirror the app'smobile/src/components/celebration/tierFx.tsandnarrative.ts. -
One clock, two drivers (
landing/src/celebrate/player.ts). Live, a rAF loop feeds real elapsed time and CSS runs natively. Stepped (?render=1), the caller advances time;advanceCss(player.ts:86) pauses every CSS animation under the screen and seeks it by the same dt, and the ember field (emberField.ts) is stepped by the same dt. Sparks come from a generator seeded by the patch id, so a patch renders the same video every time. -
The page exposes
window.__celebrate(CelebratePage.tsx:20) once the patch, its art and the fonts are loaded:begin(),step(dtMs),durationMs. -
The renderer (
backend/src/celebration-video/render-celebration-video.ts:50) opens that page in headless Chromium at 405×720 × 2.667 (:61) so each screenshot is 1080×1920, then loopsstep(1000/30)→ JPEG screenshot → ffmpeg stdin. ffmpeg converts the full-range JPEGs to limited-rangeyuv420pH.264 with+faststart(:89). Frame-stepping is why the video is smooth however slow a screenshot is. -
The service (
celebration-video.service.ts:71) never makes a caller wait. It answersready+ URL if the video is in S3, otherwise queues a render and answersrendering; the caller polls. One render runs at a time, with at most 8 queued or running (:37); past that it answersbusy. A failed render answersfailedfor 5 minutes, then may be retried (:39). A render is abandoned after 4 minutes (:41). -
The cache key (
videoKey,:48) hashes exactly what the page draws — name, art URL, rarity, note, state, first collection — plus the landing build id (a hash ofpublic/index.html,celebration-video.module.ts:26). Edit the patch or redesign the page and the next request renders a fresh video under a new key. Old files are never deleted. -
The app prewarms: the moment an Ember Surge celebration opens, it makes one status request (
useCelebrationVideoShare,mobile/src/hooks/useCelebrationVideoShare.ts), so the render runs while the reveal plays. Share video then polls every 2.5s for up to 3 minutes (mobile/src/domain/celebrationVideo.ts;busy,renderingand a dropped request all mean "ask again",failedis final), downloads the file to the cache under the patch's name withFile.downloadFileAsync, closes the menu and opens the share sheet withvideo/mp4throughshareFile(RNShareon iOS, so the result and chosen app are recorded;expo-sharingon Android). The file is deleted after.
Data model
No database tables. Reads the patch via PublicPatchService
(backend/src/public-patch/public-patch.service.ts), which now also returns
rarity (validated to the four tiers, else null; :61) and rarityNote.
Videos live in S3 only; which keys are known to exist, which are rendering
and which recently failed is in-memory per process and resets on restart.
API surface
GET /api/patch/:id— public; now includesrarityandrarityNote.GET /api/celebration-video/:patchId— public, unauthenticated, throttled 60/min per IP (celebration-video.controller.ts:21),Cache-Control: no-store. Body is always{ status, url? }:ready200 (withurl),rendering202,busy503,failed502 (:6);Retry-After: 3on 202/503. Unknown patch → 404.GET /api/celebrate/:patchId/card.png— public, unauthenticated (preview bots fetch it).200 image/png, 1200×630,Cache-Control: public, max-age=86400, cached in process (40 cards, 1 hour, keyed on what the card draws). Renders are budgeted at 60/min per IP, charged only on a cache miss (backend/src/common/card-server.ts), so a cached card is never a 429 — the route used to carry a ThrottlerGuard that ran before the cache. Concurrent misses for one card share a single render (common/in-flight.ts) instead of each drawing it; a joiner spends no budget. A card drawn with the disc because the patch's art failed to fetch is served withCache-Control: public, max-age=30and NOT cached. Unknown patch → 404. A card that fails to render → 302 to/og-card.png, never a 500 behind an advertisedog:image.GET /celebrate/:patchId(the SPA fallback) — now carries the patch's preview tags; see User-facing surfaces.
Key files
-
mobile/src/components/celebration/share/CelebrationShareMenu.tsx— the menu. -
mobile/src/hooks/useCelebrationVideoShare.ts— prewarm, wait, download, share, cancel. -
mobile/src/domain/celebrationVideo.ts— the polling rules and the file name. -
mobile/src/api/celebrationVideo.ts— the endpoint client andcelebrationPageUrl. -
mobile/src/hooks/useShareCard.ts— the still image (now sized in pixels, JPEG, named). -
landing/src/pages/CelebratePage.tsx— the page; live and?render=1modes. -
landing/src/components/EmberSurge.tsx— the screen's markup + mount. -
landing/src/celebrate/timeline.ts— the choreography as data. -
landing/src/celebrate/player.ts— runs the plan; live vs stepped. -
landing/src/celebrate/emberField.ts— the particle field (from the lab), seeded. -
landing/src/celebrate/tiers.ts— per-tier numbers/colours (mirror oftierFx.ts). -
landing/src/celebrate.css— the lab's variant-A styles, scoped to.cel-screen. -
backend/src/celebration-video/render-celebration-video.ts— Chromium → ffmpeg. -
backend/src/celebration-video/celebration-video.service.ts— queue, cache, S3. -
backend/src/celebration-video/celebration-video.controller.ts— the endpoint. -
backend/src/celebration-video/celebration-video.module.ts— wiring, build id, page origin. -
backend/src/spa-fallback.ts:121—/celebrate/:patchIdis a landing route (200, not 404). -
backend/src/celebrate-card/celebrate-card.content.ts— what a pasted link says (card + tags), one function. -
backend/src/celebrate-card/celebrate-meta.ts— swaps the shell's preview tags;celebrateShellHtmlis main.ts's glue. -
backend/src/celebrate-card/celebrate-card.render.ts— the celebrate card's art, fallback disc and tier pips, composed on the shared link card. -
backend/src/common/link-card.ts— the shared 1200×630 link card (layout, palette, fonts, Pango text setting, clamp/truncation), also used by the public-profile card. -
backend/src/celebrate-card/celebrate-card.controller.ts—card.png: the cache key, served throughbackend/src/common/card-server.ts(cache, render budget, degraded-card rule, fallback redirect), shared with the public-profile card. -
backend/src/common/card-cache.ts— the bounded LRU of rendered cards (shared with the public-profile card). -
backend/assets/fonts/— Fraunces SemiBold, Inter Tight Medium/Bold, and thefonts.confthe renderer points fontconfig at. -
mobile/research/celebrate-card-lab.html— the design (variant A picked 2026-10-13). -
backend/src/uploads/s3.service.ts:105—uploadPublicFile(public-read, immutable). -
.github/workflows/deploy.yml:128— keeps Chromium in step withplaywright-core.
Configuration and flags
CELEBRATION_PAGE_ORIGIN(optional) — where the renderer loads/celebratefrom. Defaulthttp://127.0.0.1:$PORT, i.e. the backend renders its own landing build. Local dev runs landing under Vite, so point it there.- Server prerequisites: ffmpeg and a Chromium build matching
playwright-core(DEPLOYMENT_GUIDE.md § Celebration videos). - No feature flags.
Edge cases and known limits
-
The still image was 9× too large, and its temp file never deleted (fixed 2026-10-02). view-shot's iOS capture takes points, so asking for 1080×1920 on a 3× iPhone produced 3240×5760 (11–13 MB). And
useShareCard"deleted" the temp file with expo-file-system's legacydeleteAsync, which in SDK 55 only throws — swallowed, so every share stayed in the cache. Both fixed inuseShareCard; the States and Parks map cards share the fix. -
First render is slow on prod: ~45–75s on the 2-vCPU box (22–33s on an M-series Mac), and it shares those cores with the API while it runs.
-
A patch with no art renders its initial on a disc, as the app does.
-
Long names clamp to two lines with an ellipsis ("Gates of the Arctic National Park &…"), as the app does.
-
Restart forgets in-flight state. A render running during a deploy is lost; the next request starts it again.
-
The video is silent. The app's reveal sound is not in it.
-
Fonts and art load from the network (Google Fonts, S3) at render time; the renderer waits for both (
document.fonts.ready, image decode), so an outage there stalls the render until its 30s ready timeout, then it reports failed.
What this feature does NOT do
- Not in builds before 1.1.99. It shipped in v1.1.99 and is in every build since, including the current 1.1.114; a user still on an older build cannot share the reveal as a video or a link.
- It is not personal. No username, no collected date, no badge count, no location of the user — and no invite code, unlike the share image (2026-10-13). One clip per patch, rendered once and shared by everyone who collects it: a per-user code would mean one 45–75s render per sharer per patch and defeat the per-patch cache, so the code rides only in the share text beside the video (iOS).
- It does not post anywhere. No TikTok, Snapchat or Instagram SDK and no account linking. The app hands the file to the system share sheet, nothing more; the person picks the app and posts it themselves.
- No video from Card Pull. The video is the Ember Surge reveal.
- No sound, no music.
- Card Pull and Field Stamp are not ported. Only Ember Surge.
- No rarity statistics. Like the rest of rarity, it never says "only N% of collectors"; the hook is the authored obstacle note (patch-rarity.md).
- The preview tags are swapped, not prerendered.
/celebrate/:idis still the SPA shell: a crawler that runs no JS gets the patch's title, description and card, but no body text. The page is a share target, not an SEO page. - The preview card is not personal either: no username, no date. Like the
video, one card per patch; only the
og:titlesays "I just unlocked". /discover/:patchIdhas no per-patch preview, deliberately. Those links are reached by scanning a printed QR code and are not posted online, so a preview has no audience (decided 2026-10-13).- The card's brand fonts render only on Linux. sharp's macOS build sets
text through Apple's font system, which ignores the bundled font files, so a
card rendered on a Mac (local dev, the api test) shows Helvetica. Prod is
Linux; verified in a
node:20-bookwormcontainer. - Old videos are never cleaned up from S3 when a patch or the page changes.
Tests that cover it
-
mobile/screen-tests/celebration.test.tsx§ Share menu (2026-10-13 additions) — Copy link, the image's and the video's share text carry?ref=K7M2QX&src=…for a scout with a code and noreffor a guest; the share image prints "Invite code K7M2QX", a guest's prints/getand no code line (falsified by printing it for a null code). Copy link says "Link copied" only after the clipboard write resolves and shows the failure state when it rejects; a first image share whose mint outlives the budget shares after one budget with no code on the image or in the text (each confirmed failing on the PR #234 code). -
mobile/screen-tests/celebration.test.tsx§ Share menu — the three options; prewarm on open; rendering → ready → download under the patch's name → shared asvideo/mp4; failure shown and nothing shared (falsified: treatingfailedasrenderingfails it); Copy link; Cancel; Card Pull offers no video and never prewarms (falsified: removing the Ember-Surge gate fails it). The image test now goes through the menu and asserts output pixels, JPEG and the file name. -
mobile/src/domain/__tests__/celebrationVideo.test.ts— polling: ready, render and busy waits, a dropped request, failed is final (falsified), timeout, cancel; file names. -
mobile/src/hooks/__tests__/useShareCard.test.tsx— the size, format/name and temp-file regressions; confirmed failing on the old hook. -
landing/src/celebrate/timeline.test.ts— per-tier choreography (reveal time, lights-out, rings, pips), Common/unjudged never show a tier, clip length. Falsified: forcing a shown tier fails it. -
landing/src/components/EmberSurge.test.tsx— rendered markup: Rare+ says its tier once with its note, Legendary's pips/rays, Common/unjudged get kicker + place and no tier, no-art fallback, sign-off instead of buttons. Falsified. -
backend/src/celebration-video/celebration-video.service.spec.ts— cached → no render; render → upload → ready; polling never queues a duplicate (falsified by deleting the guard); one render at a time; failure cool-down;busycap; 404; the cache key changes with every drawn field and the build. -
backend/src/public-patch/public-patch.service.spec.ts— rarity/note pass through; an out-of-vocabulary rarity reads as null. -
backend/api-tests/spa-fallback.api.spec.ts—/celebrate/:idis a 200 landing route. -
backend/src/celebrate-card/celebrate-card.content.spec.ts— tier, note and pips per tier; Common/unjudged state no tier; a city-named collection is not repeated; the description's three shapes. -
backend/src/celebrate-card/celebrate-meta.spec.ts— against the REALlanding/index.html: every generic tag swapped, none left behind, escaping, url-encoding, and null (plain shell) when a tag is missing (falsified by removing that guard);celebrateShellHtmlfor known, unknown,?render=1, other routes and an unreadable shell. -
backend/src/celebrate-card/celebrate-card.render.spec.ts— a one-word name wider than the card renders (it crashed sharp's composite) and stays within 3 lines (failed against the word-by-word clamp); unusable art falls back to the disc.truncateAt's cut rules and the one-word clamp moved with the text setting tobackend/src/common/link-card.spec.ts. -
backend/src/common/card-cache.spec.ts— hit, TTL, LRU cap. -
backend/api-tests/celebrate-card.api.spec.ts— a real 1200×630 PNG with its cache header; an undrawable card 302s to/og-card.png(was a 500); 404; 65 fetches of one cached card are all 200s; 61 different cards from one caller hit a 429. -
backend/src/celebrate-card/celebrate-card.controller.spec.ts— a card whose art fetch failed is not cached (the next fetch draws the art); a complete card is. -
backend/src/common/card-server.spec.ts— cache hits spend no render budget; renders past it are a 429 withRetry-After, per caller; degraded cards are not cached; a throwing render redirects; concurrent misses on one key render once, and a shared failed render redirects every caller. -
The brand FONTS are not asserted by any test: sharp's macOS build ignores the bundled font files (Helvetica locally). Verified by rendering in a
node:20-bookwormcontainer. -
The renderer itself has no automated test (it needs Chromium + ffmpeg); it was verified by rendering Epic, Legendary and Rare patches and probing the output (1080×1920, 30fps, yuv420p, tv range).
Open questions
- Prod render time is estimated from a synthetic benchmark (138ms/frame); not yet measured on the real page there.
- Whether TikTok/Snapchat re-encode anything visibly at this bitrate (CRF 20, ~2.5–4MB per clip) — not checked on device.