This is a Next.js project bootstrapped with create-next-app.
First, run the development server:
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun devOpen http://localhost:3000 with your browser to see the result.
You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.
Run npm run monitoring:alerts as a separate long-running Node process alongside
the Next.js app and the BullMQ workers. This dedicated process periodically
refreshes the reframe and dubbing queue-depth gauges, reads shared Redis-backed
latency and final-outcome windows, and invokes the existing alert evaluator.
Alerts are emitted as structured process logs; this command does not send
external notifications.
Configure the thresholds, observation window, cooldown, and
MONITORING_ALERT_EVALUATION_INTERVAL_SECONDS in the process environment. The
interval must be a positive whole number of seconds. Evaluation runs once at
startup and then serially after each configured interval; a slow or failed
evaluation does not overlap the next one or stop later retries. Configure
exactly one monitoring process replica because cooldown state is process-local.
Latency is aggregated into minute buckets in Redis using only pipeline, outcome status, and time bucket in keys/fields; individual job or user identifiers are not stored. The local Prometheus histograms remain process-local for exposition. Redis bucket boundaries make latency and BullMQ failure-rate windows approximate by up to one minute. Missing latency history, insufficient failure-rate history, or failed queue collection is skipped rather than treated as a firing condition.
This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.
AVS turns a recorded demo into an AI-voiced, time-synced, subtitled video. It is PRO/ENTERPRISE only and ships behind a feature flag, so it is completely invisible and no-op when the flag is off — existing (non-AVS) demos and exports are never affected.
Open a saved demo in the editor, switch to the AI Voice sidebar tab, and use Generate AI Voiceover Demo to run the full pipeline in one click:
- Steps — the demo is auto-sliced into steps from the Chrome-extension click-capture timestamps (falling back to one full-length step if there are no clicks). Split / merge / adjust boundaries in the panel timeline.
- AI script — per-step narration is seeded from the Deepgram transcript and rewritten into a tone (Sales / Onboarding / Support / Marketing) via OpenAI.
- Voiceover — the script is synthesized into one continuous MP3 with Deepgram Aura TTS in the Cloud Run worker, recording each step's timing.
- Captions — the voiceover is transcribed and stabilized into flicker-free
captions (also exportable as
.vtt). - Time-alignment — a pre-pass freeze-frames video where the audio is longer than its step and pads silence where it is shorter, muxing the voiceover into one aligned source MP4.
- Export — the aligned source (AI voice already muxed in) plus the stabilized captions are fed into the existing export route, so the final render carries the AI voice, freeze-frame timing, and clean captions.
Every stage persists to Demo.editing.avs via the normal autosave (there is no
DB migration — all AVS state lives in the existing editing JSON). Each stage
also has its own editor in the panel for fine-tuning before or after a full run.
AVS keys are server-side only — never expose them with a NEXT_PUBLIC_
prefix.
| Variable | Where | Purpose |
|---|---|---|
AVS_ENABLED |
Next app (server) | Master switch for the AVS server routes. Set to true to enable. |
NEXT_PUBLIC_AVS_ENABLED |
Next app (client) | Shows the "AI Voice" sidebar panel. Set to true to enable. |
OPENAI_API_KEY |
Next app (server) | OpenAI script tone rewrite (/api/avs/script). |
GCP_VIDEO_WORKER_URL |
Next app (server) | Reaches the Cloud Run worker for Aura TTS, subtitles, and alignment. |
WORKER_SECRET |
Next app + Cloud Run worker | Required shared bearer secret for every worker processing request. |
DEEPGRAM_API_KEY |
Cloud Run worker only | Deepgram transcription + Aura TTS. The Next app never holds this key. |
To enable AVS for a staging/QA environment, set both AVS_ENABLED=true and
NEXT_PUBLIC_AVS_ENABLED=true there (plus OPENAI_API_KEY and
GCP_VIDEO_WORKER_URL), and make sure the worker has DEEPGRAM_API_KEY.
Configure the same non-empty WORKER_SECRET in both the Next app and Cloud Run
worker in every environment that processes jobs. Keep AVS feature flags disabled
in production until the feature is signed off. See .env.example for the full list.
WTM brands an exported demo: a corner watermark on every export, and an optional circular webcam bubble composited into the video. It ships behind a feature flag and is a complete no-op when the flag is off — including the free-tier watermark, so existing exports are unchanged until enablement.
| FREE / anonymous | PRO / ENTERPRISE | |
|---|---|---|
| Watermark | Forced Marvedge badge (bottom-right, 55% opacity). Cannot be customized or removed. | Custom PNG, adjustable opacity + corner, or switched off entirely. |
| Camera bubble | Recorded and configurable, but not composited into the export. | Composited into the export. |
Recording the camera is free for everyone — creation features are not gated. The
gate sits at export: app/api/jobs/create/route.ts re-resolves the watermark
from the user's real plan (so a FREE user gets the badge no matter what the
client sends), and /api/wtm/composite rejects a non-PRO bubble.
- Record — the recorder offers a camera toggle with a live preview. With it
on, a second
MediaRecordercaptures the camera video only (the screen recording already carries mic/tab audio, so muxing the camera too would double it), uploads the clip, and stores its URL. - Arrange — the editor's Branding sidebar tab places and sizes both
overlays, and the editor preview draws them over the video card so what you
arrange is what gets baked in. Preview and export share their corner math and
margins via
app/lib/wtm/geometry.ts. - Composite (pre-pass) — on export, if a camera clip exists,
/api/wtm/compositeruns the Cloud Run worker's/wtm-compositefirst: it normalizes both inputs to 30 FPS, center-square crops the camera (so a non-16:9 webcam is cropped, not squished), applies a circular alpha mask, and overlays it in the chosen corner, producing one composited MP4. Original audio is passed through untouched. - Export — that composited MP4 becomes the source for the existing chunked export, which applies trim / zoom / background / text / subtitles and the watermark on top. The pre-pass never fails an export: no clip, a non-PRO user, or a worker error all fall back to the original source with a toast.
All WTM state persists to Demo.editing.wtm (watermark + webcam) through the
normal autosave — there is no DB migration.
| Variable | Where | Purpose |
|---|---|---|
WTM_ENABLED |
Next app (server) | Master switch for the watermark render/plan logic and the composite route. Set to true to enable. |
NEXT_PUBLIC_WTM_ENABLED |
Next app (client) | Shows the "Branding" sidebar panel and the preview overlays. Set to true to enable. |
GCP_VIDEO_WORKER_URL |
Next app (server) | Reaches the Cloud Run worker for the compositing pre-pass. |
WTM_COMPOSITE_FPS |
Cloud Run worker only | Frame rate both inputs are normalized to. Optional, defaults to 30. |
WTM_COMPOSITE_PREFIX |
Cloud Run worker only | GCS prefix for composited sources. Optional, defaults to wtm-composite/. |
WTM needs no new vendor or API key — it is pure ffmpeg on the existing GCS /
Cloud Run worker. Both flags are enabled on staging/QA; leave them blank in
production until the feature is signed off. See .env.example for the full list.
Every share link can also be a branded QR code: deep-purple rounded modules on
white with the Marvedge mark at the centre, offered wherever a share URL already
exists. It is purely derived — the QR renders a link that already exists, mints
nothing, writes nothing, and never touches the export path. No DB migration, no
new vendor, no new API key. The only dependency is qrcode-generator, a
zero-dependency matrix library.
| Surface | What appears |
|---|---|
app/components/ExportResultModal.tsx |
The QR, shown as soon as a shareUrl exists — the "after the demo is generated" moment. |
app/components/ShareModal.tsx |
The same QR, collapsed behind a "Show QR code" toggle so the modal does not balloon. |
app/hub/[domain]/share/[slug]/page.tsx |
A "scan to open" card on the customer hub share page, encoding the customer's own domain. |
GET /api/qr?url=…&size=… |
A cacheable image/svg+xml render, for the places a React component cannot reach (an <img> in an email, a slide, a PDF). |
The demos list (app/(signed)/demos/) has no share affordance at all today,
even though app/api/demos/[id]/share/route.ts exists and works. That gap is
noted rather than filled here — a QR belongs next to a share button, and building
the share flow is its own change.
Returns SVG with Cache-Control: public, max-age=31536000, immutable — the output
is a pure function of the query. Unauthenticated, because it renders a link that
is already public, and size is the only knob besides url. There is
deliberately no style param.
The url must resolve to a Marvedge-owned share URL or the route answers 400.
Allowed: the NEXT_PUBLIC_APP_URL origin, the root domain plus www and any hub
subdomain, the host the request itself arrived on (which is how a custom hub
domain validates, without a DB lookup), and loopback off production. Everything
else is refused, including look-alikes such as marvedge.com.evil.example and
credential tricks such as https://marvedge.com@evil.example/. The path must also
look like a share path, so the endpoint cannot brand /auth/signin.
Two things about that check are easy to get wrong:
sanitizeQrOptions()/toQrTargetUrl()are not a host check. They validate scheme, length and shape only, and deliberately not the host — the engine is isomorphic and cannot know the request origin. The allowlist inapp/lib/share/qrTarget.tslayers on top of them. Rendering an arbitrary URL inside a Marvedge-branded QR is a phishing primitive; there is no "allow any URL" escape hatch and adding one re-opens it.- The route never queries the database. A public unauthenticated endpoint that 404s on unknown slugs is an oracle for which share ids exist, so validation is by URL shape only.
Custom domains. app/api/demos/[id]/share/route.ts builds share URLs as
NEXT_PUBLIC_APP_URL || request.nextUrl.origin, which is right for a creator
copying a link on marvedge.com. It is wrong on a customer hub: a visitor reading
https://demos.acme.com/share/abc is served by /hub/acme/share/abc through the
middleware.ts rewrite, and a QR encoding marvedge.com/share/abc would walk them
off the customer's white-labeled domain mid-scan. Hub pages therefore resolve the
origin from the request host and ignore NEXT_PUBLIC_APP_URL — see
hubShareUrl() in app/lib/share/qrTarget.ts, which carries the rule and its
tests.
The URL encoded in the QR carries ?src=qr; the copy-to-clipboard link stays
clean, so a pasted link is never miscounted as a scan. The decoration lives in one
helper (withQrSource()), applied at the two places a QR is produced — the client
renderer and /api/qr — and is read back by
app/share/[slug]/hooks/useViewTracking.ts, which passes source: "qr" to
/api/views.
That source is logged, not stored. model View has no column for it and there
is no events table, so persisting it would need a prisma/schema.prisma change and
a migration, which this feature deliberately does not carry. Scan volume is
visible in the server logs today ([Views] QR scan: …), and the client already
sends the field — adding the column later is a one-line change in
app/api/views/route.ts, with history from that day forward.
QrStyle is "badge" | "branded" and only badge is surfaced. There is no
style toggle, no style prop and no style query param, and adding one is a product
decision rather than a UI improvement. branded (the mark drawn large and tinted
behind the modules) stays in the engine and stays covered by qr.test.ts, but
nothing offers it. The reason is not scannability — both were phone-verified at
200 px. Badge won on mark legibility: it draws the mark solid #2D1F61, while
branded's 22% tint reads as a smudge once the URL pushes the code past ~40
modules. The point of the feature is that every scan carries the mark.
These invariants are enforced in app/lib/qr/ and covered by its tests. They are
not style preferences — break one and codes silently stop scanning for some
fraction of cameras, which no visual review catches:
| Rule | Why |
|---|---|
ECC level H (30% recovery), always |
Buys the error budget the logo knockout and rounded modules spend. |
| Quiet zone ≥ 4 modules | Scanners fail without it far more often than for any styling reason. |
| Logo occlusion ≤ 25% linear (≈6% of area) | Well inside the H budget, with room left for print and camera noise. |
| Finder patterns stay solid dark-on-light | Rounding their corners is safe; tinting, occluding, or backing them with art is not. |
| Timing patterns (row/col 6) never occluded | The knockout must stay centred and small enough never to reach them. |
| Contrast ≥ 4:1 between module and light cell | Checked in code (assertQrContrast), not by eyeballing. |
If a change makes an assertion in app/lib/qr/qr.test.ts fail, the change is
wrong — do not edit the test to match.
public/qr/marvedge-mark.png is generated, not hand-edited. scripts/qr/make-mark.mjs
derives it from the source logo at build time: it chroma-keys the flat periwinkle
field out, recolours the mark to #2D1F61, trims and re-centres it, and writes
both the PNG and app/lib/qr/mark.ts, which inlines the same bytes as a data:
URI. The engine uses the constant, never the file path — a remote src would taint
the canvas the client-side PNG export draws into and toBlob() would throw. To
change the mark, edit the script and re-run it.
dither: 0in that script is load-bearing. sharp's PNG palette encoder dithers by default, which scatters off-hue pixels through what should be one flat colour and quietly breaks the "art and modules share one colour" invariant.
- Check the size it is rendered at. Below roughly 4 px per module a camera cannot resolve it. A long URL means a higher QR version, more modules, and a larger minimum size — shorten the URL before shrinking the code.
- Check the quiet zone survived. A CSS
overflow: hidden, a tight flex container, or a crop that clips the white border is the single most common cause. The engine emits it; a layout can still cut it off. - Check contrast end to end. The engine guarantees ≥ 4:1 for the colours it is given, but a parent with a coloured or textured background showing through a transparent container defeats it. The QR needs an opaque light ground.
- Check nothing was overlaid on it. A badge, a caption, or a hover effect on top of the finder or timing patterns breaks decoding even when it looks fine.
- Check the target URL actually resolves. A QR for an unshared or deleted demo
scans perfectly and then lands on a 404 —
/api/qrvalidates URL shape, never existence, by design. - Only then suspect the engine. Run
npm test; the QR suite rasterizes and decodes real codes across versions.
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_SHARE_QR_ENABLED |
Next app (client + server) | Kill-switch for the whole QR surface. Defaults to ON when unset — only an explicit false or 0 disables it. |
NEXT_PUBLIC_APP_URL |
Next app | Already required. The QR endpoint's primary allowed origin. |
NEXT_PUBLIC_ROOT_DOMAIN |
Next app | Already used by middleware.ts. Also allows hub subdomains of it. Defaults to marvedge.com. |
NEXT_PUBLIC_SHARE_QR_ENABLED deliberately defaults on, unlike the AVS and WTM
flags above, which default off. Those change the artefact — with WTM on, a FREE
export gets a watermark burned into the video — so "unset" has to mean "behave
exactly as before". The QR surface is derived, read-only and additive, and leaves
no state behind, so its flag is a kill-switch for pulling the surface during an
incident rather than a rollout gate. With it off, <ShareQrCode /> renders nothing
and /api/qr returns 404. Note the value is inlined into the client bundle at
build time: flipping it needs a rebuild for the client, only a restart for the
server.
Part of Interactive Video Overlays (issue #302 §2.3). A viewer can book a meeting from a Calendly or HubSpot Meetings calendar inside the video canvas, without leaving the share page. Configured per demo from the editor's Overlays panel; free on every plan.
Behind OVERLAYS_ENABLED + NEXT_PUBLIC_OVERLAYS_ENABLED, both default off.
This is the only place in the app that puts a third party's document inside our
page, so the host allow-list — app/lib/overlays/schedulingHosts.ts — is the
deliverable, not plumbing. calendly.com and meetings.hubspot.com, plus their
subdomains, https only, and it is enforced in three independent places:
- On save.
PUT /api/demos/[id]/overlaysrefuses an off-list host with a 400 that names the allowed ones, so a bad URL never reaches the database. - On render.
buildSchedulingEmbedUrl()re-runs the check before producing aniframesrc, so a row that never passed through the save path — hand-edited, restored from a backup — still cannot be framed. - In the CSP.
frame-srcis derived from the same constant (see below).
The matching is by full hostname: exact equality or a dot-anchored suffix,
never includes() and never a bare endsWith(), both of which wave through
calendly.com.evil.example. The parser also infers nothing — an input must
already say https://, which is what rejects a bare host and a protocol-relative
//calendly.com/x. The hostile-input suite is in schedulingHosts.test.ts.
The allow-list is deliberately not owner-editable. An owner who could add a host could frame a credential-harvesting page inside a video carrying their own customer's branding.
next.config.ts sets the app's first Content-Security-Policy, scoped to
/share/:path* and /hub/:path* only. It is not global: /video-editor runs
ffmpeg.wasm and inline workers, and a blanket policy breaks it on first load.
Both path families are listed because of the customer-domain case — a viewer on
demos.acme.com requests /share/<slug> and middleware.ts rewrites it to
/hub/<domainKey>/share/<slug>, so matching only one spelling would leave the
branded route unprotected. /api matches neither source and is untouched.
Two honest caveats:
- It is not XSS protection.
script-srccarries'unsafe-inline'because Next's App Router inlines its bootstrap and flight-data scripts; removing it needs per-request nonces threaded through middleware. frame-ancestorsis absent. Customers embed share links in their own pages today and there is no allow-list of their domains, so adding a restrictive one would silently break existing embeds.
Neither provider needs a script-src entry: both are embedded as a bare iframe
URL, never their widget script, so the share page still loads zero third-party
JavaScript.
When the PR 3 lead gate captured a name and email in this page session, they
are passed to the provider's prefill params — name/email for Calendly,
firstName/lastName/email for HubSpot.
Only after consent, and only fields the viewer actually typed. A returning viewer
recognised by their mv_sid cookie but who has typed nothing today reaches the
widget with an empty form, and that is correct rather than a limitation: consent
was given for us to make contact, and handing those details to a third party is
a fresh disclosure that only this session's own submit authorises. The lead never
leaves the page component's memory — not localStorage, not any URL but the
consented prefill.
Calendly works. The embed posts a documented calendly.event_scheduled
message, checked against the embed URL's exact origin, and a meeting_booked
PlayerEvent is emitted once per mount. Nothing from the message body goes into
the event's meta — a Calendly payload carries the invitee's name and email.
HubSpot is not reliable. There is no supported success message for the bare
meetings iframe. The code matches an observed shape (meetingBookSucceeded),
which HubSpot may change without notice, so a HubSpot booking may produce no
meeting_booked event at all. Nothing downstream — the PR 7 funnel included —
may read the absence of this event as evidence that no meeting was booked. Closing
this properly needs the HubSpot API rather than a postMessage, which is out of
scope here.
| Variable | Where | Purpose |
|---|---|---|
OVERLAYS_ENABLED |
Next app (server) | Master switch: resolves overlay config on the share routes and mounts the overlay API. Set to true to enable. |
NEXT_PUBLIC_OVERLAYS_ENABLED |
Next app (client) | Renders the player's overlay layer and the "Overlays" sidebar panel. Set to true to enable. |
The CSP is not behind either flag, deliberately. Next evaluates headers() at
build time and bakes the result into routes-manifest.json, so a flag-gated policy
would really be a build-time gate — and flipping OVERLAYS_ENABLED in a deployed
environment without a rebuild would turn the iframe on while leaving the header
that bounds it silently absent. Every directive was checked against what the share
page loads today, so it is a no-op for a flag-off page.
Part of Interactive Video Overlays (issue #302). Three things the person paying for the feature actually looks at: the conversion funnel, the leads themselves, and the retention that keeps both sustainable.
PlayerEvent is append-only and high-volume — a video_start, a gate_shown, a
cta_click and a video_completed for a single viewing. The analytics page
never touches it. It reads PlayerEventDaily, one row per
(demoId, name, date), so the page costs the same on day 1000 as on day 1.
The funnel stages are video_start -> gate_shown -> lead_submitted -> cta_click -> video_completed, shown in aggregate and per demo over a 30-day window.
Stages are counted independently and are not a strict subset chain. A demo with no lead gate records starts and CTA clicks but no gate views; clicking a branching card navigates the viewer away before the video completes. So a later stage can exceed an earlier one, conversion is clamped to 0-100%, and a stage whose predecessor had no events shows a dash rather than a fabricated ratio.
Days are UTC. A row's day is the UTC calendar day of its timestamp, never the server's local day and never the viewer's — dating by the reader's timezone would mean the same event rolls up to different days for different readers, and a timezone change would silently rewrite history. The panel says "UTC" on screen.
There is no scheduler in this repo (locked decision 10 — no queue, no cron service). The rollup is an endpoint; point whatever the deployment already uses at it — Vercel Cron, Cloud Scheduler, a GitHub Action, or a shell:
curl -X POST https://<host>/api/v3/events/rollup -H "x-marvedge-rollup-secret: $OVERLAYS_ROLLUP_SECRET" -H "content-type: application/json" -d '{"date":"2026-09-01"}'dateis optional and defaults to yesterday (UTC), the last day that is certainly complete. Rolling up today is legal and gives a partial count that the next run overwrites.{"skipRetention": true}runs the rollup alone. Use it when backfilling a month of days one at a time, so the sweep does not run thirty times.- The secret travels in a header, never a query parameter — query strings
land in access logs, proxy logs and
Refererheaders, and this secret authorises row deletion. It is compared withtimingSafeEqual. - With
OVERLAYS_ROLLUP_SECRETunset the endpoint answers 503, not 200: a maintenance route that deletes rows fails shut when nobody has configured who may call it. - Idempotent. Every write is an upsert that sets the count rather than incrementing it, so a retried cron or two overlapping schedulers land exactly the same numbers. Re-running a day after a bug fix is safe.
Both windows run from the same endpoint, after the rollup for that date has
committed. The ordering is load-bearing: PlayerEventDaily is the only durable
record of a funnel step, so deleting a raw event that has not been rolled up yet
does not lose a row, it loses a number, permanently and with nothing to report
it. The windows do not currently overlap — one edit to the env var is all it
would take, which is exactly why the order is enforced in code rather than
assumed.
| Variable | Default | Deletes |
|---|---|---|
OVERLAYS_EVENT_RETENTION_DAYS |
90 |
Raw PlayerEvent rows. The rollup they fed is kept. |
OVERLAYS_LEAD_RETENTION_DAYS |
730 (24 mo) |
Lead rows, and their LeadDelivery rows by cascade. |
A non-numeric or non-positive value falls back to the default rather than
resolving to 0, which would delete everything older than this morning.
/leads (behind NEXT_PUBLIC_OVERLAYS_ENABLED in the sidebar,
OVERLAYS_ENABLED on the page) lists leads for the signed-in user's demos with
their per-connection CRM delivery status, paginated and filterable by demo.
- CSV export streams from
/api/leads/exportrather than buffering the table, so an export of a successful customer's leads does not become a timeout. Cells are RFC 4180 quoted and formula-neutralised — a value starting=,+,-,@, TAB or CR gets a leading apostrophe, so a name field containing=HYPERLINK(...)is text in the spreadsheet rather than a live exfiltration link. A UTF-8 BOM is emitted so Excel does not mangle non-ASCII names. - Per-lead delete for subject-access requests. It is a real delete, not a
soft one, and takes the
LeadDeliveryrows with it. It cannot reach a CRM the lead was already forwarded to, and the UI says so. - Lead fields are returned to their owner and never logged, including from inside the export stream.
app/api/user/delete/route.ts deletes sessions and accounts by hand and relies
on cascade for the rest. That chain was broken: Prisma defaults a required
relation with no onDelete to Restrict, so Demo.userId,
ExportedVideo.userId, VideoJob.userId, Review.userId and CtaClick.demoId
were all ON DELETE RESTRICT. prisma.user.delete() raised a foreign-key
violation for any user who owned a demo, the route returned a 500, and nothing
was deleted — including the leads.
Migration 20260902000000_cascade_delete_chain repairs it, and
app/lib/overlays/cascade.test.ts parses the real prisma/schema.prisma and
fails if any edge regresses.
OVL turns the public share page into an interactive demo: a lead-capture
gate, branching cards at the end of the video, an in-player scheduling
widget, player telemetry feeding an overlay funnel, and — this section's
last two pieces — adaptive-bitrate HLS and signed media. It replaces the
browser's native <video controls> with a player we own, which is what makes all
five possible.
It ships behind feature flags and is a complete no-op when they are off. With
NEXT_PUBLIC_OVERLAYS_ENABLED unset, all three share routes render exactly what
they rendered before the feature existed: the same markup, the same native
<video controls>, the same View and CtaClick rows. That is the acceptance
criterion for the whole feature, not an aspiration.
app/share/[slug]/ShareVideoPageClient.tsx is the public player component. It
is rendered by all three viewer routes:
| Route | Served on |
|---|---|
/share/[slug] |
marvedge.com |
/share/video/[id] |
marvedge.com, for a bare exported video |
/hub/[domain]/share/[slug] |
a customer-owned domain, via middleware.ts |
Anything added there reaches all three at once — and has to be correct on the
customer's domain too, where the brand colours come from HubSettings and must
not be Marvedge purple. middleware.ts rewrites a non-apex host to
/hub/<domainKey>/… but skips everything under /api, so a player on
demos.acme.com posts same-origin to /api/v3/… and lands in this same
deployment with the same mv_sid cookie scope.
- Configure — the editor's Overlays sidebar panel writes to a
VideoOverlayConfigrow (its own table, notDemo.editing, which is the editor's autosaved draft and gets wholesale-overwritten). - Gate — the player opens the lead form at the configured trigger.
hardholds the video paused and inerts the control bar;softoffers a skip. A submission writes aLeadrow carrying the exact consent sentence that was on screen, and fans out to the owner's CRM connections inafter(). - Branch — in the last few seconds, two cards offer a choice of another demo
or an external URL. Branch cards are
Ctarows with placementbranch-a/branch-b, so a click writes aCtaClickrow and emits acta_clickevent — both, always. - Schedule — a Calendly or HubSpot booking widget opens inline, from a standing button, straight after a gate submission, or beside the branch cards. The URL must survive a host allow-list.
- Measure — the player emits
PlayerEventrows; a nightly rollup folds them intoPlayerEventDaily, which is what the analytics funnel reads. Telemetry is additive — every existingViewandCtaClickwrite is still there. - Package — on export completion, the Cloud Run worker encodes an HLS ladder and the share page starts serving the playlist instead of the MP4.
| FREE / anonymous | PRO / ENTERPRISE | |
|---|---|---|
| Lead-capture gate | Not available | Available |
| CRM delivery | Not available | Available |
| Branching cards | Available | Available |
| In-player scheduling | Available | Available |
| Overlay funnel + inbox | Not available | Available |
Branching and scheduling are free on purpose: CTAs are free today, and gating
them would remove functionality that already exists. The gate is re-resolved
server-side from User.plan on every render and again in POST /api/v3/leads,
so an owner who enabled a gate on PRO and then downgraded gets a demo that plays
normally rather than one that is stuck.
Exports are progressive MP4 and remain progressive MP4 — HLS is an addition, never a replacement.
POST /api/exported-videosfinishes saving an export and callspackageDemoHls()inafter(), so nobody waits on ffmpeg inside a request.- The Cloud Run worker's
POST /package-hlsdecodes the source once, splits it in the filtergraph, and encodes a 1080p / 720p / 480p ladder — never upscaling above the source — as fMP4 segments plus a master playlist, written to R2 underhls/<demoId>/. - The master playlist URI lands in
ExportedVideo.hlsPlaylistUrl. resolveShareOverlays()resolves it to https and the player attaches it: native HLS on Safari, hls.js everywhere else, and a quality menu on desktop (auto on mobile, where ABR is reacting to something the viewer cannot see).
Aligned keyframes are the whole game. A player can only switch rendition at a
segment boundary, and a segment can only start on a keyframe. If the renditions
put their keyframes in different places, every quality switch repeats or skips a
fraction of a second. Four ffmpeg settings enforce alignment and all four are
required — a constant -r, a fixed -g/-keyint_min, -force_key_frames on
the timeline, and -sc_threshold 0. The last is the one people forget, and it
silently undoes the other three.
Idempotent by demo id + source hash. The worker records a manifest.json
beside the renditions holding the sha256 of the source it encoded. Re-running on
an unchanged source skips the encode; passing the stored hash lets it skip the
download too. POST /api/demos/[id]/hls forces a repackage, and exists because
every demo exported before this landed has no renditions and re-exporting to get
them would re-encode a file identical to the one already there.
The MP4 fallback is the common path, not the exceptional one. At merge time
no demo has renditions, so every viewer takes it. It is also where a demo lands
when packaging failed, when OVERLAYS_HLS_ENABLED is off, when
R2_PUBLIC_BASE_URL is unset, or when the stored URI does not survive
validation. Losing adaptive bitrate is always preferable to losing playback.
LeadGateOverlay.tsx says it plainly and this section repeats it: hard mode is
client-side by default. The media URL is in the page source and the object is
publicly readable, so a viewer with devtools can watch the whole video without
ever seeing the form. Every control the gate disables is disabled in our UI,
not at the origin.
OVERLAYS_SIGNED_MEDIA_ENABLED is what closes that. With it on, a hard-gated
demo's share page renders no media URL at all; the player asks
GET /api/v3/media/[demoId], which answers 403 until a Lead row exists for
the viewer's mv_sid and a short-TTL presigned URL after. mv_sid is forgeable
— it is a non-httpOnly analytics cookie and nothing authorises on it — but
forging it does not help, because the check is for a row keyed on that id and
producing one means actually submitting the form.
Three limits, stated rather than implied:
- It signs the MP4, not the playlist. A presigned URL signs one object; an HLS playlist references its variants and segments by relative URI and none of those carry a signature. Making a signed playlist play means rewriting every playlist per request — a media proxy, not a presigner, and out of scope. So a hard-gated demo with signed media on is served its progressive MP4, signed. It loses adaptive bitrate and keeps its gate; the alternative loses the gate.
- Legacy
gs://media cannot be signed. Those buckets predate the R2 migration, are already public, and we hold no signing credentials for them. Such a demo keeps playing via its public URL, gated or not. Breaking pre-migration demos to enforce a gate that was never enforceable on them would be a regression dressed up as a security fix. - It is still not DRM. Someone who submits the form once gets a real URL and can pass it on until it expires. What changes is that a viewer who never submitted anything cannot read the media.
Only a hard gate withholds media. A soft gate is one the viewer may skip, so withholding the media behind it would silently convert it into a hard one.
Required reading before adding a fourth overlay; the full contract is in
app/components/player/PlayerOverlayHost.tsx.
- There is one slot.
MarvedgePlayerowns a single positioned layer and the overlays portal into it. There is also a small trigger strip in the top-right for standing affordances (the scheduling button) that sits below the overlay layer and passes pointer events through, so an empty strip costs the viewer nothing. - Priority order. The gate outranks branching, which outranks scheduling. One overlay is active at a time; a higher-priority registration displaces a lower one rather than stacking on it.
- Only the host pauses the video. Opening a blocking overlay is what pauses
playback. An overlay component must never call
pause()itself — a second caller is exactly how a video ends up resuming underneath a half-filled form. - Blocking vs dismissible is declared at registration. Blocking sets
controlsLocked, which inerts the control bar (realdisabledattributes and handlers that refuse), kills the keyboard shortcuts, and hides the bar from the accessibility tree so a screen reader is not offered controls that do nothing. - To register a new overlay: render it as a child of
<MarvedgePlayer>, call the registry hook with a priority and a blocking flag, and put the positioning, backdrop, dialog role and focus handling in<PlayerOverlay>rather than in your component. Keep any shared decision inapp/lib/overlays/so the route handler and the editor agree with the player about it.
app/lib/overlays/ is isomorphic and pure — no fs, no DOM, no node-only
API, and no process.env outside flags.ts. One code path serves a React
component, a route handler and the editor, which is the only way the three stay
in agreement. Environment-specific code goes in the component or the route.
HLS playback needs cache and CORS rules on the R2 public host that cannot be set from this repo. They are written down here because nobody set them:
- CORS on the R2 bucket: allow
GET,HEADand theRangerequest header from the share origins —https://marvedge.com, any staging origin, and every customer custom domain. This is the step most likely to be missed: playback works from marvedge.com and fails on a customer hub, because that is a different origin fetching the same segments. Verify from an actual customer domain, not only from marvedge.com. - Cache rules on the public host:
*.m3u8— short TTL (30–60s). A playlist can be re-cut by a repackage.*.m4sandinit.mp4— long TTL (a year),immutable. Segment names are stable per package and a repackage overwrites them, so pair this with a purge of thehls/<demoId>/prefix on repackage, or accept that a repackaged demo serves stale segments until the TTL lapses.
R2_PUBLIC_BASE_URLmust point at that host. An HLS playlist references its segments relatively, so they are fetched from whatever host served the playlist — with this blank the app reports no playlist and falls back to MP4 rather than emitting a URL nothing can fetch.
| Variable | Where | Purpose |
|---|---|---|
OVERLAYS_ENABLED |
Next app (server) | Master switch for /api/v3/* and the owner-facing overlay config routes. |
NEXT_PUBLIC_OVERLAYS_ENABLED |
Next app (client) | Renders the overlay layer on the share player and the "Overlays" editor panel. |
OVERLAYS_CRM_ENABLED |
Next app (server) | Outbound lead delivery. Off = leads are still captured, just not forwarded. |
OVERLAYS_CRM_SECRET_KEY |
Next app (server) | AES-256-GCM key for stored CRM credentials. Absent = creating a connection 503s. |
OVERLAYS_CRM_SECRET_KEY_PREVIOUS |
Next app (server) | Accepted on decrypt only, for key rotation. |
OVERLAYS_ROLLUP_SECRET |
Next app (server) | Guards POST /api/v3/events/rollup. Blank = the endpoint is closed (503). |
OVERLAYS_EVENT_RETENTION_DAYS |
Next app (server) | Raw PlayerEvent retention. Default 90. |
OVERLAYS_LEAD_RETENTION_DAYS |
Next app (server) | Lead retention. Default 730 (24 months). |
OVERLAYS_HLS_ENABLED |
Next app (server) | HLS packaging + preferring a playlist over the MP4. |
OVERLAYS_SIGNED_MEDIA_ENABLED |
Next app (server) | Withhold a hard-gated demo's media URL until a lead is submitted. |
OVERLAYS_SIGNED_MEDIA_TTL_SECONDS |
Next app (server) | Signed URL lifetime. Default 900, clamped to [60, 21600]. |
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY |
Next app and worker | R2 credentials. Required for HLS and for signing. |
R2_PUBLIC_BASE_URL |
Next app (server) | Public https host for R2. Required for HLS — segments are fetched relative to it. |
GCP_VIDEO_WORKER_URL |
Next app (server) | Reaches the Cloud Run worker for /package-hls. |
R2_HLS_BUCKET |
Cloud Run worker only | Bucket renditions are written to. Falls back to R2_PROCESSED_BUCKET. |
HLS_SEGMENT_SECONDS / HLS_FPS |
Cloud Run worker only | Segment/GOP length (4) and the constant frame rate (30). Optional. |
All the OVL flags default off, unlike the QR kill-switch, which defaults on.
QR is derived and read-only; these change what an unauthenticated public page
renders and cause viewer PII to be stored and forwarded to a third party. A flag
whose default is off cannot turn either of those on by accident in an environment
nobody remembered to configure. See .env.example for the full list.
app/lib/overlays/must stay pure. Onefsornext/headersimport in there breaks the editor build, because the same modules are imported by a client component.- Never log lead PII. Not a name, not an email, not a company size — not in
console.log, not in an error message, not in a thrownError. That includes not logging a Prismaerror.message, which can quote the arguments it was given, and those arguments are the lead.app/api/views/route.tsset the precedent of logging only literals; hold it. mv_sidis not an auth token. It is a non-httpOnly analytics cookie the client can read, edit and forge. Nothing may ever authorise on the value itself — only on rows keyed by it.- Telemetry is additive. If a change stops
Viewrows being written, every number on the existing analytics dashboard silently goes to zero with nothing anywhere to say why.useViewTracking()owns thevideoRefon both the overlay and the flag-off path for exactly this reason. - Don't put anything in
ExportedVideo.settings.POST /api/exported-videoswrites that field wholesale on every save, so a value parked there is erased by the next export. The HLS columns are narrow columns for this reason — the same trap that kept overlay config out ofDemo.editing. - Two workers exist.
cloudrun-worker/server.jsis the HTTP service behindGCP_VIDEO_WORKER_URLand is where every video endpoint including/package-hlslives.video-worker/index.tsis a separate BullMQ consumer that today only serves audio clips. Editing the wrong one is a silent no-op that passes every test.
SUB generates subtitles from a demo's audio, then lets you correct, re-time, restyle, translate and export them. It ships behind a feature flag and is a complete no-op when the flag is off: a demo with no subtitle style, no track and no language selection exports byte-identically to what it produced before the feature existed.
Unlike AVS, it is not plan-gated. Generation, editing, timeline retiming,
styling and .srt / .vtt / .txt export are free on every plan, including FREE
and anonymous. Translation is the only PRO/ENTERPRISE surface, gated
server-side in /api/subtitles/translate from the user's real plan — it is the
one part that spends money per use at an external vendor. Do not add a plan check
to any other subtitle path.
Open a demo in the editor and switch to the Subtitles sidebar tab:
- Generate —
/api/subtitles/createcreates aVideoJoband returns ajobIdin the same tick, dispatching the work through Next'safter(). The Cloud Run worker'sPOST /subtitlesdownloads the source, extracts 16 kHz mono PCM with ffmpeg, and transcribes it with Deepgram (nova-2, ornova-3for Arabic), clustering word timings into cues. The client polls/api/jobs/{id}. A blob-URL source is uploaded to GCS first, askind: "subtitle-source". - Edit — fix a mis-transcribed word, retime a cue to the centisecond, split a
long cue at the playhead, merge two short ones, delete or add one. Every
mutation goes through the pure helpers in
app/lib/subtitles/cues.tsand ends innormalizeCues(). - Retime on the timeline — cues appear as a track on the timeline ruler and drag and resize like trim segments. A whole drag lands as one undo step.
- Style — font, size, colour, background box, border, shadow, position and
animation. The preview overlay and the burned-in export read the same numbers
from
app/lib/subtitles/style.ts(see the parity rule below). - Language & translate — pick the spoken language before generating, switch between the demo's per-language tracks, or translate the active one with OpenAI. Translation is the gated step.
- Export — download
.srt/.vtt/.txt, and/or burn the cues into the video. Burn-in is an explicit choice in the export settings, not something that happens whenever cues exist.
Cues, the active language and the style all persist to Demo.editing through the
normal autosave — there is no save button and no DB migration for them. Generated
and translated tracks are also written to SubtitleTrack, one row per
(demo, language) holding cues as JSON. That is a deliberate, signed-off
deviation from PRD §10's per-segment SubtitleSegment table.
app/lib/subtitles/style.ts is the single source of truth for what a subtitle
looks like, and both sides must read it. The editor preview
(SubtitleOverlay.tsx) imports it directly. The renderer cannot —
cloudrun-worker/ is a standalone package with its own package.json and nothing
in it can import from app/ — so the CSS→ASS mapping exists twice: a TypeScript
original and a JavaScript port in cloudrun-worker/render.js.
Two lookalike implementations is exactly the failure this feature was built to
fix. Before it, SubtitleOverlay.tsx hardcoded a fixed 16px while
writeAssSubtitles() used a height-proportional clamp(20, h*0.05, 58) — they had
already silently drifted, so the preview was not showing what the export produced.
app/lib/subtitles/workerParity.test.ts is what stops that recurring: it loads the
real worker file and asserts the two produce identical output across a matrix
of styles and frame sizes. If it fails, the port and the original have drifted —
fix whichever is wrong. Do not relax the assertion. It is the only mechanical
reason to believe the preview matches the render. The mapping has real traps: ASS
colours are &H00BBGGRR (reversed byte order) with inverted alpha, and font size
must be resolution-independent (% of frame height), because "24 px" means one thing
on a 640 px preview and another on a 1920 px export.
remapSubtitleCuesToTrimmedTimeline() in cloudrun-worker/render.js — which
re-times cues across trim segments and slices them per 10 s export chunk —
assumes its input is sorted and non-overlapping, and two overlapping ASS
Dialogue lines render stacked on top of each other, not one after the other.
That assumption used to hold by luck, because the worker was the only producer of cues and its clustering never overlaps. Once a user can drag a cue's edge over its neighbour, an overlap is one gesture away (PRD §13 lists it explicitly).
normalizeCues() is the single place the invariant is established: it drops junk,
clamps to the video's length, enforces a minimum duration, sorts, and resolves
every overlap — truncating the earlier cue, pushing the later one when truncation
would erase it, and dropping only as a last resort. Every store mutation ends in
it, and /api/subtitles/export re-runs it server-side — never trust the client's
ordering. The other helpers in cues.ts are deliberately dumb array operations
that do not normalize; composing them and normalizing once at the end is what
keeps an undo stack reasonable.
app/lib/subtitles/burnInInvariant.test.ts drives the real worker functions end to
end (chunk slice → trim remap → ASS serialization) on overlapping-by-construction
cue sets and asserts the Dialogue lines come out ordered and non-overlapping at
the centisecond resolution ASS actually stores.
cloudrun-worker/ is the deployed worker. It is what GCP_VIDEO_WORKER_URL
points at, it is the only one with a Dockerfile, and its package.json declares
main: server.js with a Cloud Run start script.
video-worker/index.ts is a near-identical TypeScript twin with its own copies
of writeAssSubtitles / transcribeWithDeepgram /
remapSubtitleCuesToTrimmedTimeline. It is a BullMQ/Redis-era leftover: it has no
Dockerfile, nothing dispatches to it, and it was last touched by an unrelated
dependency bump. Do not edit it. A change to the wrong copy is a silent no-op
that passes every test.
PRD §7 targets under 60 s for a 10-minute video — a ratio of 0.1 s of wall clock per second of video.
⚠️ This has not been measured yet. It needs a real 10-minute video pushed through a deployed worker, and noGCP_VIDEO_WORKER_URLorDEEPGRAM_API_KEYis configured in this checkout. The target above is the PRD's requirement, not a measurement — do not quote it as one.
The worker logs everything needed to close this out. Run a 10-minute video through generation and read one line from the Cloud Run logs:
[subtitles] Timing download_ms=… extract_ms=… deepgram_ms=… total_ms=… cues=… duration_s=… source_bytes=… ratio=…
ratio is the number to compare against the target: seconds of wall clock per
second of video, so ratio < 0.1 meets PRD §7. duration_s is derived exactly
from the extracted WAV (16 kHz mono PCM s16le, so bytes ÷ 32000 is the duration).
The three phase timings say where the time goes if it misses: download_ms scales
with file size and GCS locality, extract_ms is ffmpeg and scales with duration
and codec, and deepgram_ms is one upload-and-wait round trip to the vendor.
Record the actual numbers here, and if it misses, say by how much and which phase
dominates.
| Case | Behaviour |
|---|---|
| No speech in the video | The job succeeds with zero cues, and the editor says "No speech detected in this video" rather than "Subtitles ready". |
| No audio track at all | Distinguished from a corrupted file: "This video has no audio track, so there is nothing to transcribe." |
| Corrupted / incomplete upload | The ffmpeg failure is reduced to its last meaningful stderr lines and surfaced as "This video could not be read — it may be corrupted or the upload may be incomplete." Never a raw ffmpeg banner dump, never a bare 500. |
| Cancel mid-job | See below — the work does not stop, the result is discarded. |
| Video longer than 2 hours | Refused with the actual length named, client-side and again in the route. |
| Upload not MP4/MOV/AVI/MKV | Refused at the file input and in /api/gcs/upload with a 400 naming the extension. |
| Upload over 2 GB | Same, with the actual size named. |
| Unsupported language code | The routes reject it with a 400 rather than silently falling back to auto-detect. An absent code still means auto-detect. |
| Overlapping cues after editing | Resolved by normalizeCues(); see the invariant above. |
| Speaker diarization | Explicitly not supported (PRD §15). Cues carry no speaker labels and there is no "who said this" axis anywhere in the data model. Deepgram can diarize; the feature deliberately does not ask it to. |
Cancelling does not stop the work. POST /api/subtitles/cancel marks the
VideoJob CANCELLED and the client stops polling immediately, so the editor is
usable again at once. But /subtitles is one long opaque HTTP call to Cloud Run
with no cancellation channel: the worker runs to completion and the Deepgram
minutes are spent either way. What cancelling buys is that the result is
discarded — the dispatcher re-reads the job status before writing anything, so
cues from a cancelled run never land on the demo. The UI copy says exactly this; do
not "improve" it into a promise the mechanism cannot keep.
Uploads are validated in both halves, because they have to be: the browser check is
a courtesy (accept is trivially bypassed by drag-and-drop), and /api/gcs/upload
mints a signed URL that writes straight into the bucket, so the server check is the
control. Both read the same constants from app/lib/subtitles/limits.ts. Note that
.webm is accepted alongside the PRD's four containers — Marvedge is
recording-first and MediaRecorder produces webm, so enforcing the PRD's list
literally would reject the product's primary path.
- Two lines stacked on top of each other. The cue list reaching the worker was
overlapping. Something bypassed
normalizeCues()— check the mutation path, not the renderer.npm testrunsburnInInvariant.test.ts, which covers this. - The style does not match the preview. The ASS port in
cloudrun-worker/render.jshas drifted fromapp/lib/subtitles/style.ts. Runnpm test;workerParity.test.tsnames the exact field. - Text is the wrong size on a different aspect ratio. Size is stored as a % of frame height on purpose. A px value that leaked into the persisted style will look right at one resolution and wrong at every other.
- Subtitles drift out of sync after a trim.
remapSubtitleCuesToTrimmedTimelinere-times cues across keep-segments. Check the recipe'ssubtitlesare in source time, not already-trimmed time. - Subtitles are missing entirely from the export. Burn-in is an explicit toggle
in the export settings — having cues is not sufficient. Also check the export
recipe actually carries
subtitles. - Arabic renders as disconnected, left-to-right letterforms. Expected, and why
RTL_RENDERING_VERIFIEDinlanguages.tsisfalseand Arabic appears in no picker. libass only reorders and shapes bidirectional text when it was built with FriBidi and HarfBuzz; the container's ffmpeg build has not been inspected. Verify a real 1080p export before flipping that flag. - You edited the worker and nothing changed. You probably edited
video-worker/. See "Which worker is live" above.
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_SUBTITLE_EDITOR_ENABLED |
Next app (client) | Shows the "Subtitles" sidebar panel, and through it the timeline track and style controls. Set to true to enable. |
SUBTITLE_TRANSLATE_ENABLED |
Next app (server) | Gates the AI translation route. Server-only — never expose it with a NEXT_PUBLIC_ prefix. |
OPENAI_API_KEY |
Next app (server) | Already required by AVS. Reused for translation — no new vendor and no new key. |
GCP_VIDEO_WORKER_URL |
Next app (server) | Already required. Reaches the Cloud Run worker for transcription. |
DEEPGRAM_API_KEY |
Cloud Run worker only | Already required by AVS. Speech-to-text. The Next app never holds this key. |
Both SUB flags default OFF, like AVS and WTM and unlike the QR kill-switch.
That is deliberate: the editor panel rewrites cues that get burned into an export
and the translate route spends money, so "unset" has to mean "behave exactly as
today". SUBTITLE_TRANSLATE_ENABLED is the feature gate, not the paywall — the
PRO/ENTERPRISE check is a separate server-side plan lookup. NEXT_PUBLIC_… is
inlined at build time: flipping it needs a rebuild for the client, only a restart
for the server. SUB needs no new package and no new vendor. See .env.example
for the full list.
To learn more about Next.js, take a look at the following resources:
- Next.js Documentation - learn about Next.js features and API.
- Learn Next.js - an interactive Next.js tutorial.
You can check out the Next.js GitHub repository - your feedback and contributions are welcome!
The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js.
Check out our Next.js deployment documentation for more details.