EuroSnap was a working Telegram bot I implemented to monitor Eurostar Snap availability and send alerts when matching offers appeared.
The bot and website have now been taken offline after Eurostar requested that I stop using the Eurostar name/term in this project. Because of that, I do not recommend cloning this repository to run a commercial service.
If you want to learn from the code or adapt the idea for a personal/non-commercial project, feel free to clone it and study the architecture. The repository is kept as a technical portfolio project and code reference.
This README explains what the project did, how it worked, how the code is organized, and which files controlled the main product behavior.
Last updated:
- 2026-07-01
EuroSnap is a Telegram bot + Firebase backend that monitors Eurostar Snap availability and sends Telegram alerts when matching offers appear.
Main product promise:
- the user chooses a route
- chooses a date
- chooses a time preference
- optionally chooses seat type on supported routes
- optionally sets a max price
- the bot checks Eurostar Snap automatically
- the bot sends an alert with a purchase link when matching availability is found
There is also:
- a Firebase-hosted public website
- a Firebase-hosted admin dashboard
- Stripe billing for paid subscriptions
- a small separate support-bot codebase
- Git version history in a private GitHub repository, with local secrets ignored
- an encrypted backup flow for local secrets
Main stack:
- Firebase Functions v2
- Firestore
- Firebase Hosting
- Telegram Bot API
- Stripe subscriptions
- TypeScript for the main bot/backend
Main codebases:
functions/- the main TypeScript backend
public/- the public marketing site source
- local-only / ignored by git for the current public-safe repository setup
- the live Firebase Hosting site is currently deployed as a temporary offline page
video/- standalone pure-Python ad video renderer (see section 29)
- horizontal 1920×1080 ad for the product ("promo/pub")
- built with Pillow +
ffmpegonly — no browser, no Remotion - currently a work-in-progress we are iterating on together
video-remotion/- fresh Remotion implementation of the EuroSnap promo video
- built from the current
video/render_v4.pyconcept, not from the deleted old Remotion/MCP project - main composition:
EurosnapAd, 1920×1080, 30fps, 750 frames - first three pre-phone scenes are equal length: 2.5 seconds each
- phone scene is 13 seconds with a faster conversation, longer wake, and smooth notification zoom; CTA closes in the final 5 seconds
support_functions_py/- Python Firebase Functions codebase for support tooling
supporteurosnapbot/- local standalone Python support bot using SQLite; useful as reference/legacy support implementation
Project operations / recovery docs in the repo root:
GIT_WORKFLOW.md- how to commit, push, inspect history, restore files, create safe branches, and revert bad commits
SECRETS_BACKUP.md- why raw secrets are intentionally not committed and how encrypted secret backups should be handled
BACKUP_RECOVERY.md- exact commands to decrypt and extract the encrypted backup
Sensitive local-only paths intentionally ignored by git:
.config/.firebase-tools/.local/.tooling/public/secure_backups/
Encrypted backup local-only path:
secure_backups/secrets-backup-2026-04-18.tar.gz.enc
Important:
- the encrypted backup file is NOT committed
- the decryption password is NOT stored in the repository
- the password must live in an external password manager
Hosting / URLs:
- public site:
https://eurosnap-422cf.web.app(currently temporary offline page) - billing entry domain:
https://billing.eurosnapbot.com - checkout entry:
https://billing.eurosnapbot.com/checkout?chatId=... - portal entry:
https://billing.eurosnapbot.com/manage?chatId=...
Main Functions exported from functions/src/index.ts:
checkSnapScheduled- scheduled every 10 minutes
- processes due shared search jobs
- sends 1h reminders
- handles subscription ending-soon reminders
- handles expired premium access
telegramWebhook- receives Telegram webhook updates
- drives the full user conversation flow
adminDashboard- serves the admin dashboard HTML
stripeCheckout- creates a Stripe Checkout Session URL and redirects user
stripePortal- creates a Stripe Billing Portal session URL and redirects user
stripeWebhook- receives Stripe events and updates Firestore / Telegram state
stripePortalReturn- return point after Stripe Billing Portal, used to resync status and redirect back to the bot
Current behavior:
- a new user can use
/start,/help,/support, and open/menu - the user can build an alert flow almost fully
- if the user is not premium, the paywall happens at the moment the alert would actually be created
- the alert draft is stored, but not created yet
- after successful payment:
- the user receives the premium activation message
- then the pending alert is created automatically
- then the normal “alert created” message is sent
This paywall placement is intentional. It was moved later in the flow so new users can understand how the bot works before paying.
If the user has premium access:
- they can create alerts normally
- they can have up to
MAX_ACTIVE_ALERTS = 3active alerts at once - they can manage subscription through Stripe Portal
When a paid subscription truly ends:
- premium access is removed
- active alerts are stopped
- the user is effectively pushed back into the blocked non-premium path
/menushould lead back to the premium-required flow
If the user cancels but the paid period is not over:
- access remains active until the true end date
- the bot sends an immediate cancellation message
- within the last 24 hours before the actual end:
- the bot sends an ending-soon reminder
- on the final end date:
- access is removed
- alerts are stopped
An alert belongs to one Telegram chat and contains:
- route
- date
- time selection
- seat preference
- optional unassigned departure windows
- optional max price
Stored in Firestore collection:
alerts
Main type:
AlertRecordinfunctions/src/types.ts
The bot does not check Eurostar once per user. It groups multiple user alerts into shared jobs.
Important refactor already done:
- old grouping:
route + date + time slot - current grouping:
route + date
Then each user alert is filtered after the fetch by:
- seat preference
- time slot
- unassigned departure windows
- max price
Stored in Firestore collection:
searchJobs
Main type:
SearchJobRecordinfunctions/src/types.ts
Stored in Firestore collection:
telegramUsers
Main type:
TelegramUserRecord
This stores:
- Telegram identity
- whether the user has started
- current pending alert draft
- Stripe IDs and subscription summary
- premium flags
- notification dedupe keys
Routes are configured in:
functions/src/config.ts
Important constants/functions there:
ROUTE_PRESETSROUTE_ORDERlistRouteDirections()parseRouteDirection()routeSupportsUnassignedSeats()getMonitoringWindowDays()createTripConfig()
Current route count:
- 20 directions total
Current routes with explicit unassigned-seat support:
paris_brusselsbrussels_parisamsterdam_brusselsbrussels_amsterdam
Important:
- Not every Snap route supports the
allocated / unassigneddistinction in the bot. - That distinction was enabled only on routes that were confirmed compatible.
- If adding this feature to more routes later, verify the actual Snap page behavior first.
Route kinds:
londoncontinental
This matters because:
- site behavior differs
- currency/market defaults differ
All scheduling logic lives in:
functions/src/policy.ts
The Cloud Scheduler trigger itself runs:
- every 10 minutes
But each route/date is not checked every 10 minutes all the time. The function decides whether a shared job is due.
Current monitoring windows:
- allocated seats on London routes: starts at
J-14 - allocated seats on continental routes: starts at
J-12 - unassigned seats on supported continental routes: starts at
J-21
Current cadence once a source is inside its monitoring window:
J-21toJ-8: every 1hJ-7toJ-1: every 10 min- when a future alert first reaches its opening day, the first check is scheduled at the
03:30probe before daytime cadence resumes
For any seat alerts on routes that support unassigned seats:
- unassigned availability can be checked from
J-21 - allocated availability starts later, at
J-12for continental routes orJ-14for London routes
Important operational detail:
- when a new alert is created and it is already inside the active monitoring window, the first check is scheduled immediately on the next scheduler tick
- after that first run, the alert falls back to the normal cadence above
- active legacy alerts can be resynced to the current
nextEligibleCheckAtby the scheduler if they were created under an older timing policy
Useful-hours policy:
- there is still a day window
- there is also a
03:30night probe - the scheduler wakes every 10 min, but many jobs will be skipped as “not due”
If changing frequency only:
- edit
functions/src/policy.ts - keep
index.tsscheduler frequency unless you intentionally want a platform-level change
This is one of the most subtle product areas.
Seat preference values:
allocatedunassignedany
Type is defined in:
functions/src/types.ts
Behavior:
- uses the normal Snap
searchpage - works with
Morning / Afternoon / Any time - user is treated as having a guaranteed seat
Behavior:
- uses the Snap
unallocatedpage - intended for routes where Snap exposes more precise train data
- user is warned that a seat is not guaranteed
Current UX:
- on supported routes, after date selection the bot asks for seat type
- if user chooses
unassigned, the bot asks for numbered 2-hour departure windows - user replies with one or more numbers, for example:
1,3,5
Configured departure bands in functions/src/config.ts:
- 1 =
06:00-07:59 - 2 =
08:00-09:59 - 3 =
10:00-11:59 - 4 =
12:00-13:59 - 5 =
14:00-15:59 - 6 =
16:00-17:59 - 7 =
18:00-19:59 - 8 =
20:00-21:59
Approximate journey times currently documented for the seat prompt:
- Paris ↔ Brussels: about
1h25 - Amsterdam ↔ Brussels: about
1h55
Important subtlety:
- allocated and unassigned do not come from the same Snap page
- allocated uses
search - unassigned uses
unallocated any seat typemeans the backend may need both sources
There are two provider layers:
functions/src/providers/snapHttp.ts- main HTTP parser
checkSnapHttp()for allocated/search pagecheckSnapUnallocatedHttp()for unassigned page
functions/src/providers/snapHeadless.ts- fallback provider when allocated/search parsing is inconclusive
Important reality:
- the bot does not scrape the exact same final browser view in all cases
- on some routes, Snap search results are slot-based
- on some continental flows, the unallocated page exposes exact trains
Current provider usage in scheduler:
- allocated source if any active alert requires it
- unassigned source if supported route and at least one active alert requires it
- results are then filtered per alert
This filtering happens in:
functions/src/alertMatching.ts
functions/src/alertMatching.ts is critical.
It determines whether a raw provider result truly matches a user alert.
Filters applied:
- seat preference
- unassigned departure windows
- time slot
- max price
Order:
- seat preference
- unassigned departure windows
- time slot
- max price
Important nuance:
- shared jobs fetch broader data
- alert-level filtering is where user-specific behavior actually happens
If alerts seem wrong, this file is one of the first places to inspect.
This is important because the paywall now sits before final creation.
Main files:
functions/src/bot.tsfunctions/src/alertCreation.tsfunctions/src/state.tsfunctions/src/billing.ts
Flow:
- user picks route/date/etc.
- selections are stored in
pendingAlertDraft - if user is premium:
- alert is created immediately
- if user is not premium:
- draft is preserved
- premium-required message is shown
- after payment:
- Stripe activation flow runs
- pending draft is converted into a real alert
This is an intentional product behavior and should not be broken accidentally.
Main file:
functions/src/billing.ts
Other important files:
functions/src/telegram.tsfor billing-related messagesfunctions/src/subscription.tsfor dashboard billing/access categorizationfunctions/src/index.tsfor the HTTP endpoints and scheduler hooks
Key ideas:
- Firestore is not the billing source of truth
- Stripe is the billing source of truth
- Firestore stores a local summary used by the bot and dashboard
Main stored user billing fields:
stripeCustomerIdstripeSubscriptionIdsubscriptionStatussubscriptionCancelAtPeriodEndsubscriptionCancelAtsubscriptionEndedAtsubscriptionAmountMinorlastInvoiceAmountPaidpromoCodepremiumUntilisPremiumpremiumBypassEnabled
Important helper logic:
hasPremiumAccess()getEffectiveSubscriptionEndIso()handleExpiredPremiumAccesses()handleSubscriptionsEndingSoon()reconcilePremiumAccess()processStripeWebhook()
Important Stripe details already supported:
- monthly subscription
- live/test separation handled by env secrets and
STRIPE_PRICE_ID - cancel at period end
- renew before end
- ending-soon reminder in last 24h
- end-of-subscription removal
- promo code support
- portal return sync
Current promo:
taktik- configured to be first-time only
Billing custom domain:
- public user-facing billing links use
billing.eurosnapbot.com - internal webhook still uses the Cloud Function URL
These are easy to break and worth remembering:
premiumBypassEnabled gives access without Stripe.
Used for manual/admin testing or free access users.
This affects:
hasPremiumAccess()- dashboard user categories
- whether expiration logic applies
When subscription ends:
isPremiumis removedhasStartedis reset tofalsependingAlertDraftis cleared- alert-related last state is cleared
- active alerts are stopped
This is meant to make the user behave like a blocked user again.
Reminder is sent only when:
- user still has access
- user is scheduled to end
- effective end is within 24h
- reminder has not already been sent for that end timestamp
This logic uses:
premiumUntilsubscriptionCancelAt- dedupe keys on the user record
All user-facing copy and keyboards are centralized in:
functions/src/telegram.ts
This file contains:
- text formatting helpers
- all bot messages
- all inline keyboards
- Telegram API send helpers
- premium/billing copy
- seat/time prompts
- offer notification formatting
If the user asks:
- “change wording”
- “add emoji”
- “bold this”
- “change buttons”
then functions/src/telegram.ts is usually the first file to edit.
Important:
- many strings are part of tested flows
- after message changes, run tests
- richer prompts and richer alert/detail messages now rely on Telegram
HTMLparse mode, not legacy Markdown - if bold text appears raw in Telegram, check the corresponding
sendTelegramMessage(...)call and confirm it passes parse mode"HTML"
Main file:
functions/src/bot.ts
This file is the state machine of the Telegram product.
It handles:
/start/menu/help/support/reset- text input parsing
- callback query parsing
- when to ask date / seat / slot / price
- when to enforce paywall
If the user asks:
- “change the user flow”
- “move the paywall”
- “change where a question appears”
- “change how menu pages work”
then bot.ts is the main entry point.
Important recent UX detail:
- prompts that contain HTML emphasis such as
<b>...</b>must be sent with Telegram parse mode"HTML" - if Telegram shows raw tags instead of bold text, inspect the exact
sendTelegramMessage(...)call inbot.tsorindex.ts
Main endpoint:
functions/src/admin.ts
Data snapshot building:
functions/src/state.tsfunctions/src/subscription.tsfunctions/src/usage.ts
The dashboard includes:
- users
- alerts
- shared jobs
- KPI cards
- billing/access categories
- filtering
- grouped shared jobs behavior
- counts that update when filters change
Important user categories:
free_accesspaid_subscriptionpromo_free- access grouped as:
- active renewing
- active canceling
- inactive
Recent simplification intent:
- the dashboard should stay practical, not overly technical
- many internal categories are collapsed for readability
If changing dashboard behavior:
- UI and rendering are mostly in
admin.ts - filtering logic is split between
admin.ts,state.ts, andsubscription.ts
Main collections:
telegramUsersalertssearchJobs
Support collections in Python support function:
supportUserssupportTicketssupportMessageLinks
General rule:
alertsare per-usersearchJobsare shared scheduler unitstelegramUsersstores user identity + entitlement + pending state
functions/src/index.ts- exported Functions and scheduler processing pipeline
functions/src/types.ts- global types and record shapes
functions/src/config.ts- route list, route capabilities, trip keys, monitoring window basics, seat bands
functions/src/policy.ts- monitoring cadence and useful-hours logic
functions/src/state.ts- Firestore reads/writes and snapshot building
functions/src/alertCreation.ts- pending draft evaluation and real alert creation
functions/src/alertMatching.ts- raw provider result filtering per alert
functions/src/bot.ts- Telegram conversation flow
functions/src/telegram.ts- messages, keyboards, Telegram API helper calls
functions/src/billing.ts- Stripe integration and premium lifecycle
functions/src/subscription.ts- dashboard billing/access categories and billing summaries
functions/src/admin.ts- admin dashboard HTML and request handling
functions/src/usage.ts- usage/admin metrics helpers
functions/src/providers/snapHttp.ts- HTTP parsing for search and unallocated Snap pages
functions/src/providers/snapHeadless.ts- headless fallback when HTTP allocated parsing is inconclusive
The public/ folder exists locally but is intentionally ignored by git in the current GitHub setup.
Do not push website source unless this policy changes.
public/index.html- main marketing site
public/logo.png- favicon / site icon currently in use
public/eurosnap-logo.png- additional logo asset
firebase.json- functions codebases
- hosting setup
/manageand/checkoutrewrites
firestore.rulesfirestore.indexes.json
STRIPE_COMMERCIAL_CHECKLIST.mdHOW_TO_USE_STRIPE.md
support_functions_py/main.py- Python Firebase support function
supporteurosnapbot/supporteurosnapbot.py- standalone support bot with SQLite; useful reference but separate from main bot
Test framework:
- Vitest
Command:
npm -C functions testBuild command:
npm -C functions run buildKey test files:
functions/src/bot.test.tsfunctions/src/telegram.test.tsfunctions/src/billing.test.tsfunctions/src/billing-flow.test.tsfunctions/src/alertMatching.test.tsfunctions/src/state.test.tsfunctions/src/config.test.tsfunctions/src/policy.test.tsfunctions/src/subscription.test.tsfunctions/src/providers/snapHttp.test.tsfunctions/src/usage.test.ts
Whenever changing:
- route logic
- seat logic
- price logic
- billing flow
- dashboard categorization
run at least:
- build
- full test suite
From repo root:
npx firebase-tools deploy --only functionsOr target specific functions:
npx firebase-tools deploy --only functions:telegramWebhook,functions:checkSnapSchedulednpx firebase-tools deploy --only hostingImportant subtlety:
- Hosting has rewrites pinned to Functions:
/manage->stripePortal/checkout->stripeCheckout
- so a hosting deploy may also involve pinned function behavior in Firebase tooling
Defined Firebase secrets:
TELEGRAM_BOT_TOKENADMIN_DASHBOARD_TOKENSTRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRET
Important env/config expectations:
STRIPE_PRICE_ID- optional
APP_BASE_URL - optional
BILLING_BASE_URL - optional
TELEGRAM_BOT_URL - optional
HEADLESS_FALLBACK_ENABLED - optional Eurostar gateway/base/product-family settings
Do not store secret values in this README.
These are intentional and should not be changed casually:
- max active alerts per user =
3 - scheduler frequency = every
10 min - allocated seats on London routes monitor from
J-14 - allocated seats on continental routes monitor from
J-12 - unassigned seats on supported continental routes monitor from
J-21 - monitored sources use
1hcadence from their opening day toJ-8 - all routes use
10 mincadence fromJ-7toJ-1 - non-premium users can explore flow before paywall
- paywall happens when creation would happen
- after payment, pending alert is created automatically
- dashboard should stay simple and operator-friendly
- only confirmed routes expose unassigned seat type
Start with:
functions/src/config.ts
Then verify:
- menu order
- route support flags
- market/currency behavior
- provider parsing still works
Edit:
functions/src/policy.ts
Usually do not change:
checkSnapScheduledcron inindex.ts
Inspect:
functions/src/bot.tsfunctions/src/billing.tsfunctions/src/alertCreation.ts
Edit:
functions/src/telegram.ts
Inspect:
functions/src/admin.tsfunctions/src/state.tsfunctions/src/subscription.ts
Inspect:
functions/src/billing.tsfunctions/src/index.tsSTRIPE_COMMERCIAL_CHECKLIST.mdHOW_TO_USE_STRIPE.md
Inspect:
functions/src/alertMatching.tsfunctions/src/providers/snapHttp.ts- sometimes
functions/src/config.ts
This matters a lot.
searchKey- shared grouping key
- currently based on
route + date
tripKey- alert-specific key
- includes user-facing filters such as:
- time slot
- seat preference
- unassigned departure bands
Breaking this distinction can break deduplication and scheduler sharing.
There are two layers of timing state:
- the alert document
- the shared
searchJobdocument
Changing cadence logic in policy.ts is not enough by itself if old active jobs already have stale nextEligibleCheckAt values.
Relevant code paths:
syncSearchJobForSearchKey()infunctions/src/state.tsbackfillUnstartedSearchJobs()infunctions/src/state.ts- scheduler entry in
functions/src/index.ts
If production timing looks wrong after a policy change:
- inspect
searchJobs.nextEligibleCheckAt - inspect
alerts.nextEligibleCheckAt - inspect recent
checkSnapScheduledlogs
The bot currently stores and filters numbered departure windows for unassigned alerts.
This is not the same as general Morning / Afternoon.
The dashboard intentionally simplifies some categories for readability. Firestore may contain richer internal state than the dashboard exposes.
Removing premium is not just isPremium = false.
There is also user UX reset and active alert stopping.
Many small copy changes are harmless, but changes to prompt order can affect:
- pending draft logic
- callback patterns
- tests
- Stripe post-payment continuation
These are not necessarily bugs, but useful engineering notes:
functions/src/alertMatching.tsduplicates the unassigned time band ranges instead of importing them from config- support system exists in two forms:
- Firebase Python function
- standalone local SQLite bot
- future cleanup could reduce duplication if desired
- deeper route-specific Snap parsing may still evolve
- new scheduler/backfill queries should be checked carefully against existing Firestore composite indexes before deploying
The main backend files are:
config.ts- routes and shared identifiers
policy.ts- when checks are allowed
state.ts- Firestore persistence
bot.ts- Telegram conversation flow
telegram.ts- all user-facing messages
alertCreation.ts- turn draft into alert
alertMatching.ts- decide whether an offer matches
billing.ts- Stripe + premium lifecycle
index.ts- scheduled runner and HTTP entry points
admin.ts- dashboard
This repository is already more than a simple bot script. It is a small product backend with:
- entitlement logic
- shared workload optimization
- billing
- admin operations
- a public landing page source kept locally
So changes should be made with care:
- preserve the product flow
- avoid breaking dedupe logic
- avoid breaking Stripe lifecycle behavior
- avoid accidentally regressing dashboard clarity
This folder holds a standalone promo/ad video renderer for EuroSnap.
It is completely independent from the bot, the Firebase Functions, and telegram-ad-video-mcp/.
It is a separate pure-Python pipeline: Pillow draws frames, ffmpeg encodes them into MP4.
video/render_v4.py- the current renderer, v4
- everything (frames, compositing, scenes, output) lives in this one file
High level:
- renders 600 PNG frames at
1920×1080, 30fps (20 seconds total) - calls
ffmpegto encode those PNG frames into a single MP4 - no browser, no Remotion, no Node, no HTML — just Python +
ffmpeg
ip(f, a, b, va, vb, ease)- clamped animation interpolation used by everything that moves
- applies a cubic-bezier easing, default
EXPO = (0.16, 1.0, 0.3, 1.0)— fast out, Apple-style deceleration
mask_up(base, txt, font, col, cx, clip_y, clip_h, p)- signature motion design primitive
- text is drawn on a temporary image, then only a vertical band
[clip_y, clip_y + clip_h]is pasted back - text enters by sliding up from below the invisible band → produces the classic
overflow:hidden + translateYreveal
_draw_phone(canvas, lf, py)- draws the entire Telegram UI (phone shell, screen, header, chat bubbles, keyboard buttons, notification) directly onto an RGB canvas
- intentionally NOT RGBA — previous versions used
surf.putalpha(clip)which overwrote drawn pixels; v4 avoids that by drawing on RGB and doing the fade-in withbld(img, phone_canvas, phone_a)between two RGB images
Each scene has its own render_sN(lf) function receiving a local frame index lf that resets to 0 at the start of the scene.
render_frame(gf) translates the global frame index into a local one and handles 15-frame crossfades between scenes via bld().
Durations in video/render_v4.py:
S1, S2, S3, S4 = 60, 120, 330, 135FD = 15- derived starts:
G1=0, G2=45, G3=150, G4=465 TOTAL = 600(= 20.0s at 30fps)
The three intro phrases are pinned to ~2s each of perceived on-screen time (P1 gf 0–59, P2 gf 45–104, P3 gf 104–164), and the phone is fully visible at gf=165 = 5.5s.
| Scene | Global frames | Duration | Content |
|---|---|---|---|
| S1 | 0–59 | 2.0s | Headline: "Tired of missing Eurostar Snap deals?" |
| S2 | 45–164 | 4.0s | Two-phase headline: Phase A "Stop checking for tickets all day." then Phase B "We found the solution for you." — each phrase is ~2s perceived, with a clean fade handoff at f45/f60 (no overlap) |
| S3 | 150–479 | 11.0s | Animated iPhone (Telegram) with a four-sided caption layout and a symmetric top/bottom gap (PY_FINAL = 128, bottom caption clip at y=1010..1058): "Welcome to Eurosnap." above the phone, a bigger/bolder "The telegram bot that monitors Eurostar Snap deals in real time." below (F_CAP_BOT = MEDI 40), "You set your / dates, route / & price." stacked on the LEFT with a staggered reveal (f110-148) once the chat is already moving, and "and get / notified / as soon as a / matching deal / appears." stacked on the RIGHT (reveals at f200-250, ~25f earlier than before) after the phone has turned off. The bot is named "Eurosnap" in both the chat header and the lockscreen notification card. The chat now uses realistic multi-choice keyboards: Route offers 3 buttons ("Paris to London" picked |
| S4 | 465–599 | 4.5s | €1.99 price, feature bullets, CTA (retimed to 135 frames for a punchier end: accent f0–8, price/sparkle f4–28, bullets f30–76, CTA f80–104, ~30f hold) |
The phone is fully visible (end of S2→S3 crossfade) at global frame 180 = 6.0s, matching the intended "phone arrives on second 6" pacing.
Audio is generated procedurally at encode time by _synthesize_audio in video/render_v4.py. It builds a louder pub-tech stem with:
- a 4-on-the-floor kick at 120 BPM (60 Hz sine burst, exponential decay retriggered every 0.5s via
aevalsrc), - a hi-hat (
aevalsrcwhite-noise gated every 0.25s through a 7.5 kHz highpass) for motion, - a quiet A-minor pad (220 / 277.18 / 329.63 Hz sines with a slow tremolo) as the harmonic bed, and
- a bright Telegram-style bell pop — two overlapping tones (C7 2093 Hz + G6 1567 Hz, ~40ms stagger, bell envelope) — fired at 13.50 s, exactly when the lockscreen starts waking on the phone.
The whole mix runs through an alimiter at -0.5 dBFS so nothing clips after the boost. The final ffmpeg call mixes the video frames with the generated WAV using -c:a aac -b:a 160k -shortest. If audio synthesis fails for any reason, the encode automatically falls back to a silent MP4.
Top docstring in video/render_v4.py mirrors this breakdown; keep both in sync when retiming.
The script now uses paths relative to the video/ folder:
- frames:
video/frames/frame_0000.pngthroughvideo/frames/frame_0599.png - audio:
video/out/audio.wav - final MP4:
video/out/eurostar_snap_bot_v4.mp4 - fonts:
video/fonts/Poppins-*.ttf
Required local tooling:
python3withPillowffmpeg, resolved byrender_v4.pyin this order:- bundled
imageio-ffmpegundervideo/.pydeps imageio-ffmpegon the current Python path- system
ffmpegonPATH
- bundled
Typical run:
python3 video/render_v4.pyThis will:
- write 600 PNGs into
video/frames - synthesize
video/out/audio.wav - spawn
ffmpegto encodevideo/out/eurostar_snap_bot_v4.mp4
More renderer-specific details are documented in video/README.md.
The ad video is under active iteration. When editing render_v4.py:
- keep each scene's timing table in the docstring in sync with the real
ip(lf, a, b, ...)calls, otherwise the scene comments will drift from the actual animation - when editing
_draw_phone, keep everything on an RGB surface; do NOT reintroduceputalphaon surfaces where text was already drawn, that was the v3 bug - when changing global scene lengths, update both the
S1, S2, S3, S4constants AND the scene table in section 29.4 of this README