Skip to content

Repository files navigation

This is a Next.js project bootstrapped with create-next-app.

Getting Started

First, run the development server:

npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev

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

This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.

AI Script, Voiceover & Audio Sync (AVS)

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.

The flow

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:

  1. 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.
  2. AI script — per-step narration is seeded from the Deepgram transcript and rewritten into a tone (Sales / Onboarding / Support / Marketing) via OpenAI.
  3. Voiceover — the script is synthesized into one continuous MP3 with Deepgram Aura TTS in the Cloud Run worker, recording each step's timing.
  4. Captions — the voiceover is transcribed and stabilized into flicker-free captions (also exportable as .vtt).
  5. 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.
  6. 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.

Environment variables

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.
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. Leave all of these blank/unset in production until the feature is signed off. See .env.example for the full list.

Watermarking & Camera Bubble (WTM)

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.

Plan behavior

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.

The flow

  1. Record — the recorder offers a camera toggle with a live preview. With it on, a second MediaRecorder captures 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.
  2. 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.
  3. Composite (pre-pass) — on export, if a camera clip exists, /api/wtm/composite runs the Cloud Run worker's /wtm-composite first: 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.
  4. 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.

Environment variables

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.

Shareable-Link QR Codes (QR)

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.

Where it shows up

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.

GET /api/qr

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 in app/lib/share/qrTarget.ts layers 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.

Scan attribution

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.

One style, on purpose

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.

Scannability is the acceptance criterion

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.

The brand mark asset

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: 0 in 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.

If a QR won't scan

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Check the target URL actually resolves. A QR for an unshared or deleted demo scans perfectly and then lands on a 404 — /api/qr validates URL shape, never existence, by design.
  6. Only then suspect the engine. Run npm test; the QR suite rasterizes and decodes real codes across versions.

Environment variables

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.

Learn More

To learn more about Next.js, take a look at the following resources:

You can check out the Next.js GitHub repository - your feedback and contributions are welcome!

Deploy on Vercel

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.

Releases

Packages

Used by

Contributors

Languages