Scout — Full Product Context → feature documentation

Celebration share video

The unlock celebration as a 1080×1920 MP4: the /celebrate/:patchId page, rendered server-side and cached per patch, and the celebration's Share menu (video, image or link). In every build since v1.1.99.

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:

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

How it works

  1. 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 for TAIL_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's mobile/src/components/celebration/tierFx.ts and narrative.ts.

  2. 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.

  3. The page exposes window.__celebrate (CelebratePage.tsx:20) once the patch, its art and the fonts are loaded: begin(), step(dtMs), durationMs.

  4. 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 loops step(1000/30) → JPEG screenshot → ffmpeg stdin. ffmpeg converts the full-range JPEGs to limited-range yuv420p H.264 with +faststart (:89). Frame-stepping is why the video is smooth however slow a screenshot is.

  5. The service (celebration-video.service.ts:71) never makes a caller wait. It answers ready + URL if the video is in S3, otherwise queues a render and answers rendering; the caller polls. One render runs at a time, with at most 8 queued or running (:37); past that it answers busy. A failed render answers failed for 5 minutes, then may be retried (:39). A render is abandoned after 4 minutes (:41).

  6. 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 of public/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.

  7. 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, rendering and a dropped request all mean "ask again", failed is final), downloads the file to the cache under the patch's name with File.downloadFileAsync, closes the menu and opens the share sheet with video/mp4 through shareFile (RN Share on iOS, so the result and chosen app are recorded; expo-sharing on 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

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions