diff --git a/.gitignore b/.gitignore index 154ead8..0e0a832 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,11 @@ prompt server.log # kilocode mcp config (contains sensitive data) -.kilocode/mcp.json \ No newline at end of file +.kilocode/mcp.json + +# generated UGC media — regenerable outputs, served from gw.724care.com (not source) +/public/videos/ +/public/audio/ + +# model weights (downloaded, not source) +*.pt \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f63c18d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Mexico Paradise Vacations — a travel certificate sales platform with payment plans, client portal, and admin dashboard. Built with Next.js 15 (App Router) + TypeScript + Tailwind CSS 4 + shadcn/ui. + +## Safety & Authorization + +- **Confirm before irreversible or live-serving steps.** Before installing software (docker, miniconda, model downloads, etc.) or running deploy/destructive commands (`rsync`, `scp` to a live host, symlink-to-live, `rm`, `pkill`, killing screens/PM2 apps), show a numbered plan of the exact commands and which host they target, and wait for an explicit go-ahead. A generic "go ahead" does **not** authorize promoting to production — confirm that step specifically. +- **Do not over-reach beyond the literal request.** Don't enumerate subdomains/SSH keys, provision new boxes, or patch safety/validation code that wasn't asked about. +- **Discover before you guess.** Before using a model name, endpoint, file path, asset ID, keyframe, or remote directory, query/list the real value from the source first and confirm it exists — never guess from memory (e.g. real model names like `Wan2_1-InfiniteTalk_Single_Q8.gguf`, not a remembered approximation). +- **Verify the right variant before publishing.** When deploying an installer/script/render config, confirm it's the correct variant and read its contents before pushing it live. +- **Never echo secret values** (FISH_API_KEY, CLOUDFLARE_API_TOKEN, HF_TOKEN, VAST_API_KEY, GEMINI_API_KEY, passwords). Save to file without disclosing. + +## Environment Constraints + +- Shell state (working directory, env vars) does **not** persist between Bash calls; use absolute paths. The harness cannot permanently `cd` or switch CLI sessions/projects mid-session — when an action requires that, give the user the exact CLI command to run themselves rather than attempting it. + +## Deploy Targets + +- **gw.724care.com** internal artifacts: the live Apache vhost DocumentRoot is **`/var/www/html`** (verified 2026-06-05 via `apache2ctl -S` + the `DocumentRoot` directive in `000-default.conf` / `000-default-le-ssl.conf`; real batch HTML and video URLs return 200). Deploy batch HTML and media to `root@gw.724care.com:/var/www/html/...`; the super-index lands at `/var/www/html/batches.html`. (A `/workspace` dir exists but is NOT what gw serves — don't deploy there.) +- **hi2b.com** production: `root@api3.amdy.io`, app at `/opt/hi2b` (PM2 app `hi2b`), deploy via scp + `npm run build` + `pm2 reload`. + +## Internal HTML on gw.724care.com + +Any internal artifact that's a standalone HTML page (UGC batch index, voice audition, script doc, comparison sheet, research summary) **must be**: + +1. Saved locally at `public/research/.html`. +2. Deployed with `scp` to `root@gw.724care.com:/var/www/html/.html` (the live docroot — see Deploy Targets) so it's reachable at `https://gw.724care.com/.html`. +3. Linked from the super-index at `https://gw.724care.com/batches.html`. Add a card to the appropriate section of `public/research/batches-index.html`, then re-deploy it as `batches.html` on gw. + +This does **not** apply to hi2b.com production routes (those live in `src/app/` and deploy via the prod server). It applies only to internal-tooling HTML. + +## Commands + +- **Dev server**: `npm run dev` (uses nodemon + tsx to run custom `server.ts`; HMR is disabled in favor of nodemon-based full reload) +- **Build**: `npm run build` +- **Production**: `npm run start` +- **Lint**: `npm run lint` +- **DB push schema**: `npm run db:push` +- **DB generate client**: `npm run db:generate` +- **DB migrate**: `npm run db:migrate` + +## Architecture + +### Custom Server (`server.ts`) +The app uses a custom Node HTTP server (not the default `next dev`/`next start`). It creates a Next.js app and attaches a Socket.IO server on `/api/socketio`. Dev mode runs via `nodemon --exec "npx tsx server.ts"`. + +### Dual Database Setup +- **Supabase** (`src/lib/supabase.ts`): Primary data store for the business domain — users, signups, payments, certificates. The Supabase client is initialized with hardcoded project URL/anon key. Database types are defined inline in this file. +- **Prisma + SQLite** (`src/lib/db.ts`, `prisma/schema.prisma`): Secondary database with a basic User/Post schema. Uses the singleton pattern to avoid multiple PrismaClient instances in dev. + +### Authentication +Simple demo auth in `src/lib/auth.ts` — password is hardcoded as `demo123`, user session stored in localStorage. Role-based: `admin` or `client`. + +### Payment Integration +`src/lib/maverick.ts` — `MaverickPaymentAPI` class wrapping the Maverick Payments REST API. Configured via `MAVERICK_API_URL` and `MAVERICK_API_KEY` env vars. + +### API Routes +All under `src/app/api/`: +- `POST /api/signup` — new vacation package signup +- `POST /api/payment/create` — create payment +- `POST /api/payment/confirm` — confirm payment status +- `POST /api/admin/refund` — process refund (admin only) +- `GET /api/health` — health check + +### Pages +- `/` — landing page +- `/client-portal` — client dashboard (auth-guarded) +- `/admin-portal` — admin dashboard (auth-guarded) +- `/payment/success`, `/payment/cancel` — payment result pages + +### UI Components +shadcn/ui (new-york style) in `src/components/ui/`. Custom components: `auth-guard.tsx`, `login-modal.tsx`. + +## Path Aliases + +`@/*` maps to `./src/*` (configured in tsconfig.json). + +## Environment Variables + +- `DATABASE_URL` — Prisma/SQLite connection string +- `NEXT_PUBLIC_APP_URL` — application URL +- `MAVERICK_API_URL` — Maverick Payments API base URL +- `MAVERICK_API_KEY` — Maverick Payments API key + +## Build Notes + +- TypeScript build errors are ignored (`typescript.ignoreBuildErrors: true` in next.config.ts) +- ESLint errors are ignored during builds (`eslint.ignoreDuringBuilds: true`) +- ESLint config is very permissive — most strict rules are turned off + +## Best Practices + +### Next.js App Router +- Use Server Components by default; only add `"use client"` when you need browser APIs, event handlers, or React hooks (useState, useEffect, etc.) +- Place data fetching in Server Components or API routes — never fetch in `useEffect` when a server-side approach works +- Use `route.ts` files for API routes; export named functions matching HTTP methods (`GET`, `POST`, `PUT`, `DELETE`) +- Return `NextResponse.json()` from API routes with appropriate status codes +- Use `loading.tsx`, `error.tsx`, and `not-found.tsx` for route-level UI states +- Use `layout.tsx` for shared UI that persists across navigations; avoid re-fetching data that a parent layout already provides +- Prefer Next.js `` over `` and `` over `` for optimized loading and client-side navigation +- Use `metadata` exports or `generateMetadata()` for SEO — not manual `` tags + +### Node.js / Server-Side +- Never block the event loop — use async/await for I/O, avoid synchronous file or network calls in request handlers +- Keep secrets in environment variables, never hardcode them (note: this project currently has hardcoded Supabase keys that should be moved to env vars) +- Use the Prisma singleton pattern from `src/lib/db.ts` to prevent connection exhaustion in dev +- Validate and sanitize all user input at API boundaries using Zod (already a dependency) +- Handle errors explicitly in API routes — return structured error responses, don't let unhandled exceptions leak stack traces +- Use `try/catch` around external API calls (Maverick, Supabase) and return meaningful error messages + +### React / Frontend +- Co-locate component state as close to where it's used as possible; lift state only when siblings need to share it +- Use Zustand (already installed) for global client state; avoid prop drilling more than 2 levels deep +- Use React Query (`@tanstack/react-query`, already installed) for server state — caching, refetching, and optimistic updates +- Prefer controlled form inputs with `react-hook-form` + Zod validation (both already installed) +- Use shadcn/ui components from `src/components/ui/` — don't rebuild existing primitives +- Add new shadcn components via `npx shadcn@latest add ` + +### TypeScript +- Use explicit return types on exported functions and API route handlers +- Define shared types/interfaces in dedicated files or alongside their domain (e.g., Supabase types in `src/lib/supabase.ts`) +- Prefer `interface` for object shapes that may be extended; use `type` for unions, intersections, and computed types +- Use Zod schemas as the single source of truth for validation and infer TypeScript types from them with `z.infer<>` + +### Styling +- Use Tailwind CSS utility classes; avoid custom CSS unless Tailwind cannot express the style +- Follow the shadcn/ui `new-york` style variant (configured in `components.json`) +- Use CSS variables defined in `src/app/globals.css` for theme colors +- Use `cn()` from `src/lib/utils.ts` to merge conditional Tailwind classes (combines `clsx` + `tailwind-merge`) diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..83b9b63 --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,89 @@ +# HANDOVER — hi2b.com UGC Operation & Funnel + +_Last updated: 2026-06-21_ + +Mexico Paradise Vacations / **hi2b.com** — travel-certificate offer ($249 one-time / $29/mo, +5 days / 4 nights all-inclusive, kids free, 4 Mexico destinations). Brand pillar: +**"Hour to Paradise"** — honest framing of the one resort presentation as "the only catch." + +--- + +## 🚨 TOP PRIORITY — the funnel is structurally broken (fix before spending more) + +Observed: **~2,000 landing-page visits → 0 sales → 0 PDF downloads.** That is not a weak-hook +signature; it means the message isn't being delivered. Root cause found: + +**`src/app/page.tsx:10-13` redirects every visitor to a RANDOM landing page, client-side** +(`router.replace('/lp/' + randomLP)`). Confirmed live: fetching `hi2b.com` returns only the +`"Loading your paradise..."` spinner. Consequences: + +1. **No message-match** — a "$249 honest hour" ad lands on a random page out of 43. +2. **TikTok in-app browser bounces** on client-side redirects; pixel PageView may be lost. +3. **Price/tone whiplash** — ads promise *$249 one-time, honest*; LPs lead with *$29/mo* + + fake urgency ("2,847 claimed," "expires at midnight"). This re-triggers the "scam / too + good to be true" fear the UGC works to defuse. +4. **0 downloads from ~1,000 ebook-page visitors** → verify the ebook form actually fires. + +### Recommended fixes (in order) +1. Replace the random redirect with **one server-rendered, message-matched page** mirroring the + ad: *$249, 5 days all-inclusive, kids free, one ~60-min presentation, polite "no" is fine.* +2. Answer both core objections above the fold: **why it's cheap** (resort subsidizes for the + presentation slot) and **exactly what the presentation is** (~60 min, no obligation). +3. Make the **free guide the first ask** (email capture) → nurture → sale. Don't ask cold + TikTok traffic for $249 on first touch. +4. **Verify GA4 + TikTok pixel + the download form** actually register events before scaling. + +Landing-page system: `src/app/lp/[slug]/`, config in `src/app/lp/_config/pages.ts` +(43 pages; ctaFocus split = 20 `ebook`, 23 `pay-now`). Analytics: GA4 `G-Q8TPF405Z1` +(`src/app/layout.tsx`) + TikTok pixel (`src/components/TikTokPixel.tsx`, +`NEXT_PUBLIC_TIKTOK_PIXEL_ID`). + +--- + +## Render engine (talking-head UGC) — healthy and self-sustaining + +- **Orchestrator:** `scripts/orchestrator.sh` (runs via `nohup`). Queue `scripts/render-queue.txt`, + done `scripts/render-done.txt`, log `scripts/orchestrator.log`. Renders queued batches, then + `finalize-batchNN.sh` deploys each to gw. +- **GPU (Inst 2):** `root@51.83.197.242 -p 43312`, ComfyUI on `localhost:18188` (per-batch SSH + tunnel). Single-speaker model `Wan2_1-InfiniteTalk_Single_Q8.gguf` (Wan2.1), v13 keyframes. +- **TTS:** Fish Audio `s2-pro`, Sarah `voice_ref 933563129e564b19a115bedd57b7406a`, via + `scripts/batch10-tts.ts ` (skips existing mp3s; safe to re-run to fill gaps). +- **Deploy target (gw):** live docroot `/var/www/html` (NOT `/workspace`). Batches → + `root@gw.724care.com:/var/www/html/batchNN/`. Super-index `public/research/batches-index.html` + → deployed as `/var/www/html/batches.html`. +- **State:** ~82 batches live (≈820 videos). 83–85 authored/queued. Each batch = 10 single-speaker + scripts. + +### Authoring a new batch (proven template) +1. Clone latest `batchNN-render.ts` via `sed` (batchOLD→batchNEW, bOLD→bNEW, tunnelPort, title); + fix `ALL_BNN` + `KEYFRAME_MAP` ids to match the new `scripts.json`. Verify with `diff` of ids. +2. Clone `finalize-batchNN.sh` and `public/research/batchNN.html`. +3. `npx tsx scripts/batch10-tts.ts scripts/batchNN-scripts.json`. +4. Add index card to `batches-index.html`, redeploy as `batches.html`, append `NN` to + `render-queue.txt`. Keep a 3-deep buffer. + +### Stall detection +GPU 0% is normal in the inter-video model-reload gap. REAL stall = GPU 0% + mem ~3GB + ComfyUI +`/queue` empty + log still "running". Re-sample 3–4×. Fix: `pkill -f "batchNN-render"`; the +orchestrator retries and resumes (skips existing mp4s). `finalize` scp can silently fail — always +verify gw file count (`ls /var/www/html/batchNN/*.mp4 | wc -l`) and re-deploy if < 10. + +--- + +## Quote-card pipeline (separate; CPU/ffmpeg + Gemini/Veo, no GPU) + +- `scripts/build-quote-card-vo.ts` — windswept Veo bg + Sarah VO + slow, halved, WHITE captions, + `-movflags +faststart`. Driver `build-all-quote-cards-vo.ts` (`--force`). Wave bgs via + `gen-wind-clips.ts` (Veo text-to-video, 9:16). `travel-quotes.json`. +- **55 voiced cards live** at `https://gw.724care.com/quote-cards.html` + (deployed to `/var/www/html/quotecards-vo/`). faststart fixed (moov before mdat). + +--- + +## Conventions & guardrails +- Confirm before irreversible/live-serving steps; a generic "go ahead" does NOT authorize prod. +- gw live docroot = `/var/www/html`. hi2b prod = `root@api3.amdy.io:/opt/hi2b` (PM2 `hi2b`), + deploy via scp + `npm run build` + `pm2 reload`. +- Never echo secrets (FISH_API_KEY, GEMINI_API_KEY, etc.). +- Internal standalone HTML → `public/research/.html` → scp to gw → link from `batches.html`. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..5b284e7 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,61 @@ +# Deployment + +## Production + +**hi2b.com** runs on `api3.amdy.io` (65.21.161.173), fronted by Cloudflare. + +| | | +|---|---| +| **SSH** | `ssh root@api3.amdy.io` | +| **App directory** | `/opt/hi2b` | +| **Process manager** | PM2, app name `hi2b` (id 5) | +| **Origin port** | `127.0.0.1:3015` (Cloudflare proxies to it) | + +> ⚠️ `/opt/hi2b` is **not a git checkout**. There is no `.git` directory there, so `git pull` will not work. Deploys are done by copying files over with `scp` from this repo at `/home/na/ai-management-dashboard`. + +## Standard deploy + +From the dev tree, for any changed files: + +```bash +# 1. copy the changed files to the production app dir +scp root@api3.amdy.io:/opt/hi2b/ + +# 2. build and reload +ssh root@api3.amdy.io 'cd /opt/hi2b && npm run build && pm2 reload hi2b' +``` + +PM2 `reload` is a zero-downtime restart. Use `pm2 restart hi2b` for a full restart if `reload` isn't enough. + +## Other PM2 apps on this box (do not touch) + +`api3.amdy.io` also runs unrelated services. **Leave them alone**: + +- `58dakota`, `58dakota-portal` +- `724care-next` +- `amdy-portal` +- `amdy-recording-manager` +- `amdy-worker-emails` +- `did-optimizer` + +## Useful one-liners + +```bash +# tail the prod app log +ssh root@api3.amdy.io 'pm2 logs hi2b --lines 50 --nostream' + +# pm2 status (just hi2b) +ssh root@api3.amdy.io 'pm2 show hi2b' + +# quickly verify a route after deploy +curl -sI https://hi2b.com/ +``` + +## Static SEO / crawler assets (live) + +| File | Purpose | +|---|---| +| `/robots.txt` | Explicit Allow for every major search + AI/LLM crawler | +| `/llms.txt` | [llmstxt.org](https://llmstxt.org) site summary for AI assistants | +| `/sitemap.xml` | Auto-generated by `src/app/sitemap.ts` — covers all 43 LPs + static pages | +| `src/app/not-found.tsx` | Catches any 404 and `redirect()`s to `/lp/golden-hour` so no ad traffic is lost | diff --git a/next.config.ts b/next.config.ts index ea90f9f..8d52773 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { protocol: 'https', hostname: 'images.unsplash.com' }, + ], + }, typescript: { ignoreBuildErrors: true, }, diff --git a/package-lock.json b/package-lock.json index 9b632e3..553222f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,20 +45,32 @@ "@supabase/supabase-js": "^2.76.1", "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", + "@types/ws": "^8.18.1", "axios": "^1.10.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.23.2", + "gsap": "^3.14.2", "input-otp": "^1.4.2", + "jsonwebtoken": "^9.0.3", + "lenis": "^1.3.18", "lucide-react": "^0.525.0", + "msgpackr": "^2.0.1", + "mysql2": "^3.20.0", "next": "15.3.5", "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", + "nodemailer": "^6.10.1", + "pdfkit": "^0.18.0", "prisma": "^6.11.1", + "puppeteer-core": "^24.39.1", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "^19.0.0", "react-day-picker": "^9.8.0", "react-dom": "^19.0.0", @@ -67,6 +79,7 @@ "react-resizable-panels": "^3.0.3", "react-syntax-highlighter": "^15.6.1", "recharts": "^2.15.4", + "resend": "^6.9.4", "sharp": "^0.34.3", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", @@ -76,6 +89,7 @@ "tsx": "^4.20.3", "uuid": "^11.1.0", "vaul": "^1.1.2", + "ws": "^8.20.1", "z-ai-web-dev-sdk": "^0.0.10", "zod": "^4.0.2", "zustand": "^5.0.6" @@ -83,7 +97,10 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -2438,6 +2455,84 @@ "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2595,6 +2690,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2737,6 +2856,27 @@ "@prisma/debug": "6.17.1" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -4206,6 +4346,12 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -4279,27 +4425,6 @@ "ws": "^8.18.2" } }, - "node_modules/@supabase/realtime-js/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@supabase/storage-js": { "version": "2.76.1", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.76.1.tgz", @@ -4693,6 +4818,12 @@ "node": ">=18" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4710,6 +4841,13 @@ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -4829,6 +4967,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4853,6 +5002,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/phoenix": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", @@ -4893,6 +5052,16 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", @@ -5499,6 +5668,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5535,7 +5713,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5588,6 +5765,15 @@ "dequal": "^2.0.3" } }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -5748,6 +5934,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5787,6 +5985,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", @@ -5818,6 +6025,20 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -5832,9 +6053,95 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.21.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5864,6 +6171,24 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5881,7 +6206,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5901,6 +6225,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -5925,6 +6258,21 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -6186,6 +6534,28 @@ "node": ">=18" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -6225,16 +6595,55 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/cm6-theme-basic-light": { + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cm6-theme-basic-light": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/cm6-theme-basic-light/-/cm6-theme-basic-light-0.2.0.tgz", "integrity": "sha512-1prg2gv44sYfpHscP26uLT/ePrh0mlmVwMSoSd3zYKQ92Ab3jPRLzyCnpyOCQLJbK+YdNs4HvMRqMNYdy4pMhA==", @@ -6281,7 +6690,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6294,7 +6702,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6329,7 +6736,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/confbox": { @@ -6537,6 +6943,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6656,6 +7071,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -6707,6 +7131,20 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6716,6 +7154,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6759,6 +7206,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1581282", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", + "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", + "license": "BSD-3-Clause" + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -6839,6 +7298,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.16.12", "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", @@ -6893,6 +7361,15 @@ "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", @@ -6943,6 +7420,27 @@ } } }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -6969,6 +7467,27 @@ } } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -7237,6 +7756,15 @@ "@esbuild/win32-x64": "0.25.10" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-carriage": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/escape-carriage/-/escape-carriage-1.3.1.tgz", @@ -7256,6 +7784,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.37.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", @@ -7661,6 +8210,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -7691,7 +8253,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -7725,7 +8286,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -7747,6 +8307,15 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/exsolve": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", @@ -7768,6 +8337,26 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -7794,7 +8383,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -7806,6 +8394,12 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -7850,6 +8444,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -7873,6 +8473,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7957,6 +8566,23 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7973,6 +8599,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -8024,6 +8671,26 @@ } } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -8078,6 +8745,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -8088,6 +8764,15 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8134,6 +8819,21 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8164,6 +8864,20 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -8181,6 +8895,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8240,7 +8975,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -8250,6 +8984,12 @@ "dev": true, "license": "MIT" }, + "node_modules/gsap": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8481,6 +9221,48 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8545,8 +9327,25 @@ "node": ">=0.8.19" } }, - "node_modules/inline-style-parser": { - "version": "0.2.4", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" @@ -8603,6 +9402,15 @@ "tslib": "^2.8.0" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -8711,6 +9519,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -8795,6 +9609,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8821,6 +9644,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -8929,6 +9761,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9088,6 +9938,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", @@ -9144,6 +10003,12 @@ "node": ">=14" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9196,6 +10061,40 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9212,6 +10111,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9222,6 +10142,18 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -9251,6 +10183,41 @@ "node": ">=0.10" } }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lenis": { + "version": "1.3.18", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.18.tgz", + "integrity": "sha512-7KBl3V7vx5y1h05pu9fNFZS66I0+1eZ+zUGNNNBKtEn3BONZy+nkHWvdEe2b+zKT+6WX1x7zyOb1zbYYOs6tcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9532,6 +10499,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9560,6 +10546,42 @@ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -9567,6 +10589,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9628,6 +10662,21 @@ "node": ">=10" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lucide-react": { "version": "0.525.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", @@ -9953,6 +11002,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10708,7 +11771,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10750,6 +11812,34 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/motion-dom": { "version": "12.23.23", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", @@ -10780,6 +11870,71 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/mysql2": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -10830,6 +11985,15 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/next": { "version": "15.3.5", "resolved": "https://registry.npmjs.org/next/-/next-15.3.5.tgz", @@ -11002,6 +12166,30 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -11235,6 +12423,15 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -11324,6 +12521,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11372,6 +12607,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -11395,6 +12639,26 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz", + "integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -11431,6 +12695,11 @@ "pathe": "^2.0.3" } }, + "node_modules/png-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", + "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11441,7 +12710,13 @@ "node": ">= 0.4" } }, - "node_modules/postcss": { + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, + "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", @@ -11568,6 +12843,15 @@ "node": ">=6" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11595,6 +12879,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -11608,6 +12920,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11618,6 +12940,160 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.39.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz", + "integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.0", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1581282", + "typed-query-selector": "^2.12.1", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -12152,6 +13628,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resend": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz", + "integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.86.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -12192,6 +13698,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12203,6 +13715,22 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12259,6 +13787,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -12294,6 +13842,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -12313,9 +13867,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12373,6 +13927,42 @@ "node": ">= 0.4" } }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sharp": { "version": "0.34.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", @@ -12527,6 +14117,16 @@ "node": ">=10" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -12572,6 +14172,27 @@ } } }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -12651,6 +14272,34 @@ } } }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -12661,6 +14310,16 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -12680,6 +14339,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -12687,6 +14361,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/static-browser-server": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz", @@ -12721,12 +14405,43 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -12854,6 +14569,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -12950,6 +14677,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz", + "integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -13012,6 +14762,32 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -13022,6 +14798,30 @@ "node": ">=18" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -13289,6 +15089,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -13335,6 +15141,26 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unidiff": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unidiff/-/unidiff-1.0.4.tgz", @@ -13444,6 +15270,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -13664,6 +15499,12 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -13795,10 +15636,33 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -13833,12 +15697,58 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yjs": { "version": "13.6.27", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", diff --git a/package.json b/package.json index 11d18da..017cf45 100644 --- a/package.json +++ b/package.json @@ -50,20 +50,32 @@ "@supabase/supabase-js": "^2.76.1", "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", + "@types/ws": "^8.18.1", "axios": "^1.10.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.23.2", + "gsap": "^3.14.2", "input-otp": "^1.4.2", + "jsonwebtoken": "^9.0.3", + "lenis": "^1.3.18", "lucide-react": "^0.525.0", + "msgpackr": "^2.0.1", + "mysql2": "^3.20.0", "next": "15.3.5", "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", + "nodemailer": "^6.10.1", + "pdfkit": "^0.18.0", "prisma": "^6.11.1", + "puppeteer-core": "^24.39.1", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "^19.0.0", "react-day-picker": "^9.8.0", "react-dom": "^19.0.0", @@ -72,6 +84,7 @@ "react-resizable-panels": "^3.0.3", "react-syntax-highlighter": "^15.6.1", "recharts": "^2.15.4", + "resend": "^6.9.4", "sharp": "^0.34.3", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", @@ -81,6 +94,7 @@ "tsx": "^4.20.3", "uuid": "^11.1.0", "vaul": "^1.1.2", + "ws": "^8.20.1", "z-ai-web-dev-sdk": "^0.0.10", "zod": "^4.0.2", "zustand": "^5.0.6" @@ -88,7 +102,10 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", + "@types/nodemailer": "^7.0.11", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/public/ebooks-debug/budget-luxury-travel.pdf b/public/ebooks-debug/budget-luxury-travel.pdf new file mode 100644 index 0000000..c5c9caa Binary files /dev/null and b/public/ebooks-debug/budget-luxury-travel.pdf differ diff --git a/public/ebooks/budget-luxury-travel.pdf b/public/ebooks/budget-luxury-travel.pdf new file mode 100644 index 0000000..7142574 Binary files /dev/null and b/public/ebooks/budget-luxury-travel.pdf differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..8c73bab --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/cdn/photo-1414235077428-338989a2e8c0.jpg b/public/images/cdn/photo-1414235077428-338989a2e8c0.jpg new file mode 100644 index 0000000..0bb96d8 Binary files /dev/null and b/public/images/cdn/photo-1414235077428-338989a2e8c0.jpg differ diff --git a/public/images/cdn/photo-1436491865332-7a61a109db05.jpg b/public/images/cdn/photo-1436491865332-7a61a109db05.jpg new file mode 100644 index 0000000..a9a1cfe Binary files /dev/null and b/public/images/cdn/photo-1436491865332-7a61a109db05.jpg differ diff --git a/public/images/cdn/photo-1438761681033-6461ffad8d80.jpg b/public/images/cdn/photo-1438761681033-6461ffad8d80.jpg new file mode 100644 index 0000000..4973133 Binary files /dev/null and b/public/images/cdn/photo-1438761681033-6461ffad8d80.jpg differ diff --git a/public/images/cdn/photo-1468413253725-0d5181091f76.jpg b/public/images/cdn/photo-1468413253725-0d5181091f76.jpg new file mode 100644 index 0000000..da6acd5 Binary files /dev/null and b/public/images/cdn/photo-1468413253725-0d5181091f76.jpg differ diff --git a/public/images/cdn/photo-1472099645785-5658abf4ff4e.jpg b/public/images/cdn/photo-1472099645785-5658abf4ff4e.jpg new file mode 100644 index 0000000..4e30621 Binary files /dev/null and b/public/images/cdn/photo-1472099645785-5658abf4ff4e.jpg differ diff --git a/public/images/cdn/photo-1473116763249-2faaef81ccda.jpg b/public/images/cdn/photo-1473116763249-2faaef81ccda.jpg new file mode 100644 index 0000000..921fb6b Binary files /dev/null and b/public/images/cdn/photo-1473116763249-2faaef81ccda.jpg differ diff --git a/public/images/cdn/photo-1494790108377-be9c29b29330.jpg b/public/images/cdn/photo-1494790108377-be9c29b29330.jpg new file mode 100644 index 0000000..35b867c Binary files /dev/null and b/public/images/cdn/photo-1494790108377-be9c29b29330.jpg differ diff --git a/public/images/cdn/photo-1497215728101-856f4ea42174.jpg b/public/images/cdn/photo-1497215728101-856f4ea42174.jpg new file mode 100644 index 0000000..6af2cf5 Binary files /dev/null and b/public/images/cdn/photo-1497215728101-856f4ea42174.jpg differ diff --git a/public/images/cdn/photo-1500648767791-00dcc994a43e.jpg b/public/images/cdn/photo-1500648767791-00dcc994a43e.jpg new file mode 100644 index 0000000..8e4ed67 Binary files /dev/null and b/public/images/cdn/photo-1500648767791-00dcc994a43e.jpg differ diff --git a/public/images/cdn/photo-1504674900247-0877df9cc836.jpg b/public/images/cdn/photo-1504674900247-0877df9cc836.jpg new file mode 100644 index 0000000..5e65cb2 Binary files /dev/null and b/public/images/cdn/photo-1504674900247-0877df9cc836.jpg differ diff --git a/public/images/cdn/photo-1506794778202-cad84cf45f1d.jpg b/public/images/cdn/photo-1506794778202-cad84cf45f1d.jpg new file mode 100644 index 0000000..c0aad52 Binary files /dev/null and b/public/images/cdn/photo-1506794778202-cad84cf45f1d.jpg differ diff --git a/public/images/cdn/photo-1506929562872-bb421503ef21.jpg b/public/images/cdn/photo-1506929562872-bb421503ef21.jpg new file mode 100644 index 0000000..f78a7f0 Binary files /dev/null and b/public/images/cdn/photo-1506929562872-bb421503ef21.jpg differ diff --git a/public/images/cdn/photo-1507525428034-b723cf961d3e.jpg b/public/images/cdn/photo-1507525428034-b723cf961d3e.jpg new file mode 100644 index 0000000..da6acd5 Binary files /dev/null and b/public/images/cdn/photo-1507525428034-b723cf961d3e.jpg differ diff --git a/public/images/cdn/photo-1510097467424-192c0d76b5b6.jpg b/public/images/cdn/photo-1510097467424-192c0d76b5b6.jpg new file mode 100644 index 0000000..60a3e11 Binary files /dev/null and b/public/images/cdn/photo-1510097467424-192c0d76b5b6.jpg differ diff --git a/public/images/cdn/photo-1510097467424-192d713fd8b2.jpg b/public/images/cdn/photo-1510097467424-192d713fd8b2.jpg new file mode 100644 index 0000000..60a3e11 Binary files /dev/null and b/public/images/cdn/photo-1510097467424-192d713fd8b2.jpg differ diff --git a/public/images/cdn/photo-1512100356356-de1b84283e18.jpg b/public/images/cdn/photo-1512100356356-de1b84283e18.jpg new file mode 100644 index 0000000..f072663 Binary files /dev/null and b/public/images/cdn/photo-1512100356356-de1b84283e18.jpg differ diff --git a/public/images/cdn/photo-1514362545857-3bc16c4c7d1b.jpg b/public/images/cdn/photo-1514362545857-3bc16c4c7d1b.jpg new file mode 100644 index 0000000..406d37b Binary files /dev/null and b/public/images/cdn/photo-1514362545857-3bc16c4c7d1b.jpg differ diff --git a/public/images/cdn/photo-1517248135467-4c7edcad34c4.jpg b/public/images/cdn/photo-1517248135467-4c7edcad34c4.jpg new file mode 100644 index 0000000..0c2558f Binary files /dev/null and b/public/images/cdn/photo-1517248135467-4c7edcad34c4.jpg differ diff --git a/public/images/cdn/photo-1518105779142-d975f22f1b0a.jpg b/public/images/cdn/photo-1518105779142-d975f22f1b0a.jpg new file mode 100644 index 0000000..1efb6bb Binary files /dev/null and b/public/images/cdn/photo-1518105779142-d975f22f1b0a.jpg differ diff --git a/public/images/cdn/photo-1518638150340-f706e86654de.jpg b/public/images/cdn/photo-1518638150340-f706e86654de.jpg new file mode 100644 index 0000000..3b99318 Binary files /dev/null and b/public/images/cdn/photo-1518638150340-f706e86654de.jpg differ diff --git a/public/images/cdn/photo-1519046904884-53103b34b206.jpg b/public/images/cdn/photo-1519046904884-53103b34b206.jpg new file mode 100644 index 0000000..a9a1cfe Binary files /dev/null and b/public/images/cdn/photo-1519046904884-53103b34b206.jpg differ diff --git a/public/images/cdn/photo-1520250497591-112f2f40a3f4.jpg b/public/images/cdn/photo-1520250497591-112f2f40a3f4.jpg new file mode 100644 index 0000000..b65704d Binary files /dev/null and b/public/images/cdn/photo-1520250497591-112f2f40a3f4.jpg differ diff --git a/public/images/cdn/photo-1520454974749-611b7248ffdb.jpg b/public/images/cdn/photo-1520454974749-611b7248ffdb.jpg new file mode 100644 index 0000000..46538d5 Binary files /dev/null and b/public/images/cdn/photo-1520454974749-611b7248ffdb.jpg differ diff --git a/public/images/cdn/photo-1522529599102-193c0d76b5b6.jpg b/public/images/cdn/photo-1522529599102-193c0d76b5b6.jpg new file mode 100644 index 0000000..2e9c327 Binary files /dev/null and b/public/images/cdn/photo-1522529599102-193c0d76b5b6.jpg differ diff --git a/public/images/cdn/photo-1527734055665-8def83921139.jpg b/public/images/cdn/photo-1527734055665-8def83921139.jpg new file mode 100644 index 0000000..fd060fc Binary files /dev/null and b/public/images/cdn/photo-1527734055665-8def83921139.jpg differ diff --git a/public/images/cdn/photo-1530866495561-507c83010e82.jpg b/public/images/cdn/photo-1530866495561-507c83010e82.jpg new file mode 100644 index 0000000..3f09004 Binary files /dev/null and b/public/images/cdn/photo-1530866495561-507c83010e82.jpg differ diff --git a/public/images/cdn/photo-1540497077202-7c8a3999166f.jpg b/public/images/cdn/photo-1540497077202-7c8a3999166f.jpg new file mode 100644 index 0000000..b8a6637 Binary files /dev/null and b/public/images/cdn/photo-1540497077202-7c8a3999166f.jpg differ diff --git a/public/images/cdn/photo-1540541338287-41700207dee6.jpg b/public/images/cdn/photo-1540541338287-41700207dee6.jpg new file mode 100644 index 0000000..552163a Binary files /dev/null and b/public/images/cdn/photo-1540541338287-41700207dee6.jpg differ diff --git a/public/images/cdn/photo-1544005313-94ddf0286df2.jpg b/public/images/cdn/photo-1544005313-94ddf0286df2.jpg new file mode 100644 index 0000000..393c3a0 Binary files /dev/null and b/public/images/cdn/photo-1544005313-94ddf0286df2.jpg differ diff --git a/public/images/cdn/photo-1544161515-4ab6ce6db874.jpg b/public/images/cdn/photo-1544161515-4ab6ce6db874.jpg new file mode 100644 index 0000000..9bda222 Binary files /dev/null and b/public/images/cdn/photo-1544161515-4ab6ce6db874.jpg differ diff --git a/public/images/cdn/photo-1544551763-46a013bb70d5.jpg b/public/images/cdn/photo-1544551763-46a013bb70d5.jpg new file mode 100644 index 0000000..8b5645c Binary files /dev/null and b/public/images/cdn/photo-1544551763-46a013bb70d5.jpg differ diff --git a/public/images/cdn/photo-1550966871-3ed3cdb51f3a.jpg b/public/images/cdn/photo-1550966871-3ed3cdb51f3a.jpg new file mode 100644 index 0000000..170ad64 Binary files /dev/null and b/public/images/cdn/photo-1550966871-3ed3cdb51f3a.jpg differ diff --git a/public/images/cdn/photo-1551882547-ff40c63fe5fa.jpg b/public/images/cdn/photo-1551882547-ff40c63fe5fa.jpg new file mode 100644 index 0000000..a68acb2 Binary files /dev/null and b/public/images/cdn/photo-1551882547-ff40c63fe5fa.jpg differ diff --git a/public/images/cdn/photo-1552074284-5e88ef1aef18.jpg b/public/images/cdn/photo-1552074284-5e88ef1aef18.jpg new file mode 100644 index 0000000..fe98f56 Binary files /dev/null and b/public/images/cdn/photo-1552074284-5e88ef1aef18.jpg differ diff --git a/public/images/cdn/photo-1555396273-367ea4eb4db5.jpg b/public/images/cdn/photo-1555396273-367ea4eb4db5.jpg new file mode 100644 index 0000000..b4e3e49 Binary files /dev/null and b/public/images/cdn/photo-1555396273-367ea4eb4db5.jpg differ diff --git a/public/images/cdn/photo-1558618666-fcd25c85f82e.jpg b/public/images/cdn/photo-1558618666-fcd25c85f82e.jpg new file mode 100644 index 0000000..fd060fc Binary files /dev/null and b/public/images/cdn/photo-1558618666-fcd25c85f82e.jpg differ diff --git a/public/images/cdn/photo-1566073771259-6a8506099945.jpg b/public/images/cdn/photo-1566073771259-6a8506099945.jpg new file mode 100644 index 0000000..cd95948 Binary files /dev/null and b/public/images/cdn/photo-1566073771259-6a8506099945.jpg differ diff --git a/public/images/cdn/photo-1571896349842-33c89424de2d.jpg b/public/images/cdn/photo-1571896349842-33c89424de2d.jpg new file mode 100644 index 0000000..c3bc39e Binary files /dev/null and b/public/images/cdn/photo-1571896349842-33c89424de2d.jpg differ diff --git a/public/images/cdn/photo-1575762568427-4b23bf947729.jpg b/public/images/cdn/photo-1575762568427-4b23bf947729.jpg new file mode 100644 index 0000000..90a46fa Binary files /dev/null and b/public/images/cdn/photo-1575762568427-4b23bf947729.jpg differ diff --git a/public/images/cdn/photo-1576610616656-d3aa5d1f4534.jpg b/public/images/cdn/photo-1576610616656-d3aa5d1f4534.jpg new file mode 100644 index 0000000..145bb13 Binary files /dev/null and b/public/images/cdn/photo-1576610616656-d3aa5d1f4534.jpg differ diff --git a/public/images/cdn/photo-1580415200778-625cb1890ab5.jpg b/public/images/cdn/photo-1580415200778-625cb1890ab5.jpg new file mode 100644 index 0000000..f31172b Binary files /dev/null and b/public/images/cdn/photo-1580415200778-625cb1890ab5.jpg differ diff --git a/public/images/cdn/photo-1580846629083-02669741360a.jpg b/public/images/cdn/photo-1580846629083-02669741360a.jpg new file mode 100644 index 0000000..ec12d0a Binary files /dev/null and b/public/images/cdn/photo-1580846629083-02669741360a.jpg differ diff --git a/public/images/cdn/photo-1581710862235-eb6e05d8783f.jpg b/public/images/cdn/photo-1581710862235-eb6e05d8783f.jpg new file mode 100644 index 0000000..3f09004 Binary files /dev/null and b/public/images/cdn/photo-1581710862235-eb6e05d8783f.jpg differ diff --git a/public/images/cdn/photo-1582719508461-905c673771fd.jpg b/public/images/cdn/photo-1582719508461-905c673771fd.jpg new file mode 100644 index 0000000..6d25b08 Binary files /dev/null and b/public/images/cdn/photo-1582719508461-905c673771fd.jpg differ diff --git a/public/images/cdn/photo-1584132967334-10e028bd69f7.jpg b/public/images/cdn/photo-1584132967334-10e028bd69f7.jpg new file mode 100644 index 0000000..19dafca Binary files /dev/null and b/public/images/cdn/photo-1584132967334-10e028bd69f7.jpg differ diff --git a/public/images/cdn/photo-1585793753011-397e6e4668d6.jpg b/public/images/cdn/photo-1585793753011-397e6e4668d6.jpg new file mode 100644 index 0000000..7cff1d7 Binary files /dev/null and b/public/images/cdn/photo-1585793753011-397e6e4668d6.jpg differ diff --git a/public/images/cdn/photo-1593655600619-a88c11180241.jpg b/public/images/cdn/photo-1593655600619-a88c11180241.jpg new file mode 100644 index 0000000..f31172b Binary files /dev/null and b/public/images/cdn/photo-1593655600619-a88c11180241.jpg differ diff --git a/public/images/cdn/photo-1596436889106-be35e843f974.jpg b/public/images/cdn/photo-1596436889106-be35e843f974.jpg new file mode 100644 index 0000000..6430c7a Binary files /dev/null and b/public/images/cdn/photo-1596436889106-be35e843f974.jpg differ diff --git a/public/images/cdn/photo-1602002418816-5c0aeef426aa.jpg b/public/images/cdn/photo-1602002418816-5c0aeef426aa.jpg new file mode 100644 index 0000000..a4a9724 Binary files /dev/null and b/public/images/cdn/photo-1602002418816-5c0aeef426aa.jpg differ diff --git a/public/images/cdn/photo-1615460549969-36fa19521a4f.jpg b/public/images/cdn/photo-1615460549969-36fa19521a4f.jpg new file mode 100644 index 0000000..c3bd54c Binary files /dev/null and b/public/images/cdn/photo-1615460549969-36fa19521a4f.jpg differ diff --git a/public/images/destinations/cabo-2.jpg b/public/images/destinations/cabo-2.jpg new file mode 100644 index 0000000..ec12d0a Binary files /dev/null and b/public/images/destinations/cabo-2.jpg differ diff --git a/public/images/destinations/cabo-3.jpg b/public/images/destinations/cabo-3.jpg new file mode 100644 index 0000000..fd060fc Binary files /dev/null and b/public/images/destinations/cabo-3.jpg differ diff --git a/public/images/destinations/cabo.jpg b/public/images/destinations/cabo.jpg new file mode 100644 index 0000000..f31172b Binary files /dev/null and b/public/images/destinations/cabo.jpg differ diff --git a/public/images/destinations/cancun-2.jpg b/public/images/destinations/cancun-2.jpg new file mode 100644 index 0000000..fe98f56 Binary files /dev/null and b/public/images/destinations/cancun-2.jpg differ diff --git a/public/images/destinations/cancun.jpg b/public/images/destinations/cancun.jpg new file mode 100644 index 0000000..60a3e11 Binary files /dev/null and b/public/images/destinations/cancun.jpg differ diff --git a/public/images/destinations/puerto-vallarta-2.jpg b/public/images/destinations/puerto-vallarta-2.jpg new file mode 100644 index 0000000..90a46fa Binary files /dev/null and b/public/images/destinations/puerto-vallarta-2.jpg differ diff --git a/public/images/destinations/puerto-vallarta.jpg b/public/images/destinations/puerto-vallarta.jpg new file mode 100644 index 0000000..7cff1d7 Binary files /dev/null and b/public/images/destinations/puerto-vallarta.jpg differ diff --git a/public/images/destinations/riviera-maya.jpg b/public/images/destinations/riviera-maya.jpg new file mode 100644 index 0000000..3f09004 Binary files /dev/null and b/public/images/destinations/riviera-maya.jpg differ diff --git a/public/images/hero/beach-palm.jpg b/public/images/hero/beach-palm.jpg new file mode 100644 index 0000000..2c6662b Binary files /dev/null and b/public/images/hero/beach-palm.jpg differ diff --git a/public/images/hero/beach-sunset.jpg b/public/images/hero/beach-sunset.jpg new file mode 100644 index 0000000..977cbc3 Binary files /dev/null and b/public/images/hero/beach-sunset.jpg differ diff --git a/public/images/quote-cards/mama-01.png b/public/images/quote-cards/mama-01.png new file mode 100644 index 0000000..9f58688 Binary files /dev/null and b/public/images/quote-cards/mama-01.png differ diff --git a/public/images/quote-cards/mama-02.png b/public/images/quote-cards/mama-02.png new file mode 100644 index 0000000..560edab Binary files /dev/null and b/public/images/quote-cards/mama-02.png differ diff --git a/public/images/quote-cards/mama-03.png b/public/images/quote-cards/mama-03.png new file mode 100644 index 0000000..e84c695 Binary files /dev/null and b/public/images/quote-cards/mama-03.png differ diff --git a/public/images/quote-cards/mama-04.png b/public/images/quote-cards/mama-04.png new file mode 100644 index 0000000..afbf617 Binary files /dev/null and b/public/images/quote-cards/mama-04.png differ diff --git a/public/images/quote-cards/mama-05.png b/public/images/quote-cards/mama-05.png new file mode 100644 index 0000000..4b167b4 Binary files /dev/null and b/public/images/quote-cards/mama-05.png differ diff --git a/public/images/quote-cards/mama-06.png b/public/images/quote-cards/mama-06.png new file mode 100644 index 0000000..fd337d1 Binary files /dev/null and b/public/images/quote-cards/mama-06.png differ diff --git a/public/images/quote-cards/mama-07.png b/public/images/quote-cards/mama-07.png new file mode 100644 index 0000000..d729b03 Binary files /dev/null and b/public/images/quote-cards/mama-07.png differ diff --git a/public/images/quote-cards/mama-08.png b/public/images/quote-cards/mama-08.png new file mode 100644 index 0000000..613b4e5 Binary files /dev/null and b/public/images/quote-cards/mama-08.png differ diff --git a/public/images/showcase/certificate-design.jpg b/public/images/showcase/certificate-design.jpg new file mode 100644 index 0000000..503dbed Binary files /dev/null and b/public/images/showcase/certificate-design.jpg differ diff --git a/public/images/showcase/dashboard-screen.jpg b/public/images/showcase/dashboard-screen.jpg new file mode 100644 index 0000000..c8d22ca Binary files /dev/null and b/public/images/showcase/dashboard-screen.jpg differ diff --git a/public/images/showcase/family-vacation-joy.jpg b/public/images/showcase/family-vacation-joy.jpg new file mode 100644 index 0000000..14bbeeb Binary files /dev/null and b/public/images/showcase/family-vacation-joy.jpg differ diff --git a/public/images/showcase/infinity-pool-sunset.jpg b/public/images/showcase/infinity-pool-sunset.jpg new file mode 100644 index 0000000..7af312e Binary files /dev/null and b/public/images/showcase/infinity-pool-sunset.jpg differ diff --git a/public/images/showcase/resort-beachfront.jpg b/public/images/showcase/resort-beachfront.jpg new file mode 100644 index 0000000..df44740 Binary files /dev/null and b/public/images/showcase/resort-beachfront.jpg differ diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..234d998 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,64 @@ +# hi2b.com — Mexico Paradise Vacations + +> All-inclusive Mexico vacation certificates. 5 days and 4 nights at a luxury beachfront resort in Cancun, Cabo, Riviera Maya, or Puerto Vallarta. 2 adults plus kids under 12 free. Payment plans from $39/month. + +Mexico Paradise Vacations (hi2b.com) is a travel-certificate company. Each certificate covers a 5-day / 4-night all-inclusive stay for 2 adults at a real beachfront resort in one of four Mexican destinations, with kids under 12 staying free. The certificates are sold on a monthly payment plan or as a discounted one-time payment. Customers redeem the certificate by booking dates through the client portal after the first payment. + +## Key pages + +- [Home](https://hi2b.com/): Main landing page +- [Pay](https://hi2b.com/pay): Direct payment page (used for phone sales) +- [Affiliate Portal](https://hi2b.com/affiliate): Affiliate sign-in and dashboard +- [Client Portal](https://hi2b.com/dashboard): Customer login (certificate, billing, bookings) +- [Privacy Policy](https://hi2b.com/privacy) +- [Terms of Service](https://hi2b.com/terms) + +## Landing pages + +- [Golden Hour](https://hi2b.com/lp/golden-hour): Escape to paradise. 5 days, 4 nights all-inclusive Mexico vacation for just $39/month. +- [Midnight Tropical](https://hi2b.com/lp/midnight-tropical): Limited spots remaining. Claim your all-inclusive Mexico getaway. +- [Passport Stamp](https://hi2b.com/lp/passport-stamp): Adventure awaits. All-inclusive Mexico vacation certificates from $39/month. +- [Crystal Clear](https://hi2b.com/lp/crystal-clear): All-inclusive Mexico vacation. Simple pricing. Incredible value. +- [Fiesta](https://hi2b.com/lp/fiesta): Celebrate life with an all-inclusive Mexico vacation from $39/month. +- [The Closer](https://hi2b.com/lp/the-closer): The math doesn\ +- [Resort Preview](https://hi2b.com/lp/resort-preview): Preview luxury resorts in Cancun, Cabo, Riviera Maya & Puerto Vallarta. +- [Split Decision](https://hi2b.com/lp/split-decision): Cancun or Cabo? Pick your dream destination. All-inclusive from $39/month. +- [Calculator](https://hi2b.com/lp/calculator): See exactly how much you save vs. booking direct. The math speaks for itself. +- [Countdown](https://hi2b.com/lp/countdown): Limited time offer. Claim your all-inclusive Mexico vacation before it\ +- [The Guide](https://hi2b.com/lp/the-guide): Free guide: 5 secrets to luxury Mexico vacations on a budget. +- [Dreamboard](https://hi2b.com/lp/dreamboard): Visualize your perfect Mexico getaway. Get the free planning guide. +- [Quiz Funnel](https://hi2b.com/lp/quiz-funnel): Take the quiz to find your ideal Mexico vacation destination. +- [Social Wall](https://hi2b.com/lp/social-wall): See what real travelers are saying about Mexico Paradise Vacations. +- [Savings Journal](https://hi2b.com/lp/savings-journal): $1.30/day is less than your latte. Start saving for paradise. +- [Couples Retreat](https://hi2b.com/lp/couples-retreat): Plan the romantic Mexico getaway you\ +- [Postcards](https://hi2b.com/lp/postcards): Send yourself a postcard from the future. Mexico awaits. +- [Stress Relief](https://hi2b.com/lp/stress-relief): Escape the stress. All-inclusive Mexico vacation for your wellbeing. +- [Foodie Paradise](https://hi2b.com/lp/foodie-paradise): All-inclusive dining at world-class Mexico resorts. From $39/month. +- [Family Escape](https://hi2b.com/lp/family-escape): Family-friendly all-inclusive Mexico vacations from $39/month. +- [Last Chance](https://hi2b.com/lp/last-chance): This price disappears in minutes. All-inclusive Mexico vacation. +- [The Proof](https://hi2b.com/lp/proof): Watch real TikTok videos from travelers at our resorts. +- [VIP Access](https://hi2b.com/lp/vip-access): You\ +- [One Tap](https://hi2b.com/lp/one-tap): The simplest way to book your dream Mexico vacation. $39/mo. +- [FOMO Feed](https://hi2b.com/lp/fomo-feed): See what you\ +- [Price Lock](https://hi2b.com/lp/price-lock): After this timer expires, the price goes up. Lock it in now. +- [Before & After](https://hi2b.com/lp/before-after): See the transformation. Desk to beach in one payment. +- [Risk Free](https://hi2b.com/lp/risk-free): Try it risk-free. If you\ +- [Speed Deal](https://hi2b.com/lp/speed-deal): This deal self-destructs. All-inclusive Mexico from $1.30/day. +- [Influencer](https://hi2b.com/lp/influencer): The vacation deal going viral. Watch the videos, book the trip. +- [Bucket List](https://hi2b.com/lp/bucket-list): Life\ +- [Deal Breaker](https://hi2b.com/lp/deal-breaker): Compare us to any travel site. We win every time. +- [Escape Plan](https://hi2b.com/lp/escape-plan): Download your free Mexico vacation planning guide. +- [TikTok Vibes](https://hi2b.com/lp/tiktok-vibes): See why this deal is going viral. Get the free insider guide. +- [No Brainer](https://hi2b.com/lp/no-brainer): $1.30/day for luxury. We\ +- [Weekend Escape](https://hi2b.com/lp/weekend-escape): 5 days that will change how you think about vacations. +- [Trust Fall](https://hi2b.com/lp/trust-fall): Real reviews, real videos, real people. See for yourself. +- [Sunrise](https://hi2b.com/lp/sunrise): Imagine waking up to ocean views. Get the free travel guide. +- [Adrenaline](https://hi2b.com/lp/adrenaline): Ziplines, cenotes, ruins — plus all-inclusive luxury. From $39/mo. +- [Golden Ticket](https://hi2b.com/lp/golden-ticket): This exclusive offer won\ +- [Seat Reserved](https://hi2b.com/lp/seat-reserved): Your paradise seat is confirmed. Lock in $29/mo before the countdown ends. +- [VIP Pass](https://hi2b.com/lp/vip-pass): Private concierge, lifetime rebooking, guest upgrades. VIP cohort closes at midnight. +- [Real Traveler](https://hi2b.com/lp/real-traveler): Real traveler · Day 4 in Mexico · Same resort her friends paid $2,800 for. See her 20-second story. + +## Optional + +- [Sitemap](https://hi2b.com/sitemap.xml) \ No newline at end of file diff --git a/public/research/ad-ops-setup.html b/public/research/ad-ops-setup.html new file mode 100644 index 0000000..755f0a9 --- /dev/null +++ b/public/research/ad-ops-setup.html @@ -0,0 +1,751 @@ + + + + + + +Meta + TikTok Ad Ops Setup for hi2b.com — May 2026 + + + +
+ +
+ Research Brief · Internal +

Meta + TikTok Ad Ops Setup for hi2b.com

+

End-to-end playbook for setting up Meta Business Manager (multi-entity), installing conversion tracking, and unlocking Marketing APIs for Facebook, Instagram, and TikTok — to power AI-generated UGC ad automation.

+
+ Author: Claude (Opus 4.7) + Compiled: May 17, 2026 + For: hi2b.com paid-media + multi-entity owner + Status: Action-ready playbook +
+
+ +
+ +
+

1. TL;DR

+

If you only read this section

+
+
The goal
+

Build a closed-loop ad ops system for hi2b.com (and sister brands)

+

+ AI generates UGC-style videos (Veo + HappyHorse) → automatically uploads to Meta + TikTok Marketing APIs → creates campaigns with sensible defaults → daily cron monitors performance → auto-pauses losers, auto-scales winners. Meta Business Manager is the foundation that makes all of this work. +

+
+

Three layers, in dependency order:

+
    +
  • Layer 1 — Meta Business Manager + Pixel installed. Without this, no ad performance data exists. Estimated time: 1-2 hours per entity. Blocking everything
  • +
  • Layer 2 — Marketing API tokens obtained. Lets us programmatically create campaigns, upload videos, pull reports. Estimated time: 30 min per entity. Required before automation
  • +
  • Layer 3 — Automation pipeline built in our codebase. AI video gen → ad upload → campaign creation → monitoring. Estimated time: 1-2 days dev work. Where the real value lives
  • +
+
+ +
+

2. Multi-entity structure (your case)

+

Separate legal entities = separate Business Managers — Meta is designed for this

+ +

Because you have separate legal entities, you'll have one Business Manager per entity. Each Business Manager is sealed off from the others: separate billing, separate tax invoices, separate team permissions, separate API tokens.

+ +
+Your personal Facebook account (admin of both Business Managers) + │ + ├── Business Manager 1 — "Entity that owns hi2b.com" + │ ├── Page: Mexico Paradise Vacations + │ ├── Instagram: @mexicoparadise + │ ├── Pixel: hi2b-pixel + │ ├── Ad Account: act_111... (billed to Entity 1's card) + │ ├── Domain verified: hi2b.com + │ └── System User: hi2b-api ← API token for Entity 1 only + │ + └── Business Manager 2 — "Entity that owns 724vacation.com" + ├── Page: 724 Vacation + ├── Pixel: 724v-pixel + ├── Ad Account: act_222... (billed to Entity 2's card) + ├── Domain verified: 724vacation.com + └── System User: 724v-api ← API token for Entity 2 only +
+ +
+
Hard limit
+

+ Meta caps 2 Business Managers per personal Facebook account. If you have 3+ entities, have a co-founder/partner create the 3rd from their personal account and add you as admin. Don't open fake Facebook accounts to bypass this — it gets your real account banned. +

+
+
+ +
+

3. Meta Business Manager — step-by-step

+

Repeat this entire flow once per entity. Estimated: ~30 min per entity once you have all the docs ready.

+ +
+ STEP 1 +

Create the Business Manager 5 min

+
    +
  1. Go to business.facebook.com → "Create account"
  2. +
  3. Business name: use the legal entity name matching the EIN (e.g., 2218 Pleasant LLC), not the DBA/brand name
  4. +
  5. Your name (real name — establishes admin identity) + business email
  6. +
  7. Confirm email link Meta sends
  8. +
  9. Add business info: address, phone, website (https://hi2b.com), industry: Travel
  10. +
+
+ +
+ STEP 2 +

Add or create the Facebook Page 5 min

+
    +
  1. Settings → Pages → "Add" → either link existing Page or create new
  2. +
  3. Page name: Mexico Paradise Vacations (the brand, not the entity name)
  4. +
  5. Category: Travel Company or Travel Agency
  6. +
  7. Profile photo: use /public/images/showcase/resort-beachfront.jpg from our generated images
  8. +
+
+ +
+ STEP 3 +

Add Instagram (optional but recommended) 3 min

+
    +
  1. Settings → Instagram accounts → "Add"
  2. +
  3. Connect your @mexicoparadise handle (or create one)
  4. +
  5. Required if you want ads on Instagram Reels/Stories — highest-converting placement for travel UGC
  6. +
+
+ +
+ STEP 4 +

Create an Ad Account 5 min

+
    +
  1. Settings → Ad accounts → "Add" → "Create a new ad account"
  2. +
  3. Name: hi2b.com — Main
  4. +
  5. Time zone: match business — likely America/Chicago or America/New_York
  6. +
  7. Currency: USD
  8. +
  9. Choose "My business" (not "Another business")
  10. +
  11. Save the Ad Account ID — looks like act_123456789012345
  12. +
+
+ +
+ STEP 5 +

Add payment method 5 min

+
    +
  1. Settings → Payment Methods → "Add payment method"
  2. +
  3. Add the business card belonging to this specific entity — never share cards across entities
  4. +
  5. Set a spending limit: $500 starting cap — your safety net against runaway automation
  6. +
  7. You can raise the cap later as you gain trust in the pipeline
  8. +
+
+ +
+ STEP 6 +

Set up Meta Pixel + Conversions API 10 min · MOST IMPORTANT

+

Without this, the Marketing API can't measure ROAS, can't optimize, can't build lookalikes. Must be in place before ANY spend.

+
    +
  1. Business Manager → Events Manager
  2. +
  3. "Connect Data Sources" → "Web" → "Meta Pixel"
  4. +
  5. Name: hi2b-pixel
  6. +
  7. URL: https://hi2b.com
  8. +
  9. Choose "Install code manually"
  10. +
  11. Save your Pixel ID — a 15-16 digit number
  12. +
  13. Enable Conversions API (CAPI) in the same flow — server-side conversion sending, way more reliable than browser-only since iOS 14 (Apple ATT)
  14. +
  15. Generate CAPI Access Token — looks like EAAxxxxx... — save it securely
  16. +
+
+ +
+ STEP 7 +

Verify your domain 5 min

+
    +
  1. Business Settings → Brand Safety → Domains → "Add" → hi2b.com
  2. +
  3. Choose DNS TXT record verification (cleanest)
  4. +
  5. Meta gives you a TXT record like facebook-domain-verification=abc123def456
  6. +
  7. Add to DNS (Cloudflare / Namecheap / wherever hi2b.com DNS lives)
  8. +
  9. Come back → "Verify"
  10. +
  11. Configure 8 prioritized events for Aggregated Event Measurement: +
      +
    • Purchase — highest priority (the conversion that pays the bills)
    • +
    • AddPaymentInfo
    • +
    • InitiateCheckout
    • +
    • Lead (ebook signup)
    • +
    • CompleteRegistration
    • +
    • ViewContent
    • +
    • PageView
    • +
    • (8th slot empty for future)
    • +
    +
  12. +
+
+ +
+ STEP 8 +

Send credentials to dev (me) for codebase wiring 5 min

+

Once steps 1-7 done, paste these in chat:

+
Ad Account ID:     act_______________
+Meta Pixel ID:     _________________
+CAPI Access Token: EAA______________  (keep secure)
+

I'll then add to /opt/hi2b/.env and wire into the codebase per the Pixel plan in section 4.

+
+ +
+ STEP 9 +

Create System User for Marketing API 5 min

+
    +
  1. Business Settings → System Users → "Add"
  2. +
  3. Name: hi2b-api
  4. +
  5. Role: assign as Admin of the Ad Account
  6. +
  7. Generate long-lived access token with permissions: +
      +
    • ads_management
    • +
    • ads_read
    • +
    • business_management
    • +
    +
  8. +
  9. Save token — required for programmatic campaign creation
  10. +
+
+ +
+ STEP 10 +

Smoke test with a manual $5 campaign 30 min

+

Before any automation, manually validate the full conversion loop.

+
    +
  1. Ads Manager → Create
  2. +
  3. Objective: Sales
  4. +
  5. Daily spend cap: $5
  6. +
  7. Audience: US, age 28-55, interests: Travel, Beach vacations, All-inclusive resorts
  8. +
  9. Creative: any image from /public/images/showcase/
  10. +
  11. Destination URL: https://hi2b.com/pay
  12. +
  13. Let run 24h, watch Events Manager for Purchase events
  14. +
  15. If Purchase fires correctly → ready to scale
  16. +
+
+
+ +
+

4. Meta Pixel + CAPI install plan (what I'll wire in)

+

Hybrid browser + server tracking — most reliable post-iOS 14 ATT

+ +

Once you give me the Pixel ID + CAPI token, here's what I'll add to the codebase:

+ +
+
+

Browser-side (Meta Pixel)

+
    +
  • New <MetaPixel /> component in src/components/MetaPixel.tsx
  • +
  • Mounted in src/app/layout.tsx → fires PageView on every route
  • +
  • Fires InitiateCheckout on payment modal open
  • +
  • Fires Lead on /api/claim success (client-side)
  • +
  • Fires Purchase on payment success page
  • +
+
+
+

Server-side (Conversions API)

+
    +
  • New src/lib/meta-capi.ts helper
  • +
  • Fires Purchase from /api/payment/create on success — cannot be blocked by ad blockers or ATT
  • +
  • Sends user data hashed (sha256 email/phone) for matching
  • +
  • Sends event_id matching browser pixel → Meta dedupes automatically
  • +
  • Includes value, currency, content_ids, transaction_id
  • +
+
+
+ +
+
Why both browser AND server?
+

+ iOS Apple Tracking Transparency lets users opt out of browser tracking — typically 50-70% of iOS traffic blocks the pixel. CAPI runs server-side, can't be blocked. Sending both with matching event_id = Meta sees full conversion data + dedupes so you don't double-count. +

+
+
+ +
+

5. Meta Marketing API — what we can automate

+

Full programmatic ad ops via Graph API endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityEndpointUse case for hi2b.com
Create campaignPOST /act_X/campaignsSpin up a "Spring Sale 2026" campaign with one API call
Create ad setPOST /act_X/adsetsTarget audience, budget, placement, schedule — programmatic
Upload video creativePOST /act_X/advideosPush our AI-generated UGC videos directly to Meta
Create adPOST /act_X/adsCombine creative + ad set into a live ad
Pull insightsGET /act_X/insightsDaily cron pulls spend, CPC, ROAS per ad → our /admin/campaigns
Pause / scale adPOST /<ad_id>Auto-pause CPA > $X after Y impressions; auto-scale budget on winners
Create custom audiencePOST /act_X/customaudiencesBuild lookalike of our converters from /admin/sales
+ +

Objectives in 2026 (outcome-based)

+

+ Meta replaced the old objective taxonomy with outcome-based ones. For hi2b.com: +

+
    +
  • OUTCOME_SALES — primary objective for the certificate purchase
  • +
  • OUTCOME_LEADS — for the ebook PDF capture funnel
  • +
  • OUTCOME_AWARENESS — for cold-traffic brand-building campaigns
  • +
  • OUTCOME_TRAFFIC — for driving cheap clicks to LPs in remarketing
  • +
  • OUTCOME_ENGAGEMENT — for social-proof building on Page
  • +
+ +

Sample campaign creation call

+
POST https://graph.facebook.com/v22.0/act_123456789012345/campaigns
+
+{
+  "name": "hi2b Spring Sale 2026 — UGC Test Round 1",
+  "objective": "OUTCOME_SALES",
+  "status": "PAUSED",
+  "special_ad_categories": [],
+  "daily_budget": 5000,            // $50.00 in cents
+  "bid_strategy": "LOWEST_COST_WITHOUT_CAP",
+  "access_token": "EAA..."
+}
+ +

API access tiers

+ + + + + + +
TierWho it's forApproval needed
System UserManaging your own ads only (us)No — instant
Standard AccessSaaS managing others' adsMeta App Review (~5-7 days)
+

+ For hi2b.com use case: System User is all we need. No app review. +

+
+ +
+

6. TikTok Marketing API — what we can automate

+

Equivalent capabilities, slightly different hierarchy

+ +

Structure: Campaign → Ad Group (targeting + budget) → Ad (creative)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityEndpoint familyUse case
Create campaign/open_api/v1.3/campaign/createSame as Meta — spin up campaign with one call
Upload video/open_api/v1.3/file/video/ad/uploadPush AI-generated 9:16 UGC ads directly
Create ad group/open_api/v1.3/adgroup/createTarget audience + budget + placement (Spark Ads, In-Feed, etc.)
Create ad/open_api/v1.3/ad/createLink creative + ad group
Pull reporting/open_api/v1.3/reports/integrated/getDaily performance pulls
Pause / update ad/open_api/v1.3/ad/update/statusSame kill-switch automation as Meta
+ +

What's new in 2026 — TikTok MCP Server

+
+
NEW · April 2026
+

TikTok Ads MCP Server — AI agents can run campaigns directly

+

+ TikTok shipped a Model Context Protocol server that lets AI agents (Claude, ChatGPT, custom) connect directly to the TikTok Ads platform via natural language. Plan, launch, optimize campaigns without writing API code. We can plug Claude Code into it once it's set up. +

+
+ +

Setup

+
    +
  1. Create TikTok For Business account: ads.tiktok.com/business
  2. +
  3. Verify business — they may ask for tax docs and a domain that matches your entity
  4. +
  5. Sign up as developer: business-api.tiktok.com/portal
  6. +
  7. Create OAuth 2.0 app, get app_id + secret
  8. +
  9. Authorize advertiser → get long-lived access_token
  10. +
  11. Optional: enable TikTok Pixel on hi2b.com for conversion tracking parity with Meta
  12. +
+
+ +
+

7. Multi-entity decision framework

+

Discipline matters more than tooling for tax/legal cleanliness

+ +
+
+

Do this (clean separation)

+
    +
  • ✅ Separate Business Manager per legal entity
  • +
  • ✅ Separate business cards per entity (different bank accounts)
  • +
  • ✅ Separate Pixels per primary domain
  • +
  • ✅ Separate API tokens stored under distinct env var prefixes
  • +
  • ✅ Domain verified inside the BM of its owner entity
  • +
  • ✅ Each entity's invoices/1099s land separately
  • +
+
+
+

Don't do this (creates problems)

+
    +
  • ❌ "Just this once" — running hi2b.com ads from 724v's ad account
  • +
  • ❌ Sharing Pixels across domains — pollutes conversion data + audiences
  • +
  • ❌ Reusing the same business card across entities — piercing corporate veil
  • +
  • ❌ Creating fake personal Facebook accounts to bypass the 2-BM limit
  • +
  • ❌ Storing all API tokens in one env var — disaster if leaked
  • +
+
+
+ +

Recommended environment variables structure

+
# /opt/hi2b/.env (Entity 1 — owner of hi2b.com)
+META_BM1_NAME=2218_pleasant_llc
+META_BM1_AD_ACCOUNT=act_111...
+META_BM1_PIXEL_ID=987654321098765
+META_BM1_CAPI_TOKEN=EAA
+META_BM1_API_TOKEN=EAA
+TIKTOK_BM1_ADVERTISER_ID=...
+TIKTOK_BM1_ACCESS_TOKEN=...
+
+# Entity 2 vars only live on Entity 2's own servers
+# Don't co-mingle even in env files
+ +

Sequence to follow

+
    +
  • Week 1: Stand up Business Manager for Entity 1 (hi2b.com owner). Run one $5/day test campaign. Verify Purchase events firing in Events Manager. Do not move on until this works.
  • +
  • Week 2: Repeat the entire flow for Entity 2. Separate cards, separate Pixel, separate verification.
  • +
  • Week 3+: Build automation pipeline that routes by destination URL → calls correct entity's API.
  • +
+
+ +
+

8. End-to-end automation pipeline (the vision)

+

What we're building toward once Layer 1 (BM + Pixel) is in place

+ +
1. /admin/ads — UI for marketing to enter ad concept + persona + length + target entity
+2. POST /api/ads/generate
+3. Backend: parallel call to Veo 3.1 Fast + HappyHorse 1.0 (per ad-models brief)
+4. Both videos saved to /public/ads/<job_id>/{veo,hh}.mp4
+5. Side-by-side viewer in admin → human approves winner
+6. Auto-upload approved video to Meta + TikTok via Marketing APIs
+7. Auto-create campaign with sensible defaults:
+   - Objective: OUTCOME_SALES (Meta) / CONVERSIONS (TikTok)
+   - Audience: lookalike of /admin/sales conversions
+   - Budget: $25/day per platform per ad to start
+   - UTM tracking: ?utm_source=fb|tt&utm_campaign=<ad_id>
+8. Daily cron pulls performance via Marketing APIs → /admin/campaigns
+9. Auto-pause ads with CPA > $X after Y impressions
+10. Auto-scale budget on winners (CPA < $Y after Z conversions)
+ +
+
Cost economics
+

This is what ad agencies charge $5k–$50k/month for

+

+ Full agentic ad ops loop (creative gen + upload + campaign creation + monitoring + optimization) at roughly $500/mo in API costs + your time approving creatives. Build time: 1-2 days. Replaces creative agency, ad ops manager, and most of a media buyer. +

+
+
+ +
+

9. Gotchas & risk mitigation

+

Things that cost us if we don't plan for them

+
    +
  • Meta App Review delays. Only needed if we go beyond "managing our own ads" (System User mode is exempt). Don't apply for "Standard Access" unless we need it — keeps us flying.
  • +
  • Domain verification is per-BM. If two BMs both want to run ads to hi2b.com, you re-verify in each — or use Shared Pixels (more complex). Cleanest: one BM owns hi2b.com, the other owns its own domains.
  • +
  • Spend limits on new Ad Accounts. Meta caps new accounts at $50/day until they see clean spend history. Plan to ramp budgets gradually over 2-3 weeks before automation can scale freely.
  • +
  • AI ad disclosure policies. Both Meta and TikTok now require "Made with AI" labels on "realistic" synthetic content. Talking-head testimonials need the label. Day-in-the-life montages are gray area. Check both platforms' current policies before $5k+ spend.
  • +
  • Pixel ID is public. Anyone can read it from your HTML source. CAPI token is NOT public — server-side only. Never expose it in client code or git commits.
  • +
  • Token rotation. System User tokens are long-lived but Meta can invalidate them. Build a small /admin/api-tokens page so you can rotate without redeploying code.
  • +
  • Aggregated Event Measurement priority. The 8 events you configure in Step 7 are ordered. Purchase MUST be #1 — for iOS users, only the highest-priority event that fires gets counted.
  • +
  • Sister-brand cannibalization. If two BMs both run ads to similar audiences (US travel intenders 28-55), you'll bid against yourself in Meta's auction. Coordinate audiences across BMs or split by geography/age.
  • +
+
+ +
+

10. What I need from you next

+

Minimum unlocks the next phase of work

+ +
+
+

1. Confirm entity ownership

+

+ Which legal entity owns hi2b.com? (For BM1 naming and tax-clean billing.) +

+
+
+

2. Complete BM Steps 1-7

+

+ ~30 min on business.facebook.com. Result: 3 credentials to send me. +

+
+
+

3. Paste credentials

+

+ Ad Account ID, Pixel ID, CAPI Token. I'll wire into the codebase + deploy same day. +

+
+
+ +

After that, I can:

+
    +
  • Install Meta Pixel + CAPI on hi2b.com (Pixel + server-side conversions, fully wired to /api/payment/create, /api/claim, /api/signup)
  • +
  • Build /admin/campaigns page showing live Meta + TikTok performance
  • +
  • Wire the AI video gen pipeline (per the video-models brief) into auto-upload-to-Meta/TikTok
  • +
  • Set up Entity 2's BM in parallel once Entity 1 is verified working
  • +
+
+ +
+

11. Sources

+ +
+ + + +
+ + diff --git a/public/research/batch10.html b/public/research/batch10.html new file mode 100644 index 0000000..599e880 --- /dev/null +++ b/public/research/batch10.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 10 · Cancún Discovery Hooks + + + +
+

hi2b.com — Batch 10 · "Cancún Discovery Hooks"

+

Pre-purchase angles: "how is this legal", math reveal, skeptic, conspiratorial. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 10 · 10 ads
+ + + diff --git a/public/research/batch11.html b/public/research/batch11.html new file mode 100644 index 0000000..aa032da --- /dev/null +++ b/public/research/batch11.html @@ -0,0 +1,62 @@ + + + + + +hi2b.com — Batch 11 · Day-4 Poolside + + + +
+

hi2b.com — Batch 11 · "Day-4 Poolside"

+

Black presenter, in-the-moment experiential. "Day 4 and I'm not emotionally ready to go home." Sarah voice. Captioned variants available.

+
+

Each card uses the captioned variant. Add ?raw to use the uncaptioned versions instead.

+
+
hi2b.com · internal · batch 11 · 10 captioned ads
+ + + diff --git a/public/research/batch12.html b/public/research/batch12.html new file mode 100644 index 0000000..83f86cc --- /dev/null +++ b/public/research/batch12.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 12 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 12 · "Day 2 at the Buffet"

+

Filipino presenter, poolside buffet, day-2 experiential angle. 5 keyframes × 2 scripts. Sarah voice, InfiniteTalk lip-sync, burned-in UGC captions.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch13.html b/public/research/batch13.html new file mode 100644 index 0000000..4cc2c07 --- /dev/null +++ b/public/research/batch13.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 13 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 13 · "Couple on the Beach"

+

A couple on the beach with drinks, woman foreground speaker. 5 keyframes × 2 scripts. Sarah voice, InfiniteTalk lip-sync.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch14.html b/public/research/batch14.html new file mode 100644 index 0000000..c2194c2 --- /dev/null +++ b/public/research/batch14.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 14 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 14 · "Touring the All-Inclusive"

+

One presenter touring an all-inclusive Mexican resort — lobby, swim-up bar, suite, restaurant, infinity pool. 5 locations × 2 scripts. Sarah voice, InfiniteTalk.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch15.html b/public/research/batch15.html new file mode 100644 index 0000000..2684628 --- /dev/null +++ b/public/research/batch15.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 15 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 15 · "Older Couple in the Water"

+

Older Black couple standing in the water (fish, kids playing). Man speaking — MultiTalk multi-speaker, man masked to VO. 5 keyframes × 2 scripts.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch16.html b/public/research/batch16.html new file mode 100644 index 0000000..3dde562 --- /dev/null +++ b/public/research/batch16.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 16 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 16 · "Honeymoon Couple"

+

Newlywed couple on honeymoon — woman speaking. 5 keyframes × 2 scripts. Sarah voice, MultiTalk masking (woman→VO, husband→silence).

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch17.html b/public/research/batch17.html new file mode 100644 index 0000000..2b2d4d6 --- /dev/null +++ b/public/research/batch17.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 17 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 17 · "Single Mom + Kids"

+

Single mom with 2 kids at the resort — affordability + family angle. 5 keyframes × 2 scripts. Sarah voice, single-speaker InfiniteTalk.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch18.html b/public/research/batch18.html new file mode 100644 index 0000000..5d36619 --- /dev/null +++ b/public/research/batch18.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 18 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 18 · "Snowbird Retirees Escape Winter"

+

Retired couple in their sixties escaping winter at a Mexican resort. Woman speaking — MultiTalk masking. 5 keyframes × 2 scripts. Laura voice.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch19.html b/public/research/batch19.html new file mode 100644 index 0000000..a69e564 --- /dev/null +++ b/public/research/batch19.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 19 · Day-2 Buffet Ads + + + +
+

hi2b.com — Batch 19 · "Cancun vs Cabo Comparison"

+

Side-by-side destination comparison: Cancun, Cabo, Riviera Maya, Puerto Vallarta. Single-speaker decision helper. 5 keyframes × 2 scripts. Sarah voice.

+
+
+
hi2b.com · internal · batch 12 · 10 captioned ads
+ + + diff --git a/public/research/batch20.html b/public/research/batch20.html new file mode 100644 index 0000000..f8006dc --- /dev/null +++ b/public/research/batch20.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 20 · Hour Test + + + +
+

hi2b.com — Batch 20 · "Hour Test"

+

Hour-to-Paradise spin. Sarah reframes the 60-90min presentation as "the test you take to unlock 5 days in Mexico." 5 keyframes (v13) × 2 scripts. Single-speaker InfiniteTalk.

+
+
+
hi2b.com · internal · batch 20 · 10 ads
+ + + diff --git a/public/research/batch21.html b/public/research/batch21.html new file mode 100644 index 0000000..e22b1c5 --- /dev/null +++ b/public/research/batch21.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 21 · What Happens In That Hour + + + +
+

hi2b.com — Batch 21 · "What Happens In That Hour"

+

Honeymoon couple transparently walks the viewer through the actual 60-90min vacation-membership presentation — the room, the coffee, the pitch, the walkout. 5 keyframes (v10) × 2 scripts. MultiTalk couple, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch22.html b/public/research/batch22.html new file mode 100644 index 0000000..13edaf2 --- /dev/null +++ b/public/research/batch22.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 22 · I Set a Timer + + + +
+

hi2b.com — Batch 22 · "I Set a Timer"

+

Deadpan-humor angle. Sarah sets a 90-min timer when they sit her down and narrates the countdown — 15 min, 30 min, 60 min ask, 72 min done. 5 keyframes (v13) × 2 scripts.

+
+
+
hi2b.com · internal · batch 22 · 10 ads
+ + + diff --git a/public/research/batch23.html b/public/research/batch23.html new file mode 100644 index 0000000..d02f5fb --- /dev/null +++ b/public/research/batch23.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 23 · Hour-Per-Day Math + + + +
+

hi2b.com — Batch 23 · "Hour-Per-Day Math"

+

Laura (mature warm female) does the math out loud. 90min / 5 days = 18min "cost" per day. Comparisons: Airbnb cleaning fee, TSA line, cruise boarding. 5 keyframes (v13) × 2 scripts.

+
+
+
hi2b.com · internal · batch 23 · 10 ads
+ + + diff --git a/public/research/batch24.html b/public/research/batch24.html new file mode 100644 index 0000000..7e3c803 --- /dev/null +++ b/public/research/batch24.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 24 · Hour Worth Five Days + + + +
+

hi2b.com — Batch 24 · "Hour Worth Five Days"

+

Couple banter — he was the skeptic, she insisted on going. They went, they laughed, they got the trip. Resolves to "best hour we've ever spent together." 5 keyframes (v10) × 2 scripts. MultiTalk couple, woman speaking.

+
+
+
hi2b.com · internal · batch 24 · 10 ads
+ + + diff --git a/public/research/batch25.html b/public/research/batch25.html new file mode 100644 index 0000000..e241367 --- /dev/null +++ b/public/research/batch25.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 25 · Hour Test + + + +
+

hi2b.com — Batch 25 · "Hour from Hell? Nope"

+

Hour-to-Paradise spin. Sarah reframes the 60-90min presentation as "the test you take to unlock 5 days in Mexico." 5 keyframes (v13) × 2 scripts. Single-speaker InfiniteTalk.

+
+
+
hi2b.com · internal · batch 20 · 10 ads
+ + + diff --git a/public/research/batch26.html b/public/research/batch26.html new file mode 100644 index 0000000..10d21e8 --- /dev/null +++ b/public/research/batch26.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 26 · What Happens In That Hour + + + +
+

hi2b.com — Batch 26 · "The 90-Minute Date"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch27.html b/public/research/batch27.html new file mode 100644 index 0000000..f4d734c --- /dev/null +++ b/public/research/batch27.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 27 · Hour Test + + + +
+

hi2b.com — Batch 27 · "Hour Before Paradise"

+

Hour-to-Paradise spin. Sarah reframes the 60-90min presentation as "the test you take to unlock 5 days in Mexico." 5 keyframes (v13) × 2 scripts. Single-speaker InfiniteTalk.

+
+
+
hi2b.com · internal · batch 20 · 10 ads
+ + + diff --git a/public/research/batch28.html b/public/research/batch28.html new file mode 100644 index 0000000..e20db5e --- /dev/null +++ b/public/research/batch28.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 28 · What Happens In That Hour + + + +
+

hi2b.com — Batch 28 · "Just Came Out"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch29.html b/public/research/batch29.html new file mode 100644 index 0000000..4aba7bb --- /dev/null +++ b/public/research/batch29.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 29 · What Happens In That Hour + + + +
+

hi2b.com — Batch 29 · "We Came Back"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch30.html b/public/research/batch30.html new file mode 100644 index 0000000..2146d5c --- /dev/null +++ b/public/research/batch30.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 30 · What Happens In That Hour + + + +
+

hi2b.com — Batch 30 · "Surprise Trip"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch31.html b/public/research/batch31.html new file mode 100644 index 0000000..354e8e9 --- /dev/null +++ b/public/research/batch31.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 31 · What Happens In That Hour + + + +
+

hi2b.com — Batch 31 · "Empty Nesters Reclaim"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch32.html b/public/research/batch32.html new file mode 100644 index 0000000..fa4bdcd --- /dev/null +++ b/public/research/batch32.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 32 · What Happens In That Hour + + + +
+

hi2b.com — Batch 32 · "Wedding Gift, 5 Years Later"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch33.html b/public/research/batch33.html new file mode 100644 index 0000000..47355c0 --- /dev/null +++ b/public/research/batch33.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 33 · What Happens In That Hour + + + +
+

hi2b.com — Batch 33 · "Couple's Pact"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch34.html b/public/research/batch34.html new file mode 100644 index 0000000..0675d84 --- /dev/null +++ b/public/research/batch34.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 34 · What Happens In That Hour + + + +
+

hi2b.com — Batch 34 · "Just Engaged"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch35.html b/public/research/batch35.html new file mode 100644 index 0000000..bf7ee00 --- /dev/null +++ b/public/research/batch35.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 35 · What Happens In That Hour + + + +
+

hi2b.com — Batch 35 · "Wife Surprised Husband"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch36.html b/public/research/batch36.html new file mode 100644 index 0000000..a62d481 --- /dev/null +++ b/public/research/batch36.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 36 · What Happens In That Hour + + + +
+

hi2b.com — Batch 36 · "Vow Renewal"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch37.html b/public/research/batch37.html new file mode 100644 index 0000000..0f257f6 --- /dev/null +++ b/public/research/batch37.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 37 · What Happens In That Hour + + + +
+

hi2b.com — Batch 37 · "First Trip After Baby"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch38.html b/public/research/batch38.html new file mode 100644 index 0000000..4d86480 --- /dev/null +++ b/public/research/batch38.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 38 · What Happens In That Hour + + + +
+

hi2b.com — Batch 38 · "Almost Didn't Book"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch39.html b/public/research/batch39.html new file mode 100644 index 0000000..1abdad0 --- /dev/null +++ b/public/research/batch39.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 39 · What Happens In That Hour + + + +
+

hi2b.com — Batch 39 · "Brought Our Parents"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch40.html b/public/research/batch40.html new file mode 100644 index 0000000..ab97891 --- /dev/null +++ b/public/research/batch40.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 40 · What Happens In That Hour + + + +
+

hi2b.com — Batch 40 · "He Proposed Here Last Year"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch41.html b/public/research/batch41.html new file mode 100644 index 0000000..8470082 --- /dev/null +++ b/public/research/batch41.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 41 · What Happens In That Hour + + + +
+

hi2b.com — Batch 41 · "Bought 3 Certificates"

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch42.html b/public/research/batch42.html new file mode 100644 index 0000000..c86f777 --- /dev/null +++ b/public/research/batch42.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 42 · What Happens In That Hour + + + +
+

hi2b.com — Batch 42 · "I Hate Travel But..."

+

Couple treats the 60-90min presentation like a date — coffee, snacks, conversation, jokes. Less car dealership, more hotel breakfast. v10 reuse, woman speaking.

+
+
+
hi2b.com · internal · batch 21 · 10 ads
+ + + diff --git a/public/research/batch43.html b/public/research/batch43.html new file mode 100644 index 0000000..431c7fc --- /dev/null +++ b/public/research/batch43.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 43 · Hour Play-by-Play + + + +
+

hi2b.com — Batch 43 · "Hour Play-by-Play"

+

Honest minute-by-minute walk-through of the 60-90 min resort presentation — demystifies the "catch." Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 43 · 10 ads
+ + + diff --git a/public/research/batch44.html b/public/research/batch44.html new file mode 100644 index 0000000..d8e2645 --- /dev/null +++ b/public/research/batch44.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 44 · The Real Catches + + + +
+

hi2b.com — Batch 44 · "The Real Catches"

+

Honest objection-first: the 3 genuine conditions (the hour, date availability, the upsell) then why it's still worth it. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 44 · 10 ads
+ + + diff --git a/public/research/batch45.html b/public/research/batch45.html new file mode 100644 index 0000000..1241ac6 --- /dev/null +++ b/public/research/batch45.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 45 · Your DMs Answered + + + +
+

hi2b.com — Batch 45 · "Your DMs Answered"

+

FAQ-style — Sarah answers the most common DM questions/objections: is it real, hidden fees, the catch, destinations, kids free, flights, timeshare. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 45 · 10 ads
+ + + diff --git a/public/research/batch46.html b/public/research/batch46.html new file mode 100644 index 0000000..20f0fbc --- /dev/null +++ b/public/research/batch46.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 46 · Stop Overthinking It + + + +
+

hi2b.com — Batch 46 · "Stop Overthinking It"

+

Decisive anti-analysis-paralysis push — $249 stakes, worst-case is one hour, no perfect time, just pick the dates. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 46 · 10 ads
+ + + diff --git a/public/research/batch47.html b/public/research/batch47.html new file mode 100644 index 0000000..8a1efae --- /dev/null +++ b/public/research/batch47.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 47 · Booked On My Lunch Break + + + +
+

hi2b.com — Batch 47 · "Booked On My Lunch Break"

+

Speed/ease angle — ~5 min checkout, no phone call, instant confirmation, pick dates later, all on your phone. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 47 · 10 ads
+ + + diff --git a/public/research/batch48.html b/public/research/batch48.html new file mode 100644 index 0000000..70077a1 --- /dev/null +++ b/public/research/batch48.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 48 · What I'd Tell My Past Self + + + +
+

hi2b.com — Batch 48 · "What I'd Tell My Past Self"

+

Regret-reversal / aspirational — wish we'd done it sooner, money comes back but time doesn't, kids grow fast, this is your permission. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 48 · 10 ads
+ + + diff --git a/public/research/batch49.html b/public/research/batch49.html new file mode 100644 index 0000000..7dfd7c8 --- /dev/null +++ b/public/research/batch49.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 49 · Girls Trip on a Budget + + + +
+

hi2b.com — Batch 49 · "Girls Trip on a Budget"

+

Friend-group angle — the group chat trip that finally happened: everyone buys their own cert, no planner burnout, no bill fights, kids free. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 49 · 10 ads
+ + + diff --git a/public/research/batch50.html b/public/research/batch50.html new file mode 100644 index 0000000..1cdd3ac --- /dev/null +++ b/public/research/batch50.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 50 · Anniversary Reset + + + +
+

hi2b.com — Batch 50 · "Anniversary Reset"

+

Long-married couple angle — using the cheap trip to reconnect: off autopilot, cheaper than therapy, kids free but still couple-time, felt like newlyweds. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 50 · 10 ads
+ + + diff --git a/public/research/batch51.html b/public/research/batch51.html new file mode 100644 index 0000000..e81d13a --- /dev/null +++ b/public/research/batch51.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 51 · Solo Reset Trip + + + +
+

hi2b.com — Batch 51 · "Solo Reset Trip"

+

Solo-traveler / self-care angle — going alone to recharge, no compromises, safe & easy, came back a better everything, yearly ritual. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 51 · 10 ads
+ + + diff --git a/public/research/batch52.html b/public/research/batch52.html new file mode 100644 index 0000000..913a94c --- /dev/null +++ b/public/research/batch52.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 52 · Skeptic Converted + + + +
+

hi2b.com — Batch 52 · "Skeptic Converted"

+

Hard skeptic-to-believer arc — assumed scam, did the research, looked for the trap, the hour proved it's legit, converted the skeptic husband, now an evangelist. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 52 · 10 ads
+ + + diff --git a/public/research/batch53.html b/public/research/batch53.html new file mode 100644 index 0000000..9750c29 --- /dev/null +++ b/public/research/batch53.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 53 · Bucket List Without the Price + + + +
+

hi2b.com — Batch 53 · "Bucket List Without the Price"

+

Aspirational bucket-list angle — cross off the dream resort trip for a fraction of the expected cost; you don't have to be rich; do it now, not at 70. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 53 · 10 ads
+ + + diff --git a/public/research/batch54.html b/public/research/batch54.html new file mode 100644 index 0000000..10190e7 --- /dev/null +++ b/public/research/batch54.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 54 · Last-Minute Escape + + + +
+

hi2b.com — Batch 54 · "Last-Minute Escape"

+

Spontaneity / burnout angle — booked on impulse when fried, cheap enough to be spontaneous, no overthinking, a mental-health reset, came back recharged. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 54 · 10 ads
+ + + diff --git a/public/research/batch55.html b/public/research/batch55.html new file mode 100644 index 0000000..59fee4d --- /dev/null +++ b/public/research/batch55.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 55 · Honeymoon on a Budget + + + +
+

hi2b.com — Batch 55 · "Honeymoon on a Budget"

+

Newlyweds / engaged angle — dream honeymoon without the price tag, wedding drained savings, still romantic, save money for the marriage. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 55 · 10 ads
+ + + diff --git a/public/research/batch56.html b/public/research/batch56.html new file mode 100644 index 0000000..0e650a2 --- /dev/null +++ b/public/research/batch56.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 56 · Retirement Travel Hack + + + +
+

hi2b.com — Batch 56 · "Retirement Travel Hack"

+

Retired / fixed-income angle — stretch a pension into more trips, the hour's easy when you're retired, grandkids free, do the list while healthy, all-inclusive is easy on the knees. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 56 · 10 ads
+ + + diff --git a/public/research/batch57.html b/public/research/batch57.html new file mode 100644 index 0000000..ffbd848 --- /dev/null +++ b/public/research/batch57.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 57 · Dad Who Hates Spending + + + +
+

hi2b.com — Batch 57 · "Dad Who Hates Spending"

+

Frugal-husband POV — the cheapskate finally approved a trip; the price won him over, he sat through the hour gladly, kids free sealed it, now he won't stop bragging. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 57 · 10 ads
+ + + diff --git a/public/research/batch58.html b/public/research/batch58.html new file mode 100644 index 0000000..81f92f8 --- /dev/null +++ b/public/research/batch58.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 58 · I Took My Mom + + + +
+

hi2b.com — Batch 58 · "I Took My Mom"

+

Adult-child / give-back angle — take your aging parent while they can still enjoy it, her face at the ocean, best money ever spent, three generations + kids free. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 58 · 10 ads
+ + + diff --git a/public/research/batch59.html b/public/research/batch59.html new file mode 100644 index 0000000..88d17b1 --- /dev/null +++ b/public/research/batch59.html @@ -0,0 +1,59 @@ + + + + + +hi2b.com — Batch 59 · Winter Escape + + + +
+

hi2b.com — Batch 59 · "Winter Escape"

+

Seasonal angle — beat the cold, gray winter; vitamin sea, kill the February blues, a cheap warm getaway while everyone else scrapes ice. One honest hour, five days of sun, kids free. Sarah voice. 10 single-speaker ads.

+
+
+
hi2b.com · internal · batch 59 · 10 ads
+ + + diff --git a/public/research/batches-index.html b/public/research/batches-index.html new file mode 100644 index 0000000..110bda6 --- /dev/null +++ b/public/research/batches-index.html @@ -0,0 +1,446 @@ + + + + + +hi2b.com — UGC Ad Batches + + + + +
+

hi2b.com — UGC Ad Batches

+

All AI-generated TikTok UGC ads. 5 keyframes × 2 scripts per batch, Fish Audio voiceover, InfiniteTalk lip-sync.

+
+ +
+ + +
Batch 10 · 10 videos
+

Cancún woman — discovery hooks

+

Pre-purchase angles: "how is this legal", math reveal, skeptic, conspiratorial. Sarah voice.

+
livesingle-speaker
+
+ + +
Batch 11 · 10 videos
+

Black presenter — day-4 poolside

+

In-the-moment experiential. "Day 4 and I'm not emotionally ready to go home." Sarah voice.

+
livesingle-speaker
+
+ + +
Batch 12 · 10 videos
+

Filipino presenter — day-2 buffet

+

Food/buffet-forward. "I've already eaten my money's worth." Sarah voice.

+
livesingle-speaker
+
+ + +
Batch 13 · 10 videos
+

Couples on the beach with drinks

+

"We" framing — anniversary, best decision, almost-didn't-book. Multi-speaker (woman talks). Sarah voice.

+
livemultitalk
+
+ + +
Batch 14 · 10 videos
+

Touring the all-inclusive resort

+

5 locations × 2 — lobby, swim-up bar, suite, beach restaurant, infinity pool. Sarah voice.

+
livesingle-speaker
+
+ + +
Batch 15 · 10 videos
+

Older Black couple in the water

+

Mature man speaking — "after 30 years we finally did it." Sand-blending fish, kids playing ambiance.

+
livemultitalk
+
+ + +
Batch 16 · 10 videos
+

Honeymoon couple — just married

+

"Best decision we made as a married couple." Newlyweds POV, almost-waited, husband-best, to-newlyweds CTA.

+
livemultitalk
+
+ + +
Batch 17 · 10 videos
+

Single mom + kids

+

"I made this happen by myself." Since-divorce, kids' faces, before-they're-grown, to-other-moms CTA.

+
livesingle-speaker
+
+ + +
Batch 18 · 10 videos
+

Snowbird retirees escape winter

+

"Best retirement decision." Older skeptic, vs-cruises, forty-years, first-morning POV, to-other-snowbirds CTA.

+
livemultitalk
+
+ + +
Batch 19 · 10 videos
+

Cancún vs Cabo — side-by-side

+

Same certificate, two vibes. Vibe-split, why-Cancun-first, why-Cabo-next, with-kids vs grown-ups, Pacific vs Caribbean.

+
livesingle-speaker
+
+ +
+ +
"Hour to Paradise" series · brand-pillar spin
+
+ + +
Batch 20 · 10 scripts
+

The Hour Test

+

"There's a test you take to unlock 5 days in Mexico." Reframes the 60-90min presentation as the only catch. v13 reuse, Sarah.

+
queued · Inst 1single-speaker
+
+ + +
Batch 21 · 10 scripts
+

What Happens In That Hour

+

Honeymoon couple walks through the actual presentation — the room, coffee, the pitch, the walkout. Transparency play. v10 reuse.

+
livemultitalk
+
+ + +
Batch 22 · 10 scripts
+

I Set a Timer

+

Deadpan humor — Sarah times the 90-min presentation, narrates the countdown. 15→30→60→72 min done. v13 reuse.

+
queued · Inst 1single-speaker
+
+ + +
Batch 23 · 10 scripts
+

Hour-Per-Day Math

+

Laura (mature warm female) does the math out loud — 18min "cost" per day. Comparisons: Airbnb cleaning fee, TSA, cruise boarding. v13 reuse.

+
queued · Inst 1single-speaker
+
+ + +
Batch 24 · 10 scripts
+

Hour Worth Five Days

+

Couple banter — he was skeptical, she insisted. They went, he became the evangelist. "Best hour we ever spent together." v10 reuse.

+
livemultitalk
+
+ + +
Batch 25 · 10 scripts
+

Hour from Hell? Nope

+

Inverts every timeshare horror story — no locked doors, no shouting, no follow-ups. "I almost felt cheated by how easy it was." v13 reuse.

+
queued · Inst 1single-speaker
+
+ + +
Batch 26 · 10 scripts
+

The 90-Minute Date

+

Couple dresses up, makes the presentation a date. Coffee bar, conversation, jokes. "Better than half our recent dinners." v10 reuse.

+
livemultitalk
+
+ + +
Batch 27 · 10 scripts
+

Hour Before Paradise

+

Anticipation/countdown POV — Sarah at the welcome room with the pool visible through the window, husband pacing in his swim shorts. T-minus 90 min. v13 reuse.

+
queued · Inst 1single-speaker
+
+ + +
Batch 33 · 10 videos
+

Couple's Pact

+

Couple wrote 3 rules on a napkin at the airport — no buying, keep it short, polite not engaged. Husband's secret rule: bring a snack. 65 min flat. v10 reuse.

+
livemultitalk
+
+ + +
Batch 28 · 10 videos
+

Just Came Out

+

Real-time post-presentation POV — couple selfie-streams in the hallway right after walking out at minute 67. Wristbands on, husband grin, pool next. v10 reuse.

+
livemultitalk
+
+ + +
Batch 29 · 10 videos
+

We Came Back

+

10th-anniversary couple returning for second certificate (Cancún → Cabo). Knows the drill, 55min not 75. "This is our thing now." v10 reuse.

+
livemultitalk
+
+ + +
Batch 30 · 10 videos
+

Surprise Trip

+

Husband secretly bought the certificate, prepped for the hour solo, wife found out at the airport. Wife narrates from the resort. v10 reuse.

+
livemultitalk
+
+ + +
Batch 31 · 10 videos
+

Empty Nesters Reclaim

+

60yo couple, kids in college, first trip just them in 20 years. The presentation hour became their quiet reset — first hand-hold in years. v10 reuse.

+
livemultitalk
+
+ + +
Batch 32 · 10 videos
+

Wedding Gift, 5 Years Later

+

Couple finally redeeming the certificate Aunt Pat gave at their wedding 5 years ago. "She didn't pick an object — she picked an experience." v10 reuse.

+
livemultitalk
+
+ + +
Batch 34 · 10 videos
+

Just Engaged

+

Newly engaged couple taking the engagement trip BEFORE the wedding — testing how they travel together. "Survive 90 min of sales, survive marriage." v10 reuse.

+
livemultitalk
+
+ + +
Batch 35 · 10 videos
+

Wife Surprised Husband

+

Gender flip on b30 — wife secretly bought the certificate, prepped the hour solo, said the no thanks first. Take-charge energy. v10 reuse.

+
livemultitalk
+
+ + +
Batch 36 · 10 videos
+

Vow Renewal

+

20-year couple back for beach vow renewal ceremony. "Better than the original wedding — no parents fighting." v10 reuse.

+
livemultitalk
+
+ + +
Batch 37 · 10 videos
+

First Trip After Baby

+

New parents left 8mo baby with grandma. Postpartum-mom emotional angle — relearning how to be a couple, first quiet dinner in 8 months. v10 reuse.

+
livemultitalk
+
+ + +
Batch 38 · 10 videos
+

Almost Didn't Book

+

Couple almost cancelled 3 times — pushed through, friends who DID cancel are now pool-envying via text. "Pull the trigger" CTA. v10 reuse.

+
livemultitalk
+
+ + +
Batch 39 · 10 videos
+

Brought Our Parents

+

Couple + parents trip, 2 certificates, multi-gen at same resort. "Gen-X parents loved it more than we did." v10 reuse.

+
livemultitalk
+
+ + +
Batch 40 · 10 videos
+

He Proposed Here Last Year

+

Couple returning to the engagement spot for one last pre-wedding quiet trip. Staff remembered them, sent champagne. v10 reuse.

+
livemultitalk
+
+ + +
Batch 41 · 10 videos
+

Bought 3 Certificates

+

Couple bulk-bought 3 certs, gifted 2 to friend couples, using 1 themselves. "Cheaper than wedding flowers." v10 reuse.

+
livemultitalk
+
+ + +
Batch 42 · 10 videos
+

I Hate Travel But...

+

Anti-travel wife angle — all-inclusive removes all decisions, introvert paradise, husband suspicious she's not complaining. v10 reuse.

+
livemultitalk
+
+ + +
Batch 43 · 10 videos
+

Hour Play-by-Play

+

Honest minute-by-minute walk-through of the 60-90 min resort presentation — demystifies the "catch," tells you exactly what to say. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 44 · 10 videos
+

The Real Catches

+

Honest objection-first — the 3 genuine conditions (the hour, date availability, the upsell sampler) then why it's still worth it. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 45 · 10 videos
+

Your DMs Answered

+

FAQ-style — answers the top DM objections one per video: is it real, hidden fees, the catch, destinations, kids free, flights, timeshare. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 46 · 10 videos
+

Stop Overthinking It

+

Decisive anti-analysis-paralysis push — $249 stakes, worst case is one hour, no perfect time, just pick the dates, future-you is grateful. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 47 · 10 videos
+

Booked On My Lunch Break

+

Speed/ease — ~5 min checkout, no phone call, instant confirmation, pick dates later, all on your phone, best impulse buy. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 48 · 10 videos
+

What I'd Tell My Past Self

+

Regret-reversal — wish we'd gone sooner, money comes back but time doesn't, kids grow fast, this is your permission. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 49 · 10 videos
+

Girls Trip on a Budget

+

Friend-group angle — the group-chat trip that finally happened: everyone buys their own cert, no planner burnout, no bill fights, kids free, planning round two. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 50 · 10 videos
+

Anniversary Reset

+

Long-married couple angle — off autopilot, cheaper than therapy, kids free but still couple-time, no money fights, felt like newlyweds again. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 51 · 10 videos
+

Solo Reset Trip

+

Solo-traveler / self-care angle — go alone to recharge, no compromises, safe & easy, came back a better everything, yearly ritual. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 52 · 10 videos
+

Skeptic Converted

+

Hard skeptic-to-believer arc — assumed scam, did the research, looked for the trap, the hour proved it legit, converted the skeptic husband, now an evangelist. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 53 · 10 videos
+

Bucket List Without the Price

+

Aspirational angle — cross off the dream resort trip for a fraction of the expected cost; you don't have to be rich; do it now not at 70; kids saw the ocean. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 54 · 10 videos
+

Last-Minute Escape

+

Spontaneity / burnout angle — booked on impulse when fried, cheap enough to be spontaneous, no overthinking, a mental-health reset, came back recharged. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 55 · 10 videos
+

Honeymoon on a Budget

+

Newlyweds / engaged angle — dream honeymoon without the price tag, wedding drained savings, still romantic, save the money for the marriage. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 56 · 10 videos
+

Retirement Travel Hack

+

Retired / fixed-income angle — stretch a pension into more trips, the hour's easy when retired, grandkids free, do the list while healthy, easy on the knees. Single-speaker, v13 reuse.

+
livesingle
+
+ + +
Batch 57 · 10 videos
+

Dad Who Hates Spending

+

Frugal-husband POV — the cheapskate finally approved a trip; the price won him over, he sat through the hour gladly, kids free sealed it, now he brags about the deal. Single-speaker, v13 reuse.

+
queuedsingle
+
+ + +
Batch 58 · 10 videos
+

I Took My Mom

+

Adult-child / give-back angle — take your aging parent while they can still enjoy it, her face at the ocean, best money ever spent, three generations + kids free. Single-speaker, v13 reuse.

+
queuedsingle
+
+ + +
Batch 59 · 10 videos
+

Winter Escape

+

Seasonal angle — beat the cold, gray winter; vitamin sea, kill the February blues, a cheap warm getaway while everyone else scrapes ice. One honest hour, five days of sun, kids free. Single-speaker, v13 reuse.

+
queuedsingle
+
+ +
+ +
Strategy & planning
+ + +
One-offs & tooling
+ + +
hi2b.com · internal batch index
+ + + diff --git a/public/research/content-strategy.html b/public/research/content-strategy.html new file mode 100644 index 0000000..6a2e271 --- /dev/null +++ b/public/research/content-strategy.html @@ -0,0 +1,204 @@ + + + + + +hi2b.com — Content Strategy · An Hour to Paradise + + + + +
+

hi2b.com — Content Strategy

+

Brand spine + three concrete content sprints, all built around one honest positioning.

+
+ +
+ + +
+
brand pillar · runs through all content
+

An Hour to Paradise

+
"Give us one hour. We give you five days."
+

The hi2b certificate includes a 60-90 minute vacation-membership presentation at the resort. Every competitor in this category hides that fact behind asterisks and small print. We do the opposite — we put it in the headline and make it the proof of how the math works.

+
1 hour of your time · = · 5 days · 4 nights · all-inclusive · 2 adults · kids free
+

The whole content strategy below treats this honesty as a feature, not a defect. Every blog post, every destination guide, every comparison page closes the loop with: "yes, there is an hour-long presentation — here's exactly what it is and why it makes the price possible." This kills the #1 objection at the top of the funnel instead of letting visitors find it as a surprise on Reddit.

+
+ + +
+

Context we have

+
+
Product
All-inclusive Mexico vacation certificates · $29/mo (10 mo, $290 total) or $249 one-time
+
Coverage
5 days / 4 nights · 2 adults · kids under 12 free
+
Required
60-90 minute vacation-membership presentation on-resort — the "hour to paradise"
+
Destinations
Cancun, Cabo, Riviera Maya, Puerto Vallarta
+
Audience
"I want a real vacation but I think I can't afford one"
+
Current footprint
43 LPs · trust pages live (/about, /faq, /reviews) · no blog yet
+
Missing for full data-driven plan
GA/Cloudflare traffic data · sales call transcripts · explicit competitor list
+
+
+ + +
+Option A · recommended first +

Destination hub-and-spoke

+

Four pillar pages + 3 spokes each = 16 articles. Awareness-stage SEO + trust signal + clean funnels to LPs.

+
+ Type: searchable + Buyer stage: awareness + Effort: ~1 day per hub + ½ day per spoke + Payoff: compounds 6-12 months +
+
/destinations/cancun (hub) — "Cancun All-Inclusive: The 2026 Honest Guide"
+├── /destinations/cancun/best-time-to-go
+├── /destinations/cancun/with-kids
+├── /destinations/cancun/all-inclusive-vs-airbnb
+└── /destinations/cancun/the-presentation-explained   ← Hour to Paradise spoke
+
+/destinations/cabo (hub)
+├── /destinations/cabo/vs-cancun
+├── /destinations/cabo/adults-only
+├── /destinations/cabo/november-to-april
+└── /destinations/cabo/the-presentation-explained
+
+/destinations/riviera-maya (hub)
+├── /destinations/riviera-maya/cenotes-and-ruins
+├── /destinations/riviera-maya/playa-del-carmen-vs-tulum
+├── /destinations/riviera-maya/family-resorts
+└── /destinations/riviera-maya/the-presentation-explained
+
+/destinations/puerto-vallarta (hub)
+├── /destinations/puerto-vallarta/old-town
+├── /destinations/puerto-vallarta/vs-cancun
+├── /destinations/puerto-vallarta/budget-week
+└── /destinations/puerto-vallarta/the-presentation-explained
+
+Why first. Every LP says "pick from 4 destinations" but there is nowhere on the site to learn about them. Each hub will rank for [destination] all inclusive queries, lift trust across the whole site, and feed traffic straight to LPs. +
+
+Hour to Paradise hook. Every destination hub ends with a section: "How the price actually works — your hour at the resort." Each destination also gets one dedicated spoke (/the-presentation-explained) covering what the presentation is like at that specific resort partner — Hilton vs Hyatt vs Royalton style — so the answer to "is it scammy?" is location-specific and concrete, not generic. +
+
+ + +
+Option B · lowest cost, fastest +

FAQ → blog conversion

+

Take each FAQ item and turn it into a 1,500-word blog post answering that exact search query. The presentation post becomes the flagship.

+
+ Type: searchable + Buyer stage: awareness + consideration + Effort: 1-2 hrs per post · 16+ posts + Payoff: direct query match, fast ranking +
+ + + + + + + + + + + +
FAQ questionPost URLPriority
"Do I have to sit through a timeshare presentation?"/blog/an-hour-to-paradise-the-presentation-honestly-explainedFLAGSHIP
Why is this cheaper than booking direct?/blog/why-vacation-certificates-cost-lessP1
What does all-inclusive actually include?/blog/what-does-all-inclusive-actually-includeP1
What can I say no to during the presentation?/blog/how-to-politely-decline-vacation-membershipP1
How do I book my trip after I buy?/blog/how-to-redeem-vacation-certificateP1
When can I travel? Date restrictions?/blog/vacation-certificate-blackout-datesP2
… 11 more, one per FAQ entry
+
+Why fast. 100% answer-the-question SEO. Seeds already written in the FAQ. Each post ranks for the literal question people Google before buying. +
+
+Hour to Paradise hook. The flagship post — "An Hour to Paradise" — is the single most important page on the site after the LPs. It owns the queries "hi2b timeshare presentation", "vacation certificate presentation what to expect", "do I have to attend a presentation hi2b". Long, honest, with a real outline of what happens minute-by-minute, what they pitch, what you can decline, and the exact scripts to say "no thanks." This page is the trust anchor we link to from every other piece of content, from the FAQ, from the LPs, and from ad-account compliance pages. +
+
+ + +
+Option C · highest commercial intent +

Comparison content

+

"X vs Y" decision-stage posts. Catches people actively shopping who are ready to convert.

+
+ Type: searchable + shareable + Buyer stage: consideration → decision + Effort: 2-3 hrs per comparison · 6 posts + Payoff: highest per-visit conversion rate +
+
    +
  • /compare/vacation-certificate-vs-timeshareflagship · kills the #1 objection at the top of the funnel
  • +
  • /compare/vs-apple-vacations — major branded competitor (with honest presentation disclosure both companies use)
  • +
  • /compare/vs-costco-travel — Costco Travel is the trust-comparison everyone reaches for
  • +
  • /compare/vs-cruises — common alternative; cruises = 7 days of upsells, ours = 1 hour
  • +
  • /compare/vs-vrbo-cancun — DIY rental comparison
  • +
  • /compare/vs-booking-com — vs the default booking flow
  • +
+
+Why high-leverage. "X vs Y" queries are bottom-of-funnel — searcher already wants a Mexico trip, is just deciding where to buy it. Conversion rates on this content type are usually 5-10× higher than awareness content. +
+
+Hour to Paradise hook. The vs-timeshare post is the conversion weapon. Frame: "A timeshare costs $20k+ and locks you in forever. Our certificate costs $249 and locks you in for one hour." Same applies to vs-cruises: cruise lines run multiple paid upsells (drinks, excursions, specialty restaurants, photos) every single day; we have one presentation, then you are done. Make "how upsells work" a comparison row on every single one of these pages — that table is the conversion driver. +
+
+ + +
+

Recommendation

+

Ship in this order. Each builds on the brand pillar and reinforces the "Hour to Paradise" honesty.

+
+1 · Option A destinations (with /presentation-explained spokes) +2 · Option B FAQ→blog (flagship: "An Hour to Paradise") +3 · Option C comparison (flagship: vs-timeshare) +
+

After we ship one, pull GA/Cloudflare data to validate which posts pull traffic, then double down on winners. The flagship presentation post should probably ship in week one regardless of which option we pick first — it is the trust anchor everything else links to.

+
+ +
+ +
hi2b.com · internal · content-strategy proposal · brand pillar: An Hour to Paradise · all internal pages
+ + + diff --git a/public/research/fish-voices.html b/public/research/fish-voices.html new file mode 100644 index 0000000..03a0d23 --- /dev/null +++ b/public/research/fish-voices.html @@ -0,0 +1,328 @@ + + + + + + +Fish Audio Voice Comparison — hi2b.com UGC Ad + + + +
+ +
+ Voice A/B · hi2b.com UGC ad +

Fish Audio voiceover comparison

+

9 takes of the same 22-second walking-and-talking script, generated via Fish Audio TTS. Pick the winner.

+
+ TTS: Fish Audio s2-pro + Format: MP3 192 kbps + Cost: ~$0.005 each + Compiled: May 18, 2026 +
+
+ +
+
The script being spoken
+

+ “Day four in Mexico. I paid two hundred and ninety dollars for all of this. + This exact trip last year cost me twenty-eight hundred. I almost didn't even come this time. + I'm rested. I've eaten incredibly. I've done absolutely nothing for four days. + Five-star, all inclusive, my whole family. Twenty-nine a month, ten months, that's it. + Link's in my bio. Don't sleep on this one.” +

+
+ +
+ + + + + Tip: only one plays at a time +
+ +
+ +
+ Your pick: +
+ + + + + + + diff --git a/public/research/male-voice-audition.html b/public/research/male-voice-audition.html new file mode 100644 index 0000000..e46f4d9 --- /dev/null +++ b/public/research/male-voice-audition.html @@ -0,0 +1,49 @@ + + +Male Voice Audition — Older Couple Ad + +

Male Voice Audition — Older Couple Ad (man speaking)

+

Pick the voice that best sounds like a warm, older man. Same line in every clip.

+
"After thirty years together, my wife and I finally took the trip we always talked about. Five days in Mexico, all-inclusive, right on the beach. And I am telling you, it was worth every minute of the wait."
+
+
Warm Conversational Male
+
Middle-aged, conversational, warm
+ + c4723bc253cc4fac8c6b3d143f042636
+
Dave — Deep Voice for Media
+
Deep, media/VO style
+ + 0dd3903013144408b29b7e74ca9e8614
+
Deep Male (old, calm)
+
Older, narration, deep, calm
+ + a60cb2ef5d15412c8c4e63545640eadb
+
American Soft Smooth Male
+
Middle-aged, narration, smooth, professional
+ + 6290ad34543a487ab85d4f36defd6be4
+
Male Narrator (warm)
+
Middle-aged, educational, warm
+ + efc2f5153a24463dbfe54acd93a145f8
+
Confident Male Narrator
+
Older, narration, advertisement-tagged
+ + c8b00ae2d256487bbb1971d70d782c9a
+
w2w (calm, confident)
+
Middle-aged, calm, confident delivery
+ + 4746f1b456494387993af9041d0516c4
+
Energetic American Male
+
Young, energetic, clear American accent — most popular
+ + 802e3bc2b27e49c2995d23ef70e6ac89
\ No newline at end of file diff --git a/public/research/male-voice-samples/m1-warm-conv.mp3 b/public/research/male-voice-samples/m1-warm-conv.mp3 new file mode 100644 index 0000000..5dc99a1 Binary files /dev/null and b/public/research/male-voice-samples/m1-warm-conv.mp3 differ diff --git a/public/research/male-voice-samples/m2-dave-deep.mp3 b/public/research/male-voice-samples/m2-dave-deep.mp3 new file mode 100644 index 0000000..b1b7c6a Binary files /dev/null and b/public/research/male-voice-samples/m2-dave-deep.mp3 differ diff --git a/public/research/male-voice-samples/m3-deep-old-calm.mp3 b/public/research/male-voice-samples/m3-deep-old-calm.mp3 new file mode 100644 index 0000000..ef4acb5 Binary files /dev/null and b/public/research/male-voice-samples/m3-deep-old-calm.mp3 differ diff --git a/public/research/male-voice-samples/m4-soft-smooth.mp3 b/public/research/male-voice-samples/m4-soft-smooth.mp3 new file mode 100644 index 0000000..b674bcc Binary files /dev/null and b/public/research/male-voice-samples/m4-soft-smooth.mp3 differ diff --git a/public/research/male-voice-samples/m5-male-narrator.mp3 b/public/research/male-voice-samples/m5-male-narrator.mp3 new file mode 100644 index 0000000..a588b23 Binary files /dev/null and b/public/research/male-voice-samples/m5-male-narrator.mp3 differ diff --git a/public/research/male-voice-samples/m6-confident-narrator.mp3 b/public/research/male-voice-samples/m6-confident-narrator.mp3 new file mode 100644 index 0000000..0236644 Binary files /dev/null and b/public/research/male-voice-samples/m6-confident-narrator.mp3 differ diff --git a/public/research/male-voice-samples/m7-w2w.mp3 b/public/research/male-voice-samples/m7-w2w.mp3 new file mode 100644 index 0000000..a47de24 Binary files /dev/null and b/public/research/male-voice-samples/m7-w2w.mp3 differ diff --git a/public/research/male-voice-samples/m8-energetic.mp3 b/public/research/male-voice-samples/m8-energetic.mp3 new file mode 100644 index 0000000..0f01536 Binary files /dev/null and b/public/research/male-voice-samples/m8-energetic.mp3 differ diff --git a/public/research/quote-cards.html b/public/research/quote-cards.html new file mode 100644 index 0000000..59abef9 --- /dev/null +++ b/public/research/quote-cards.html @@ -0,0 +1,124 @@ + + + + + +hi2b.com — Quote Cards + + + +
+

hi2b.com — Quote Cards

+

Kinetic-caption travel motivation over authentic back-to-camera "mama at the ocean" stills (Gemini-generated). Words reveal 1–3 at a time, punchy. ~9–12s, 9:16, CTA flash. 75 cards across 3 sets.

+
+ +
FUCK-IT Closers · blunt, $29, just-go energy · 15
+
+ +
Wife-to-Wife · Involving Your Husband · make him take you, rekindle, you've earned it · 20
+
+ +
Wife-to-Wife · Involving the Kids · family getaway, moms deserve a break, kids-grow-fast · 20
+
+ +
hi2b.com · internal · quote-cards · 75 cards
+ + + diff --git a/public/research/tiktok-ugc-scripts.md b/public/research/tiktok-ugc-scripts.md new file mode 100644 index 0000000..b261958 --- /dev/null +++ b/public/research/tiktok-ugc-scripts.md @@ -0,0 +1,183 @@ +# hi2b.com — TikTok UGC Ad Scripts (37s each) + +Ready-to-shoot scripts. Same product (Mexico vacation certificate), different +angles. Each is timed for ~37 seconds at natural speaking pace. + +Voice direction: **female, 30s, warm, slightly conspiratorial, talking to a +friend — not selling**. No upspeak. Light laugh on the hook. Phone-held selfie +framing, golden hour, no studio lighting. + +--- + +## ★ HERO SCRIPT — "How is this legal" (highest-converting) + +*Use this one first. Front-loads the killer number in the hook, kills the +timeshare objection mid-script, single hard CTA. ~38s.* + +``` +[0:00 — 0:04] HOOK — straight to camera, half-laugh, a little disbelief +"My husband booked our whole family's Mexico trip for under four +hundred dollars — and I need someone to tell me how this is legal." + +[0:04 — 0:13] SETUP — relax, lean in slightly +"It's a travel certificate. Five days, four nights, a real +beachfront resort in Cancun. Two adults, two kids — and the kids +stay completely free." + +[0:13 — 0:23] PROOF — eyebrows up, hand gesture, genuine +"I was sure it was a scam. I read every single word. It's not. +The resort just uses these to fill empty rooms — they'd rather have +you at the bar than have the room sitting dark." + +[0:23 — 0:31] OBJECTION KILL — shake head, reassuring +"And no — nobody made us sit through a timeshare pitch. We checked +in, got our wristbands, walked straight to the pool. That was it." + +[0:31 — 0:38] CTA — direct, calm, confident +"It's hi2b.com — link's in my bio. They only release these a +batch at a time, so if it's up... don't think about it. Just go." +``` + +**On-screen text:** +- 0:00 — "under $400. for the WHOLE family. 🇲🇽" +- 0:13 — "kids stay FREE 👀" +- 0:23 — "❌ no timeshare pitch" +- 0:31 — "hi2b.com 🔗 in bio" + +**Why it converts:** +- **Hook** leads with the concrete number + a curiosity gap ("how is this legal") — the strongest 4 seconds we can buy. +- **Specificity** ("five days, four nights", "Cancun", "wristbands") reads as lived experience, not ad copy. +- **Objection kill** is mid-script, not an afterthought — the timeshare fear is the #1 reason this audience hesitates. +- **CTA** is single and frictionless — one destination, one action, mild scarcity, no menu of options. + +--- + +## Script A — "I shouldn't be saying this" +*Best for cold traffic. Pattern-interrupt hook. Wide appeal.* + +``` +[0:00 — 0:03] HOOK — to camera, half-smile, slight head shake +"Okay I'm probably gonna get in trouble for posting this." + +[0:03 — 0:09] SETUP — relax, lean into camera +"My husband found this travel site called hi2b — Mexico, all-inclusive, +five days four nights — for like the price of one dinner out." + +[0:09 — 0:18] PROOF — eyes wide, mock-whisper +"Real resort. Beachfront. Two adults plus our kids under twelve stay free. +And it's not a timeshare thing — they don't even ask you to sit through +anything. I literally checked twice." + +[0:18 — 0:27] STORY — natural smile, looks off, looks back +"We just got back. Pool with a swim-up bar. Tacos for breakfast. +My nine-year-old still won't shut up about the iguanas." + +[0:27 — 0:34] CTA — direct, simple +"It's hi2b.com. Use my link below — they're capping how many they sell. +Don't say I didn't warn you." + +[0:34 — 0:37] END — small genuine smile, look away, look back +"...okay bye." +``` + +**On-screen text:** +- 0:00 — "POV: my husband found a cheat code" +- 0:09 — "👀 Mexico. All-inclusive. Kids free." +- 0:27 — "hi2b.com 🔗 in bio" + +--- + +## Script B — "The math doesn't math" +*Best for retargeting. Specific numbers. Skeptic-friendly.* + +``` +[0:00 — 0:03] HOOK — squint at camera, half-laugh +"Tell me how this is legal." + +[0:03 — 0:12] SETUP — count on fingers +"Four nights, five days. Two adults. Two kids under twelve — free. +Beachfront resort in Cancun. All-inclusive food and drinks. Transfers +included." + +[0:12 — 0:22] PROOF — palms up, genuinely confused +"You know what we paid? Three hundred ninety-seven dollars. Total. +Not per person. Total. I literally booked it twice because I thought +the first one was a scam." + +[0:22 — 0:30] ANSWER — lean in +"Turns out the resort uses these to fill rooms. They'd rather have +you drinking at the bar than have an empty room. That's it. That's +the whole trick." + +[0:30 — 0:37] CTA — relaxed close +"It's hi2b.com. Link in my bio. Use it before they wise up." +``` + +**On-screen text:** +- 0:00 — "wait what 🤨" +- 0:12 — "$397. TOTAL." +- 0:30 — "hi2b.com ↓" + +--- + +## Script C — "I was the skeptic" +*Best for objection-handling. Converted-skeptic frame.* + +``` +[0:00 — 0:03] HOOK — flat, deadpan +"I do not fall for things on the internet." + +[0:03 — 0:11] SETUP — gesturing at self +"I'm the person who reads the terms. I cancel free trials the same +day. My husband calls me 'the auditor.'" + +[0:11 — 0:20] TURN — soften, lean in +"So when he showed me this hi2b travel certificate — Mexico, +all-inclusive, kids stay free for under a few hundred bucks — +I spent an hour trying to find the catch." + +[0:20 — 0:30] PROOF — head shake, half-laugh +"There isn't one. Real resort. Real dates. Real food. We went. +We came back. Nobody asked us to buy a timeshare. Nobody up-sold us. +I checked the bill three times." + +[0:30 — 0:37] CTA — sincere +"Link's in my bio. It's hi2b.com. Just look — that's all I'm +saying." +``` + +**On-screen text:** +- 0:00 — "I am the worst customer 🙃" +- 0:11 — "audited every line" +- 0:30 — "hi2b.com — just look" + +--- + +## Production notes (all three) + +- **Aspect:** 9:16 vertical, 1080×1920 or 480×832 (Wan2.2-S2V native). +- **Frame rate:** 24 or 30 fps. TikTok re-encodes either way. +- **Sound:** clean voice, no music bed under the talking head — TikTok's + algorithm prefers raw UGC audio. Add trending sound under the + on-screen-text-only cutaways if any. +- **Hook frame must be the cover.** TikTok shows the first frame as a + thumbnail in the For You feed. +- **No mention of price in voiceover for Script A or C** — only in + on-screen text. Lets you swap pricing without re-rendering audio. +- **CTA:** "hi2b.com" stays verbal. Link in bio + sticker do the rest. +- **First-3-second test:** if a stranger watching with sound off would + swipe away, rework the hook before you ever queue a render. + +## Voice pick + +Stay with the Miranda Lambert voice from `scripts/fish-voices.json` — that's +the one already audience-tested with the existing chain render. If you want +to A/B, queue Script B with the Sarah voice (younger, faster cadence) as +the variant. + +## Next step + +Pick one (A / B / C), and I'll: +1. Generate the Fish Audio voiceover at 24kHz. +2. Chunk it into 11 × 3.375s files. +3. Queue the chain render with `wan-s2v-chain.ts` (already proven on Inst 1). diff --git a/public/research/video-models.html b/public/research/video-models.html new file mode 100644 index 0000000..d2bbf58 --- /dev/null +++ b/public/research/video-models.html @@ -0,0 +1,499 @@ + + + + + + +AI Video Model Analysis for hi2b.com UGC Ads — May 2026 + + + +
+ +
+ Research Brief · Internal +

AI Video Models for hi2b.com UGC Ads

+

Practical analysis of HappyHorse, Veo, Seedance, Kling, z.ai/CogVideoX, Runway, and other 2026 contenders — with a concrete recommendation for Facebook & TikTok ad creative for vacation certificates.

+
+ Author: Claude (Opus 4.7) + Compiled: May 17, 2026 + For: hi2b.com paid-media creative + Status: Decision-ready +
+
+ +
+

TL;DR

+

If you only read this section

+
+
Recommendation
+

Two-model hybrid: Veo 3.1 Fast for testing + HappyHorse 1.0 for scale

+

+ Generate 20–50 cheap variants at $0.15/sec on Veo Fast. + Pick top 3–5 winners. Re-render those for paid scale on HappyHorse 1.0 + at $0.28/sec for premium fidelity. + Total to find your first batch of scalable ad winners: + ≈ $174. +

+
+ +
+
30 test variants × 20 sec × $0.15 (Veo Fast)
$ 90.00
+
5 winners re-rendered × 60 sec × $0.28 (HappyHorse 1.0)
$ 84.00
+
Total to discover your first winning concept
$ 174.00
+
+
+ +
+

1. The contenders — Tier 1 (ship-ready)

+

Models with native synced audio + vertical 9:16 + production-grade APIs

+ +
+
+

Veo 3.1 Fast 🥇

+ $0.15 / sec @ 1080p +
Native audio
9:16
+
    +
  • Up to 8 sec per gen; chain up to 20 clips
  • +
  • Google AI / Vertex API
  • +
  • Same key as Gemini image gen
  • +
  • Best price-perf for A/B testing
  • +
+
+ +
+

HappyHorse 1.0 🥈

+ $0.28 / sec @ 1080p +
Native audio
9:16
15B params
+
    +
  • 3–15 sec per gen, multi-shot consistency
  • +
  • fal.ai or Alibaba Bailian
  • +
  • #1 on Artificial Analysis arena
  • +
  • Most realistic talking heads
  • +
+
+ +
+

Veo 3.1 Standard

+ $0.40 / sec @ 1080p +
Native audio
9:16
+
    +
  • Premium fidelity, slower
  • +
  • Cinematic prompt adherence
  • +
  • Best for hero ads at scale
  • +
+
+ +
+

Seedance 2.0

+ $0.20 / sec @ 1080p +
Multi-shot audio
9:16
+
    +
  • ByteDance, released Feb 2026
  • +
  • Built for narrative-driven content
  • +
  • Best for 60-90sec story ads
  • +
+
+ +
+

Kling 3.0

+ $0.18 / sec @ 1080p +
Native audio
9:16
+
    +
  • Kuaishou, strong on liquids/fabric
  • +
  • Cinematic lighting parity with Veo
  • +
  • Multi-shot storyboard mode
  • +
+
+
+
+ +
+

2. The contenders — Tier 2 (trade-offs)

+

Usable but with friction for hi2b.com's specific use case

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModelProsWhy not (for us)
z.ai / CogVideoX ZhipuCheap with free tier on bigmodel.cn; up to 4K; 30/60 fps; open-source weightsAudio is newer/less synced. Chinese-platform onboarding (KYC + payments) eats a day.
Runway Gen-4.5Best creative control (motion brush, camera moves, reference characters)No native audio — need separate TTS + lip-sync pipeline. ~$0.50/sec. Overkill for ads.
LTX-2.3 Pro LightricksCurrently tied #1 on text-to-video arena (no-audio)No synced audio. UGC ads live or die on lip-sync.
Adobe Firefly VideoCommercially-safe training data; Creative Cloud integrationNot API-first. Slow iteration. Built for filmmakers, not ad ops.
+
+ +
+

3. The contenders — Tier 3 (skip)

+

Don't build on these

+ + + + + + + + + +
ModelWhy skip
OpenAI Sora deprecatedWeb/app being shut down. API deprecation announced for later in 2026. Don't invest.
Pika 2.xCapable but no differentiation over Veo/HappyHorse. Integration overhead not justified.
Luma Dream MachineSame as above. Best for hobbyist prosumer use, not ops automation.
Hunyuan Video / Mochi 1Solid open-source options if self-hosting GPUs. Otherwise no API edge.
+
+ +
+

4. Comparison matrix at a glance

+

All Tier 1 + Tier 2 models, side by side

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelVendorNative audio9:16$/sec @ 1080pAPI accessVerdict
Veo 3.1 FastGoogleYesYes$0.15Gemini, VertexTest/draft
Veo 3.1 StandardGoogleYesYes$0.40Gemini, VertexHero ads
HappyHorse 1.0AlibabaYes syncedYes$0.28fal.ai, BailianTalking heads
Seedance 2.0ByteDanceMulti-shotYes~$0.20fal.ai, BytePlusNarrative
Kling 3.0KuaishouYesYes~$0.18Kling directMotion
CogVideoX (z.ai)ZhipuNewerYes~$0.09 + free tierbigmodel.cnCheap bulk
Runway Gen-4.5RunwaySeparateYes~$0.50Runway APIDirector-led
LTX-2.3 ProLightricksNoYesvariableLightricks, falSkip for UGC
+
+ +
+

5. The three UGC ad flavors that work

+

Battle-tested formats for vacation/travel direct response

+ +
+
+

A · Talking-head testimonial

+

+ Young woman, raw iPhone aesthetic, 9:16, talks to camera. 15–25 seconds. + Cuts to b-roll of beach/resort during emphasis beats. +

+

+ "OK so I just got back from Cancun and y'all — I paid $290 for a trip my friend paid $3,400 for. Same resort. I'm literally crying. Link in bio." +

+

Highest ROAS on TikTok.

+
+ +
+

B · Reaction / unboxing

+

+ Person opens welcome email → reads cert number aloud → screams → cuts to plane window → resort lobby → infinity pool. 20–30 sec. +

+

+ "WAIT — it's actually real. MPV-2026... that's my certificate number?! I'M GOING TO MEXICO." +

+

Great for Facebook/Reels.

+
+ +
+

C · Day-in-the-life montage

+

+ Morning coffee → plane window → taxi → lobby → room reveal → pool → sunset cocktail → beach dinner. No dialogue, music + captions only. 30–45 sec. +

+

+ Caption overlay: "Day 3. I forgot what my work email looks like." +

+

Lowest AI-detection risk.

+
+
+
+ +
+

6. Why I'd skip z.ai, Runway, LTX for this

+

Specifically — they're not bad, just wrong tools for the job

+ +
    +
  • z.ai / CogVideoX — Genuinely cheap, but Chinese-platform onboarding (KYC + payments + doc translation) costs a day. Quality also visibly behind Veo/HappyHorse. Not worth it when fal.ai gives you HappyHorse in 2 minutes.
  • +
  • Runway Gen-4.5 — Pros use it for motion direction. You're not directing — you're testing scripts at volume. The control isn't worth $0.50/sec plus the no-audio penalty (which means a separate TTS + lip-sync pipeline you have to maintain).
  • +
  • LTX-2.3 Pro — Top of the arena leaderboard. But no synced audio. UGC ads live or die on lip-sync. Hard pass for this use case.
  • +
  • Seedance 2.0 — Excellent, but built for multi-shot narratives. For 15-30sec UGC ads, you rarely need multi-shot. Consider adding it only when you build longer 60-90sec story ads.
  • +
  • Sora — API deprecation already announced. Don't build on a sunset platform.
  • +
+
+ +
+

7. Concrete proposal — the pipeline

+

What I'd build for hi2b.com, ~half a day of work

+ +
1. /admin/ads — UI for sales/marketing to enter ad concept + persona + length
+2. POST /api/ads/generate
+3. Backend: parallel call to Veo Fast + HappyHorse for the same script
+4. Both videos saved to /public/ads/<job_id>/{veo,hh}.mp4
+5. Side-by-side viewer in admin → human picks winner
+6. Approved videos exported to a folder for upload to Meta Ads / TikTok Ads
+7. Each video tagged with ?ad=<job_id> in landing URL
+8. /admin/ads tracks click → conversion attribution per ad variant
+ +
+
Build time (one-shot)
~4 hours
+
Compute per concept tested (Veo Fast + HappyHorse)
~$11
+
50 ad concepts / month (aggressive)
~$500/mo
+
Equivalent human UGC creator cost (50 videos)
$100k–$250k
+
+
+ +
+

8. Caveats — read before scaling

+

Honest pitfalls

+
    +
  • Identity drift across separate generations. Same "persona" will look slightly different between calls. Workaround: use HappyHorse's reference-to-video with a fixed input image of your AI spokesperson.
  • +
  • English lip-sync is good, not perfect. Native Spanish/Mandarin is sharper. Plan for 2–3 takes per ad and pick the cleanest.
  • +
  • Meta's "Made with AI" disclosure policy. Talking-head testimonials almost certainly need the label. Day-in-the-life montages are a gray area. Check current Meta policy before scaling.
  • +
  • TikTok's Synthetic Media policy requires AI-disclosure on "realistic" content. Same gray zone. Test compliance before $5k+ spend.
  • +
  • Don't fake medical/financial outcomes — both platforms enforce this. Vacation testimonials are safe.
  • +
+
+ +
+

9. Decision points for you

+

Pick one path and I'll ship it

+ +
+
+

Path 1 · HappyHorse only

+

Single model, fal.ai integration. First 10 ads ≈ $56 compute.

+
+
+

Path 2 · Hybrid (recommended)

+

Veo Fast + HappyHorse, side-by-side admin UI. ~$11 per concept tested. Best long-term.

+
+
+

Path 3 · Veo Fast only

+

Cheapest entry. Single Google API key. ~$30 for first 10 ads.

+
+
+

Path 4 · Wait

+

Stay with human UGC creators. Revisit when leaderboards settle.

+
+
+
+ +
+

10. Sources

+ +
+ +
+ Compiled for internal hi2b.com strategy · May 17, 2026 · noindex +
+ +
+ + diff --git a/public/research/voice-audition.html b/public/research/voice-audition.html new file mode 100644 index 0000000..7f01bc9 --- /dev/null +++ b/public/research/voice-audition.html @@ -0,0 +1,68 @@ + + + +hi2b.com — Fish Audio UGC Voice Audition + +

Fish Audio — UGC Voice Audition

+

Top candidate voices for hi2b.com TikTok ads, ranked from the Fish Audio library. Same script in every clip.

+
"Okay, I probably shouldn't be sharing this, but my husband found a way to take our whole family to Mexico for under four hundred dollars. Five days, all-inclusive, a real beachfront resort. I genuinely cannot believe it is real. The link is in my bio."
+
+
+
ALLE
+
Young female · energetic, friendly · 2.3k likes, 285k uses · built for social-media/product reviews
+ + 59e9dc1cb20c452584788a2690c80970 +
+
+
teto
+
Youthful female · relaxed, informal, friendly · 1.1k likes, 209k uses
+ + a3b3f0a9c49340bd8fa722d83c81cb08 +
+
+
Taylor (soft/sincere)
+
Young female · soft, sincere, intimate, slightly breathy · 121k uses
+ + cfc33da8775c47afacccf4eebabe44dc +
+
+
Megan
+
Young female · bright, energetic, confident, expressive · 38k uses
+ + fb43143e46f44cc6ad7d06230215bab6 +
+
+
Sarah (current)
+
Young female · conversational, soft · the voice used in batches 10-13 · 527k uses
+ + 933563129e564b19a115bedd57b7406a +
+
+
Energetic Male
+
Young male · energetic, enthusiastic, clear American accent · 2.1k likes, 380k uses
+ + 802e3bc2b27e49c2995d23ef70e6ac89 +
+
+
Adam
+
Male · confident, energetic, friendly, conversational · 68k uses · advertisement-tagged
+ + 9259a7392c454a1eb6436141abb5a558 +
+
hi2b.com · internal · voice audition
+ \ No newline at end of file diff --git a/public/research/voice-samples/adam.mp3 b/public/research/voice-samples/adam.mp3 new file mode 100644 index 0000000..7dcd8f0 Binary files /dev/null and b/public/research/voice-samples/adam.mp3 differ diff --git a/public/research/voice-samples/alle.mp3 b/public/research/voice-samples/alle.mp3 new file mode 100644 index 0000000..5d4cffb Binary files /dev/null and b/public/research/voice-samples/alle.mp3 differ diff --git a/public/research/voice-samples/energetic-male.mp3 b/public/research/voice-samples/energetic-male.mp3 new file mode 100644 index 0000000..6e3a7a0 Binary files /dev/null and b/public/research/voice-samples/energetic-male.mp3 differ diff --git a/public/research/voice-samples/megan.mp3 b/public/research/voice-samples/megan.mp3 new file mode 100644 index 0000000..ff6488a Binary files /dev/null and b/public/research/voice-samples/megan.mp3 differ diff --git a/public/research/voice-samples/sarah.mp3 b/public/research/voice-samples/sarah.mp3 new file mode 100644 index 0000000..1779897 Binary files /dev/null and b/public/research/voice-samples/sarah.mp3 differ diff --git a/public/research/voice-samples/taylor.mp3 b/public/research/voice-samples/taylor.mp3 new file mode 100644 index 0000000..40bfaae Binary files /dev/null and b/public/research/voice-samples/taylor.mp3 differ diff --git a/public/research/voice-samples/teto.mp3 b/public/research/voice-samples/teto.mp3 new file mode 100644 index 0000000..743c806 Binary files /dev/null and b/public/research/voice-samples/teto.mp3 differ diff --git a/public/robots.txt b/public/robots.txt index 6018e70..3f3b67a 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,14 +1,134 @@ +# hi2b.com — Mexico Paradise Vacations +# All crawlers welcome, including AI / LLM training and assistant bots. + +User-agent: * +Allow: / + +# --- Search engines --- User-agent: Googlebot Allow: / User-agent: Bingbot Allow: / +User-agent: DuckDuckBot +Allow: / + +User-agent: Baiduspider +Allow: / + +User-agent: YandexBot +Allow: / + +# --- Social / link previews --- User-agent: Twitterbot Allow: / User-agent: facebookexternalhit Allow: / -User-agent: * +User-agent: LinkedInBot +Allow: / + +User-agent: Slackbot +Allow: / + +User-agent: Discordbot +Allow: / + +# --- OpenAI --- +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: OAI-SearchBot +Allow: / + +# --- Anthropic --- +User-agent: anthropic-ai +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: Claude-Web +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: Claude-SearchBot +Allow: / + +# --- Google AI --- +User-agent: Google-Extended +Allow: / + +User-agent: GoogleOther +Allow: / + +# --- Apple --- +User-agent: Applebot +Allow: / + +User-agent: Applebot-Extended +Allow: / + +# --- Perplexity --- +User-agent: PerplexityBot +Allow: / + +User-agent: Perplexity-User +Allow: / + +# --- Meta / Facebook --- +User-agent: Meta-ExternalAgent +Allow: / + +User-agent: Meta-ExternalFetcher +Allow: / + +User-agent: FacebookBot +Allow: / + +# --- ByteDance / TikTok --- +User-agent: Bytespider +Allow: / + +# --- Amazon --- +User-agent: Amazonbot +Allow: / + +# --- Cohere --- +User-agent: cohere-ai +Allow: / + +User-agent: cohere-training-data-crawler +Allow: / + +# --- Other AI assistants --- +User-agent: DuckAssistBot +Allow: / + +User-agent: YouBot +Allow: / + +User-agent: MistralAI-User Allow: / + +User-agent: Diffbot +Allow: / + +User-agent: ImagesiftBot +Allow: / + +User-agent: omgilibot +Allow: / + +# --- Common Crawl (used for many LLM training corpora) --- +User-agent: CCBot +Allow: / + +Sitemap: https://hi2b.com/sitemap.xml diff --git a/public/tiktok/7598715055793868052.jpg b/public/tiktok/7598715055793868052.jpg new file mode 100644 index 0000000..115e045 Binary files /dev/null and b/public/tiktok/7598715055793868052.jpg differ diff --git a/public/tiktok/7598726765644696853.jpg b/public/tiktok/7598726765644696853.jpg new file mode 100644 index 0000000..8982ca2 Binary files /dev/null and b/public/tiktok/7598726765644696853.jpg differ diff --git a/public/tiktok/7598734004359007509.jpg b/public/tiktok/7598734004359007509.jpg new file mode 100644 index 0000000..6e5bc25 Binary files /dev/null and b/public/tiktok/7598734004359007509.jpg differ diff --git a/public/tiktok/7599817155001044231.jpg b/public/tiktok/7599817155001044231.jpg new file mode 100644 index 0000000..ecd56a7 Binary files /dev/null and b/public/tiktok/7599817155001044231.jpg differ diff --git a/public/tiktok/7599823703538453768.jpg b/public/tiktok/7599823703538453768.jpg new file mode 100644 index 0000000..3a879cb Binary files /dev/null and b/public/tiktok/7599823703538453768.jpg differ diff --git a/public/tiktok/7599836054589394194.jpg b/public/tiktok/7599836054589394194.jpg new file mode 100644 index 0000000..5b7a7e6 Binary files /dev/null and b/public/tiktok/7599836054589394194.jpg differ diff --git a/public/tiktok/7600189653106380039.jpg b/public/tiktok/7600189653106380039.jpg new file mode 100644 index 0000000..1e10840 Binary files /dev/null and b/public/tiktok/7600189653106380039.jpg differ diff --git a/public/tiktok/7600194855343541522.jpg b/public/tiktok/7600194855343541522.jpg new file mode 100644 index 0000000..ab28297 Binary files /dev/null and b/public/tiktok/7600194855343541522.jpg differ diff --git a/public/tiktok/7600201175933177109.jpg b/public/tiktok/7600201175933177109.jpg new file mode 100644 index 0000000..e9ac5e6 Binary files /dev/null and b/public/tiktok/7600201175933177109.jpg differ diff --git a/public/tiktok/7600205520087043335.jpg b/public/tiktok/7600205520087043335.jpg new file mode 100644 index 0000000..004268b Binary files /dev/null and b/public/tiktok/7600205520087043335.jpg differ diff --git a/public/tiktok/7600207352259808532.jpg b/public/tiktok/7600207352259808532.jpg new file mode 100644 index 0000000..e2d4c45 Binary files /dev/null and b/public/tiktok/7600207352259808532.jpg differ diff --git a/public/tiktok/7600210082596539669.jpg b/public/tiktok/7600210082596539669.jpg new file mode 100644 index 0000000..1f8ca73 Binary files /dev/null and b/public/tiktok/7600210082596539669.jpg differ diff --git a/public/tiktok/7600210549238009106.jpg b/public/tiktok/7600210549238009106.jpg new file mode 100644 index 0000000..f2a0575 Binary files /dev/null and b/public/tiktok/7600210549238009106.jpg differ diff --git a/public/tiktok/7600213209684970772.jpg b/public/tiktok/7600213209684970772.jpg new file mode 100644 index 0000000..452c16a Binary files /dev/null and b/public/tiktok/7600213209684970772.jpg differ diff --git a/public/tiktok/7600559301060578581.jpg b/public/tiktok/7600559301060578581.jpg new file mode 100644 index 0000000..beea6da Binary files /dev/null and b/public/tiktok/7600559301060578581.jpg differ diff --git a/public/tiktok/7600559846118690055.jpg b/public/tiktok/7600559846118690055.jpg new file mode 100644 index 0000000..93eb67d Binary files /dev/null and b/public/tiktok/7600559846118690055.jpg differ diff --git a/public/tiktok/7600564652304518418.jpg b/public/tiktok/7600564652304518418.jpg new file mode 100644 index 0000000..73decfe Binary files /dev/null and b/public/tiktok/7600564652304518418.jpg differ diff --git a/public/tiktok/7600564867724021013.jpg b/public/tiktok/7600564867724021013.jpg new file mode 100644 index 0000000..e5624bc Binary files /dev/null and b/public/tiktok/7600564867724021013.jpg differ diff --git a/public/tiktok/7600568620103650578.jpg b/public/tiktok/7600568620103650578.jpg new file mode 100644 index 0000000..af83e56 Binary files /dev/null and b/public/tiktok/7600568620103650578.jpg differ diff --git a/public/tiktok/7600574564598385928.jpg b/public/tiktok/7600574564598385928.jpg new file mode 100644 index 0000000..89563b7 Binary files /dev/null and b/public/tiktok/7600574564598385928.jpg differ diff --git a/public/tiktok/7600579435082812679.jpg b/public/tiktok/7600579435082812679.jpg new file mode 100644 index 0000000..64668b1 Binary files /dev/null and b/public/tiktok/7600579435082812679.jpg differ diff --git a/public/tiktok/7600582098000383252.jpg b/public/tiktok/7600582098000383252.jpg new file mode 100644 index 0000000..95cca5d Binary files /dev/null and b/public/tiktok/7600582098000383252.jpg differ diff --git a/public/tiktok/7600582664717913351.jpg b/public/tiktok/7600582664717913351.jpg new file mode 100644 index 0000000..866e1ed Binary files /dev/null and b/public/tiktok/7600582664717913351.jpg differ diff --git a/public/tiktok/7600928850821860626.jpg b/public/tiktok/7600928850821860626.jpg new file mode 100644 index 0000000..95f0d18 Binary files /dev/null and b/public/tiktok/7600928850821860626.jpg differ diff --git a/public/tiktok/7600928959915756820.jpg b/public/tiktok/7600928959915756820.jpg new file mode 100644 index 0000000..1b78163 Binary files /dev/null and b/public/tiktok/7600928959915756820.jpg differ diff --git a/public/tiktok/7600931800495475975.jpg b/public/tiktok/7600931800495475975.jpg new file mode 100644 index 0000000..64ca383 Binary files /dev/null and b/public/tiktok/7600931800495475975.jpg differ diff --git a/public/tiktok/7600936171354557704.jpg b/public/tiktok/7600936171354557704.jpg new file mode 100644 index 0000000..3ccbc57 Binary files /dev/null and b/public/tiktok/7600936171354557704.jpg differ diff --git a/public/tiktok/7600940014209371400.jpg b/public/tiktok/7600940014209371400.jpg new file mode 100644 index 0000000..1b48917 Binary files /dev/null and b/public/tiktok/7600940014209371400.jpg differ diff --git a/public/tiktok/7600948478646242581.jpg b/public/tiktok/7600948478646242581.jpg new file mode 100644 index 0000000..bab988d Binary files /dev/null and b/public/tiktok/7600948478646242581.jpg differ diff --git a/public/tiktok/7600949627860307208.jpg b/public/tiktok/7600949627860307208.jpg new file mode 100644 index 0000000..a6bf7ff Binary files /dev/null and b/public/tiktok/7600949627860307208.jpg differ diff --git a/public/tiktok/7600953259527818517.jpg b/public/tiktok/7600953259527818517.jpg new file mode 100644 index 0000000..aa03186 Binary files /dev/null and b/public/tiktok/7600953259527818517.jpg differ diff --git a/public/tiktok/7600953943153331463.jpg b/public/tiktok/7600953943153331463.jpg new file mode 100644 index 0000000..fd29b46 Binary files /dev/null and b/public/tiktok/7600953943153331463.jpg differ diff --git a/public/tiktok/7601298265182637320.jpg b/public/tiktok/7601298265182637320.jpg new file mode 100644 index 0000000..0326064 Binary files /dev/null and b/public/tiktok/7601298265182637320.jpg differ diff --git a/public/tiktok/7601301018957106440.jpg b/public/tiktok/7601301018957106440.jpg new file mode 100644 index 0000000..9875a87 Binary files /dev/null and b/public/tiktok/7601301018957106440.jpg differ diff --git a/public/tiktok/7601302133505264916.jpg b/public/tiktok/7601302133505264916.jpg new file mode 100644 index 0000000..4929f44 Binary files /dev/null and b/public/tiktok/7601302133505264916.jpg differ diff --git a/public/tiktok/7601306996272270599.jpg b/public/tiktok/7601306996272270599.jpg new file mode 100644 index 0000000..0b06745 Binary files /dev/null and b/public/tiktok/7601306996272270599.jpg differ diff --git a/public/tiktok/7601307252854721813.jpg b/public/tiktok/7601307252854721813.jpg new file mode 100644 index 0000000..8155147 Binary files /dev/null and b/public/tiktok/7601307252854721813.jpg differ diff --git a/public/tiktok/7601314019064040724.jpg b/public/tiktok/7601314019064040724.jpg new file mode 100644 index 0000000..d4d99d1 Binary files /dev/null and b/public/tiktok/7601314019064040724.jpg differ diff --git a/public/tiktok/7601315168517180679.jpg b/public/tiktok/7601315168517180679.jpg new file mode 100644 index 0000000..1672b08 Binary files /dev/null and b/public/tiktok/7601315168517180679.jpg differ diff --git a/public/tiktok/7601319181895814421.jpg b/public/tiktok/7601319181895814421.jpg new file mode 100644 index 0000000..4641aef Binary files /dev/null and b/public/tiktok/7601319181895814421.jpg differ diff --git a/public/tiktok/7601322386176134407.jpg b/public/tiktok/7601322386176134407.jpg new file mode 100644 index 0000000..8b4f170 Binary files /dev/null and b/public/tiktok/7601322386176134407.jpg differ diff --git a/public/tiktok/7601324989383527698.jpg b/public/tiktok/7601324989383527698.jpg new file mode 100644 index 0000000..eae76e1 Binary files /dev/null and b/public/tiktok/7601324989383527698.jpg differ diff --git a/public/tiktok/7601325166483737877.jpg b/public/tiktok/7601325166483737877.jpg new file mode 100644 index 0000000..4ace3ef Binary files /dev/null and b/public/tiktok/7601325166483737877.jpg differ diff --git a/public/tiktok/7602412252343438599.jpg b/public/tiktok/7602412252343438599.jpg new file mode 100644 index 0000000..57c9a21 Binary files /dev/null and b/public/tiktok/7602412252343438599.jpg differ diff --git a/public/tiktok/7602416103356239111.jpg b/public/tiktok/7602416103356239111.jpg new file mode 100644 index 0000000..d1597d8 Binary files /dev/null and b/public/tiktok/7602416103356239111.jpg differ diff --git a/public/tiktok/7602416902236835093.jpg b/public/tiktok/7602416902236835093.jpg new file mode 100644 index 0000000..841ea6b Binary files /dev/null and b/public/tiktok/7602416902236835093.jpg differ diff --git a/public/tiktok/7602421070645169429.jpg b/public/tiktok/7602421070645169429.jpg new file mode 100644 index 0000000..ed76452 Binary files /dev/null and b/public/tiktok/7602421070645169429.jpg differ diff --git a/public/tiktok/7602422161747283207.jpg b/public/tiktok/7602422161747283207.jpg new file mode 100644 index 0000000..ff8d27f Binary files /dev/null and b/public/tiktok/7602422161747283207.jpg differ diff --git a/public/tiktok/7602427461330029844.jpg b/public/tiktok/7602427461330029844.jpg new file mode 100644 index 0000000..4b901c1 Binary files /dev/null and b/public/tiktok/7602427461330029844.jpg differ diff --git a/public/tiktok/7602427669245889800.jpg b/public/tiktok/7602427669245889800.jpg new file mode 100644 index 0000000..177660b Binary files /dev/null and b/public/tiktok/7602427669245889800.jpg differ diff --git a/public/tiktok/7602432094261841172.jpg b/public/tiktok/7602432094261841172.jpg new file mode 100644 index 0000000..1b1bcad Binary files /dev/null and b/public/tiktok/7602432094261841172.jpg differ diff --git a/public/tiktok/7602432924708834568.jpg b/public/tiktok/7602432924708834568.jpg new file mode 100644 index 0000000..f1ca966 Binary files /dev/null and b/public/tiktok/7602432924708834568.jpg differ diff --git a/public/tiktok/7602435246679739668.jpg b/public/tiktok/7602435246679739668.jpg new file mode 100644 index 0000000..fae08de Binary files /dev/null and b/public/tiktok/7602435246679739668.jpg differ diff --git a/public/tiktok/7602436296635632904.jpg b/public/tiktok/7602436296635632904.jpg new file mode 100644 index 0000000..a17161a Binary files /dev/null and b/public/tiktok/7602436296635632904.jpg differ diff --git a/public/tiktok/7602437874096639253.jpg b/public/tiktok/7602437874096639253.jpg new file mode 100644 index 0000000..6c514e5 Binary files /dev/null and b/public/tiktok/7602437874096639253.jpg differ diff --git a/public/tiktok/7602783687364578567.jpg b/public/tiktok/7602783687364578567.jpg new file mode 100644 index 0000000..90922ad Binary files /dev/null and b/public/tiktok/7602783687364578567.jpg differ diff --git a/public/tiktok/7602788687109328135.jpg b/public/tiktok/7602788687109328135.jpg new file mode 100644 index 0000000..a2bb938 Binary files /dev/null and b/public/tiktok/7602788687109328135.jpg differ diff --git a/public/tiktok/7602793251241856263.jpg b/public/tiktok/7602793251241856263.jpg new file mode 100644 index 0000000..afaf489 Binary files /dev/null and b/public/tiktok/7602793251241856263.jpg differ diff --git a/public/tiktok/7602797860169567506.jpg b/public/tiktok/7602797860169567506.jpg new file mode 100644 index 0000000..7e17933 Binary files /dev/null and b/public/tiktok/7602797860169567506.jpg differ diff --git a/public/tiktok/7602801434245074194.jpg b/public/tiktok/7602801434245074194.jpg new file mode 100644 index 0000000..5d30325 Binary files /dev/null and b/public/tiktok/7602801434245074194.jpg differ diff --git a/public/tiktok/7602808291353201938.jpg b/public/tiktok/7602808291353201938.jpg new file mode 100644 index 0000000..be020a6 Binary files /dev/null and b/public/tiktok/7602808291353201938.jpg differ diff --git a/public/tiktok/7603155425307184402.jpg b/public/tiktok/7603155425307184402.jpg new file mode 100644 index 0000000..f187417 Binary files /dev/null and b/public/tiktok/7603155425307184402.jpg differ diff --git a/public/tiktok/7603159313737190664.jpg b/public/tiktok/7603159313737190664.jpg new file mode 100644 index 0000000..2795997 Binary files /dev/null and b/public/tiktok/7603159313737190664.jpg differ diff --git a/public/tiktok/7603162462015245576.jpg b/public/tiktok/7603162462015245576.jpg new file mode 100644 index 0000000..0b92b62 Binary files /dev/null and b/public/tiktok/7603162462015245576.jpg differ diff --git a/public/tiktok/7603170263777201416.jpg b/public/tiktok/7603170263777201416.jpg new file mode 100644 index 0000000..3c46cbe Binary files /dev/null and b/public/tiktok/7603170263777201416.jpg differ diff --git a/public/tiktok/7603172717289950472.jpg b/public/tiktok/7603172717289950472.jpg new file mode 100644 index 0000000..1562a11 Binary files /dev/null and b/public/tiktok/7603172717289950472.jpg differ diff --git a/public/tiktok/7603176846171098375.jpg b/public/tiktok/7603176846171098375.jpg new file mode 100644 index 0000000..c6335de Binary files /dev/null and b/public/tiktok/7603176846171098375.jpg differ diff --git a/public/tiktok/7603529785150672146.jpg b/public/tiktok/7603529785150672146.jpg new file mode 100644 index 0000000..5fb2d5f Binary files /dev/null and b/public/tiktok/7603529785150672146.jpg differ diff --git a/public/tiktok/7603533611102424328.jpg b/public/tiktok/7603533611102424328.jpg new file mode 100644 index 0000000..9cc6eec Binary files /dev/null and b/public/tiktok/7603533611102424328.jpg differ diff --git a/public/tiktok/7603541240449223943.jpg b/public/tiktok/7603541240449223943.jpg new file mode 100644 index 0000000..14c3a97 Binary files /dev/null and b/public/tiktok/7603541240449223943.jpg differ diff --git a/public/tiktok/7603542827678715156.jpg b/public/tiktok/7603542827678715156.jpg new file mode 100644 index 0000000..d3489a4 Binary files /dev/null and b/public/tiktok/7603542827678715156.jpg differ diff --git a/public/tiktok/7603547156028476693.jpg b/public/tiktok/7603547156028476693.jpg new file mode 100644 index 0000000..b192875 Binary files /dev/null and b/public/tiktok/7603547156028476693.jpg differ diff --git a/public/tiktok/7603547720111901970.jpg b/public/tiktok/7603547720111901970.jpg new file mode 100644 index 0000000..df6a68d Binary files /dev/null and b/public/tiktok/7603547720111901970.jpg differ diff --git a/public/tiktok/7603904300644961556.jpg b/public/tiktok/7603904300644961556.jpg new file mode 100644 index 0000000..3192b5c Binary files /dev/null and b/public/tiktok/7603904300644961556.jpg differ diff --git a/public/tiktok/7603905329964895495.jpg b/public/tiktok/7603905329964895495.jpg new file mode 100644 index 0000000..3259fd6 Binary files /dev/null and b/public/tiktok/7603905329964895495.jpg differ diff --git a/public/tiktok/7603907885462080775.jpg b/public/tiktok/7603907885462080775.jpg new file mode 100644 index 0000000..8432fa9 Binary files /dev/null and b/public/tiktok/7603907885462080775.jpg differ diff --git a/public/tiktok/7603915141607853332.jpg b/public/tiktok/7603915141607853332.jpg new file mode 100644 index 0000000..43abf56 Binary files /dev/null and b/public/tiktok/7603915141607853332.jpg differ diff --git a/public/tiktok/7603916425044036882.jpg b/public/tiktok/7603916425044036882.jpg new file mode 100644 index 0000000..61c9d30 Binary files /dev/null and b/public/tiktok/7603916425044036882.jpg differ diff --git a/public/tiktok/7603917143503113492.jpg b/public/tiktok/7603917143503113492.jpg new file mode 100644 index 0000000..4317427 Binary files /dev/null and b/public/tiktok/7603917143503113492.jpg differ diff --git a/public/tiktok/7603919277430721810.jpg b/public/tiktok/7603919277430721810.jpg new file mode 100644 index 0000000..246b574 Binary files /dev/null and b/public/tiktok/7603919277430721810.jpg differ diff --git a/public/tiktok/7603922127410056469.jpg b/public/tiktok/7603922127410056469.jpg new file mode 100644 index 0000000..7837280 Binary files /dev/null and b/public/tiktok/7603922127410056469.jpg differ diff --git a/public/tiktok/7605009292281859335.jpg b/public/tiktok/7605009292281859335.jpg new file mode 100644 index 0000000..b54528e Binary files /dev/null and b/public/tiktok/7605009292281859335.jpg differ diff --git a/public/tiktok/7605011840698944786.jpg b/public/tiktok/7605011840698944786.jpg new file mode 100644 index 0000000..73aa65f Binary files /dev/null and b/public/tiktok/7605011840698944786.jpg differ diff --git a/public/tiktok/7605011911742115093.jpg b/public/tiktok/7605011911742115093.jpg new file mode 100644 index 0000000..eccf8e6 Binary files /dev/null and b/public/tiktok/7605011911742115093.jpg differ diff --git a/public/tiktok/7605014829023251719.jpg b/public/tiktok/7605014829023251719.jpg new file mode 100644 index 0000000..cfed514 Binary files /dev/null and b/public/tiktok/7605014829023251719.jpg differ diff --git a/public/tiktok/7605017071356792084.jpg b/public/tiktok/7605017071356792084.jpg new file mode 100644 index 0000000..093cf59 Binary files /dev/null and b/public/tiktok/7605017071356792084.jpg differ diff --git a/public/tiktok/7605025108616350996.jpg b/public/tiktok/7605025108616350996.jpg new file mode 100644 index 0000000..7af1862 Binary files /dev/null and b/public/tiktok/7605025108616350996.jpg differ diff --git a/public/tiktok/7605027310227426567.jpg b/public/tiktok/7605027310227426567.jpg new file mode 100644 index 0000000..69bb4dc Binary files /dev/null and b/public/tiktok/7605027310227426567.jpg differ diff --git a/public/tiktok/7605030386606951687.jpg b/public/tiktok/7605030386606951687.jpg new file mode 100644 index 0000000..7594b36 Binary files /dev/null and b/public/tiktok/7605030386606951687.jpg differ diff --git a/public/tiktok/7605031715878341908.jpg b/public/tiktok/7605031715878341908.jpg new file mode 100644 index 0000000..a8e6bf0 Binary files /dev/null and b/public/tiktok/7605031715878341908.jpg differ diff --git a/public/tiktok/7605034148218113301.jpg b/public/tiktok/7605034148218113301.jpg new file mode 100644 index 0000000..c27efb5 Binary files /dev/null and b/public/tiktok/7605034148218113301.jpg differ diff --git a/public/tiktok/7605385078083980565.jpg b/public/tiktok/7605385078083980565.jpg new file mode 100644 index 0000000..ebdb77e Binary files /dev/null and b/public/tiktok/7605385078083980565.jpg differ diff --git a/public/tiktok/7605388929088441621.jpg b/public/tiktok/7605388929088441621.jpg new file mode 100644 index 0000000..85e515d Binary files /dev/null and b/public/tiktok/7605388929088441621.jpg differ diff --git a/public/tiktok/7605393075606850836.jpg b/public/tiktok/7605393075606850836.jpg new file mode 100644 index 0000000..d9867cb Binary files /dev/null and b/public/tiktok/7605393075606850836.jpg differ diff --git a/public/tiktok/7605403420501134613.jpg b/public/tiktok/7605403420501134613.jpg new file mode 100644 index 0000000..610b873 Binary files /dev/null and b/public/tiktok/7605403420501134613.jpg differ diff --git a/public/tiktok/7605763423334337813.jpg b/public/tiktok/7605763423334337813.jpg new file mode 100644 index 0000000..e0d54df Binary files /dev/null and b/public/tiktok/7605763423334337813.jpg differ diff --git a/public/tiktok/7605765455831141653.jpg b/public/tiktok/7605765455831141653.jpg new file mode 100644 index 0000000..21b9cec Binary files /dev/null and b/public/tiktok/7605765455831141653.jpg differ diff --git a/public/tiktok/7605771533738380564.jpg b/public/tiktok/7605771533738380564.jpg new file mode 100644 index 0000000..e286378 Binary files /dev/null and b/public/tiktok/7605771533738380564.jpg differ diff --git a/public/tiktok/7605774409177107732.jpg b/public/tiktok/7605774409177107732.jpg new file mode 100644 index 0000000..e76d78f Binary files /dev/null and b/public/tiktok/7605774409177107732.jpg differ diff --git a/public/tiktok/7605777886037003541.jpg b/public/tiktok/7605777886037003541.jpg new file mode 100644 index 0000000..e4fc2f1 Binary files /dev/null and b/public/tiktok/7605777886037003541.jpg differ diff --git a/public/tiktok/7606128084961545493.jpg b/public/tiktok/7606128084961545493.jpg new file mode 100644 index 0000000..660fdb3 Binary files /dev/null and b/public/tiktok/7606128084961545493.jpg differ diff --git a/public/tiktok/7606131787672079636.jpg b/public/tiktok/7606131787672079636.jpg new file mode 100644 index 0000000..a2adbf2 Binary files /dev/null and b/public/tiktok/7606131787672079636.jpg differ diff --git a/public/tiktok/7606137412191882517.jpg b/public/tiktok/7606137412191882517.jpg new file mode 100644 index 0000000..2dc8660 Binary files /dev/null and b/public/tiktok/7606137412191882517.jpg differ diff --git a/public/tiktok/7606141425520053524.jpg b/public/tiktok/7606141425520053524.jpg new file mode 100644 index 0000000..44b3aee Binary files /dev/null and b/public/tiktok/7606141425520053524.jpg differ diff --git a/public/tiktok/7606146061446417685.jpg b/public/tiktok/7606146061446417685.jpg new file mode 100644 index 0000000..610b873 Binary files /dev/null and b/public/tiktok/7606146061446417685.jpg differ diff --git a/public/tiktok/7608360935576603922.jpg b/public/tiktok/7608360935576603922.jpg new file mode 100644 index 0000000..23c9047 Binary files /dev/null and b/public/tiktok/7608360935576603922.jpg differ diff --git a/public/tiktok/7608725450415770888.jpg b/public/tiktok/7608725450415770888.jpg new file mode 100644 index 0000000..065d68d Binary files /dev/null and b/public/tiktok/7608725450415770888.jpg differ diff --git a/public/tiktok/7608727936786648338.jpg b/public/tiktok/7608727936786648338.jpg new file mode 100644 index 0000000..4b3f950 Binary files /dev/null and b/public/tiktok/7608727936786648338.jpg differ diff --git a/public/tiktok/7608739596989451527.jpg b/public/tiktok/7608739596989451527.jpg new file mode 100644 index 0000000..0f3b574 Binary files /dev/null and b/public/tiktok/7608739596989451527.jpg differ diff --git a/public/tiktok/7608741747623070983.jpg b/public/tiktok/7608741747623070983.jpg new file mode 100644 index 0000000..504a73f Binary files /dev/null and b/public/tiktok/7608741747623070983.jpg differ diff --git a/public/tiktok/7609095709073263880.jpg b/public/tiktok/7609095709073263880.jpg new file mode 100644 index 0000000..8bd32da Binary files /dev/null and b/public/tiktok/7609095709073263880.jpg differ diff --git a/public/tiktok/7609098109934767367.jpg b/public/tiktok/7609098109934767367.jpg new file mode 100644 index 0000000..3a39557 Binary files /dev/null and b/public/tiktok/7609098109934767367.jpg differ diff --git a/public/tiktok/7609100427375725832.jpg b/public/tiktok/7609100427375725832.jpg new file mode 100644 index 0000000..056da59 Binary files /dev/null and b/public/tiktok/7609100427375725832.jpg differ diff --git a/public/tiktok/7609113519203667218.jpg b/public/tiktok/7609113519203667218.jpg new file mode 100644 index 0000000..a47ef48 Binary files /dev/null and b/public/tiktok/7609113519203667218.jpg differ diff --git a/public/tiktok/7609115330388053256.jpg b/public/tiktok/7609115330388053256.jpg new file mode 100644 index 0000000..213e2eb Binary files /dev/null and b/public/tiktok/7609115330388053256.jpg differ diff --git a/public/tiktok/7609117013834845447.jpg b/public/tiktok/7609117013834845447.jpg new file mode 100644 index 0000000..892e3ad Binary files /dev/null and b/public/tiktok/7609117013834845447.jpg differ diff --git a/public/tiktok/7610205552127266066.jpg b/public/tiktok/7610205552127266066.jpg new file mode 100644 index 0000000..2504524 Binary files /dev/null and b/public/tiktok/7610205552127266066.jpg differ diff --git a/public/tiktok/7610212093278555413.jpg b/public/tiktok/7610212093278555413.jpg new file mode 100644 index 0000000..cc62995 Binary files /dev/null and b/public/tiktok/7610212093278555413.jpg differ diff --git a/public/tiktok/7610214043680345352.jpg b/public/tiktok/7610214043680345352.jpg new file mode 100644 index 0000000..29e9588 Binary files /dev/null and b/public/tiktok/7610214043680345352.jpg differ diff --git a/public/tiktok/7610214868418956565.jpg b/public/tiktok/7610214868418956565.jpg new file mode 100644 index 0000000..f312849 Binary files /dev/null and b/public/tiktok/7610214868418956565.jpg differ diff --git a/public/tiktok/7610220494742211858.jpg b/public/tiktok/7610220494742211858.jpg new file mode 100644 index 0000000..dada332 Binary files /dev/null and b/public/tiktok/7610220494742211858.jpg differ diff --git a/public/tiktok/7610225854345399573.jpg b/public/tiktok/7610225854345399573.jpg new file mode 100644 index 0000000..ff8a1da Binary files /dev/null and b/public/tiktok/7610225854345399573.jpg differ diff --git a/public/tiktok/7610226305556221191.jpg b/public/tiktok/7610226305556221191.jpg new file mode 100644 index 0000000..033adf2 Binary files /dev/null and b/public/tiktok/7610226305556221191.jpg differ diff --git a/public/tiktok/7610228483909782791.jpg b/public/tiktok/7610228483909782791.jpg new file mode 100644 index 0000000..4c6f107 Binary files /dev/null and b/public/tiktok/7610228483909782791.jpg differ diff --git a/public/tiktok/7610229016678796565.jpg b/public/tiktok/7610229016678796565.jpg new file mode 100644 index 0000000..9884212 Binary files /dev/null and b/public/tiktok/7610229016678796565.jpg differ diff --git a/public/tiktok/7610582097748577544.jpg b/public/tiktok/7610582097748577544.jpg new file mode 100644 index 0000000..733b56c Binary files /dev/null and b/public/tiktok/7610582097748577544.jpg differ diff --git a/public/tiktok/7610586339880242453.jpg b/public/tiktok/7610586339880242453.jpg new file mode 100644 index 0000000..410546a Binary files /dev/null and b/public/tiktok/7610586339880242453.jpg differ diff --git a/public/tiktok/7610591010338458901.jpg b/public/tiktok/7610591010338458901.jpg new file mode 100644 index 0000000..c7b9da9 Binary files /dev/null and b/public/tiktok/7610591010338458901.jpg differ diff --git a/public/tiktok/7610593736338197780.jpg b/public/tiktok/7610593736338197780.jpg new file mode 100644 index 0000000..1282d7d Binary files /dev/null and b/public/tiktok/7610593736338197780.jpg differ diff --git a/public/tiktok/7610594867172920584.jpg b/public/tiktok/7610594867172920584.jpg new file mode 100644 index 0000000..da9f9d4 Binary files /dev/null and b/public/tiktok/7610594867172920584.jpg differ diff --git a/public/tiktok/7610596527475608853.jpg b/public/tiktok/7610596527475608853.jpg new file mode 100644 index 0000000..20e6aac Binary files /dev/null and b/public/tiktok/7610596527475608853.jpg differ diff --git a/public/tiktok/7610599550029516053.jpg b/public/tiktok/7610599550029516053.jpg new file mode 100644 index 0000000..ecfdff8 Binary files /dev/null and b/public/tiktok/7610599550029516053.jpg differ diff --git a/public/tiktok/7610608531678530823.jpg b/public/tiktok/7610608531678530823.jpg new file mode 100644 index 0000000..2e1e224 Binary files /dev/null and b/public/tiktok/7610608531678530823.jpg differ diff --git a/public/tiktok/7611326035031788818.jpg b/public/tiktok/7611326035031788818.jpg new file mode 100644 index 0000000..9c89593 Binary files /dev/null and b/public/tiktok/7611326035031788818.jpg differ diff --git a/public/tiktok/7611328364732845320.jpg b/public/tiktok/7611328364732845320.jpg new file mode 100644 index 0000000..d41f7a2 Binary files /dev/null and b/public/tiktok/7611328364732845320.jpg differ diff --git a/public/tiktok/7611330525692841224.jpg b/public/tiktok/7611330525692841224.jpg new file mode 100644 index 0000000..3d7322e Binary files /dev/null and b/public/tiktok/7611330525692841224.jpg differ diff --git a/public/tiktok/7611332584718568711.jpg b/public/tiktok/7611332584718568711.jpg new file mode 100644 index 0000000..27417f9 Binary files /dev/null and b/public/tiktok/7611332584718568711.jpg differ diff --git a/public/tiktok/7611333495373204756.jpg b/public/tiktok/7611333495373204756.jpg new file mode 100644 index 0000000..33918f3 Binary files /dev/null and b/public/tiktok/7611333495373204756.jpg differ diff --git a/public/tiktok/7611333628567571732.jpg b/public/tiktok/7611333628567571732.jpg new file mode 100644 index 0000000..71a560e Binary files /dev/null and b/public/tiktok/7611333628567571732.jpg differ diff --git a/public/tiktok/7611333870310460693.jpg b/public/tiktok/7611333870310460693.jpg new file mode 100644 index 0000000..d6c98de Binary files /dev/null and b/public/tiktok/7611333870310460693.jpg differ diff --git a/public/tiktok/7611334042528533780.jpg b/public/tiktok/7611334042528533780.jpg new file mode 100644 index 0000000..00c1512 Binary files /dev/null and b/public/tiktok/7611334042528533780.jpg differ diff --git a/public/tiktok/7611334215837256981.jpg b/public/tiktok/7611334215837256981.jpg new file mode 100644 index 0000000..610b873 Binary files /dev/null and b/public/tiktok/7611334215837256981.jpg differ diff --git a/public/tiktok/7611334414362004743.jpg b/public/tiktok/7611334414362004743.jpg new file mode 100644 index 0000000..b543a7f Binary files /dev/null and b/public/tiktok/7611334414362004743.jpg differ diff --git a/public/tiktok/7611694991936736530.jpg b/public/tiktok/7611694991936736530.jpg new file mode 100644 index 0000000..5dd1b6a Binary files /dev/null and b/public/tiktok/7611694991936736530.jpg differ diff --git a/public/tiktok/7611698597385522440.jpg b/public/tiktok/7611698597385522440.jpg new file mode 100644 index 0000000..b15edb2 Binary files /dev/null and b/public/tiktok/7611698597385522440.jpg differ diff --git a/public/tiktok/7611700828247706887.jpg b/public/tiktok/7611700828247706887.jpg new file mode 100644 index 0000000..b0a1767 Binary files /dev/null and b/public/tiktok/7611700828247706887.jpg differ diff --git a/public/tiktok/7611702983197314311.jpg b/public/tiktok/7611702983197314311.jpg new file mode 100644 index 0000000..2225037 Binary files /dev/null and b/public/tiktok/7611702983197314311.jpg differ diff --git a/public/tiktok/7611704941241060626.jpg b/public/tiktok/7611704941241060626.jpg new file mode 100644 index 0000000..f09e0c2 Binary files /dev/null and b/public/tiktok/7611704941241060626.jpg differ diff --git a/public/tiktok/7611712572877851925.jpg b/public/tiktok/7611712572877851925.jpg new file mode 100644 index 0000000..c3b9983 Binary files /dev/null and b/public/tiktok/7611712572877851925.jpg differ diff --git a/public/tiktok/7611712751257292053.jpg b/public/tiktok/7611712751257292053.jpg new file mode 100644 index 0000000..abde576 Binary files /dev/null and b/public/tiktok/7611712751257292053.jpg differ diff --git a/public/tiktok/7611712896271240469.jpg b/public/tiktok/7611712896271240469.jpg new file mode 100644 index 0000000..792479a Binary files /dev/null and b/public/tiktok/7611712896271240469.jpg differ diff --git a/public/tiktok/7611713028819537172.jpg b/public/tiktok/7611713028819537172.jpg new file mode 100644 index 0000000..dbc4006 Binary files /dev/null and b/public/tiktok/7611713028819537172.jpg differ diff --git a/public/tiktok/7611713202807754005.jpg b/public/tiktok/7611713202807754005.jpg new file mode 100644 index 0000000..0cc197f Binary files /dev/null and b/public/tiktok/7611713202807754005.jpg differ diff --git a/public/tiktok/7612811446468005138.jpg b/public/tiktok/7612811446468005138.jpg new file mode 100644 index 0000000..b647696 Binary files /dev/null and b/public/tiktok/7612811446468005138.jpg differ diff --git a/public/tiktok/7612813070410337554.jpg b/public/tiktok/7612813070410337554.jpg new file mode 100644 index 0000000..5ce54b4 Binary files /dev/null and b/public/tiktok/7612813070410337554.jpg differ diff --git a/public/tiktok/7612815416146103570.jpg b/public/tiktok/7612815416146103570.jpg new file mode 100644 index 0000000..e2a139d Binary files /dev/null and b/public/tiktok/7612815416146103570.jpg differ diff --git a/public/tiktok/7612818180699852050.jpg b/public/tiktok/7612818180699852050.jpg new file mode 100644 index 0000000..9a73fe9 Binary files /dev/null and b/public/tiktok/7612818180699852050.jpg differ diff --git a/public/tiktok/7612824588543626516.jpg b/public/tiktok/7612824588543626516.jpg new file mode 100644 index 0000000..e0a70dc Binary files /dev/null and b/public/tiktok/7612824588543626516.jpg differ diff --git a/public/tiktok/7612824727228271892.jpg b/public/tiktok/7612824727228271892.jpg new file mode 100644 index 0000000..77f6263 Binary files /dev/null and b/public/tiktok/7612824727228271892.jpg differ diff --git a/public/tiktok/7612824883743001877.jpg b/public/tiktok/7612824883743001877.jpg new file mode 100644 index 0000000..f05ba77 Binary files /dev/null and b/public/tiktok/7612824883743001877.jpg differ diff --git a/public/tiktok/7612825010587077908.jpg b/public/tiktok/7612825010587077908.jpg new file mode 100644 index 0000000..cef901c Binary files /dev/null and b/public/tiktok/7612825010587077908.jpg differ diff --git a/public/tiktok/7612825138291166485.jpg b/public/tiktok/7612825138291166485.jpg new file mode 100644 index 0000000..6d16600 Binary files /dev/null and b/public/tiktok/7612825138291166485.jpg differ diff --git a/public/tiktok/7613178679929228552.jpg b/public/tiktok/7613178679929228552.jpg new file mode 100644 index 0000000..234dc5c Binary files /dev/null and b/public/tiktok/7613178679929228552.jpg differ diff --git a/public/tiktok/7613180263983549703.jpg b/public/tiktok/7613180263983549703.jpg new file mode 100644 index 0000000..813e0e8 Binary files /dev/null and b/public/tiktok/7613180263983549703.jpg differ diff --git a/public/tiktok/7613182036366478610.jpg b/public/tiktok/7613182036366478610.jpg new file mode 100644 index 0000000..3e8d7b3 Binary files /dev/null and b/public/tiktok/7613182036366478610.jpg differ diff --git a/public/tiktok/7613183865204641031.jpg b/public/tiktok/7613183865204641031.jpg new file mode 100644 index 0000000..669f20a Binary files /dev/null and b/public/tiktok/7613183865204641031.jpg differ diff --git a/public/tiktok/7613185644747164935.jpg b/public/tiktok/7613185644747164935.jpg new file mode 100644 index 0000000..a2956d7 Binary files /dev/null and b/public/tiktok/7613185644747164935.jpg differ diff --git a/public/tiktok/7613607211008363784.jpg b/public/tiktok/7613607211008363784.jpg new file mode 100644 index 0000000..359cf01 Binary files /dev/null and b/public/tiktok/7613607211008363784.jpg differ diff --git a/public/tiktok/7613608501083917576.jpg b/public/tiktok/7613608501083917576.jpg new file mode 100644 index 0000000..e6f4926 Binary files /dev/null and b/public/tiktok/7613608501083917576.jpg differ diff --git a/public/tiktok/7613609941160529160.jpg b/public/tiktok/7613609941160529160.jpg new file mode 100644 index 0000000..337b68d Binary files /dev/null and b/public/tiktok/7613609941160529160.jpg differ diff --git a/public/tiktok/7613611646375693576.jpg b/public/tiktok/7613611646375693576.jpg new file mode 100644 index 0000000..68170e7 Binary files /dev/null and b/public/tiktok/7613611646375693576.jpg differ diff --git a/public/tiktok/7613613311300504850.jpg b/public/tiktok/7613613311300504850.jpg new file mode 100644 index 0000000..ff4dcc4 Binary files /dev/null and b/public/tiktok/7613613311300504850.jpg differ diff --git a/public/tiktok/7613938879388798228.jpg b/public/tiktok/7613938879388798228.jpg new file mode 100644 index 0000000..bbda2e5 Binary files /dev/null and b/public/tiktok/7613938879388798228.jpg differ diff --git a/public/tiktok/7613939116627086613.jpg b/public/tiktok/7613939116627086613.jpg new file mode 100644 index 0000000..43d54f2 Binary files /dev/null and b/public/tiktok/7613939116627086613.jpg differ diff --git a/public/tiktok/7613939329387220245.jpg b/public/tiktok/7613939329387220245.jpg new file mode 100644 index 0000000..88948b7 Binary files /dev/null and b/public/tiktok/7613939329387220245.jpg differ diff --git a/public/tiktok/7613939542713715989.jpg b/public/tiktok/7613939542713715989.jpg new file mode 100644 index 0000000..88948b7 Binary files /dev/null and b/public/tiktok/7613939542713715989.jpg differ diff --git a/public/tiktok/7613939774008593684.jpg b/public/tiktok/7613939774008593684.jpg new file mode 100644 index 0000000..e5f86c0 Binary files /dev/null and b/public/tiktok/7613939774008593684.jpg differ diff --git a/public/tiktok/7613974424663952658.jpg b/public/tiktok/7613974424663952658.jpg new file mode 100644 index 0000000..5dc22c5 Binary files /dev/null and b/public/tiktok/7613974424663952658.jpg differ diff --git a/public/tiktok/7613976159252008199.jpg b/public/tiktok/7613976159252008199.jpg new file mode 100644 index 0000000..9d73e97 Binary files /dev/null and b/public/tiktok/7613976159252008199.jpg differ diff --git a/public/tiktok/7613977546417310983.jpg b/public/tiktok/7613977546417310983.jpg new file mode 100644 index 0000000..cbe435b Binary files /dev/null and b/public/tiktok/7613977546417310983.jpg differ diff --git a/public/tiktok/7613978916574874887.jpg b/public/tiktok/7613978916574874887.jpg new file mode 100644 index 0000000..86dbb07 Binary files /dev/null and b/public/tiktok/7613978916574874887.jpg differ diff --git a/public/tiktok/7613982935712107794.jpg b/public/tiktok/7613982935712107794.jpg new file mode 100644 index 0000000..57dba06 Binary files /dev/null and b/public/tiktok/7613982935712107794.jpg differ diff --git a/public/tiktok/7614306570301738260.jpg b/public/tiktok/7614306570301738260.jpg new file mode 100644 index 0000000..df1a3c2 Binary files /dev/null and b/public/tiktok/7614306570301738260.jpg differ diff --git a/public/tiktok/7614306744541383957.jpg b/public/tiktok/7614306744541383957.jpg new file mode 100644 index 0000000..4aaf591 Binary files /dev/null and b/public/tiktok/7614306744541383957.jpg differ diff --git a/public/tiktok/7614306835511790868.jpg b/public/tiktok/7614306835511790868.jpg new file mode 100644 index 0000000..a5b2f01 Binary files /dev/null and b/public/tiktok/7614306835511790868.jpg differ diff --git a/public/tiktok/7614306978164264213.jpg b/public/tiktok/7614306978164264213.jpg new file mode 100644 index 0000000..d8972ce Binary files /dev/null and b/public/tiktok/7614306978164264213.jpg differ diff --git a/public/tiktok/7614307089007021332.jpg b/public/tiktok/7614307089007021332.jpg new file mode 100644 index 0000000..4d3ca7d Binary files /dev/null and b/public/tiktok/7614307089007021332.jpg differ diff --git a/public/tiktok/7615058166895709458.jpg b/public/tiktok/7615058166895709458.jpg new file mode 100644 index 0000000..1c2b9ff Binary files /dev/null and b/public/tiktok/7615058166895709458.jpg differ diff --git a/public/tiktok/7615059773691546887.jpg b/public/tiktok/7615059773691546887.jpg new file mode 100644 index 0000000..6163e7e Binary files /dev/null and b/public/tiktok/7615059773691546887.jpg differ diff --git a/public/tiktok/7615061277148187922.jpg b/public/tiktok/7615061277148187922.jpg new file mode 100644 index 0000000..ec90edc Binary files /dev/null and b/public/tiktok/7615061277148187922.jpg differ diff --git a/public/tiktok/7615063282382146824.jpg b/public/tiktok/7615063282382146824.jpg new file mode 100644 index 0000000..7fab61d Binary files /dev/null and b/public/tiktok/7615063282382146824.jpg differ diff --git a/public/tiktok/7615066189110185234.jpg b/public/tiktok/7615066189110185234.jpg new file mode 100644 index 0000000..037ff90 Binary files /dev/null and b/public/tiktok/7615066189110185234.jpg differ diff --git a/public/tiktok/7615417396140068116.jpg b/public/tiktok/7615417396140068116.jpg new file mode 100644 index 0000000..5e40352 Binary files /dev/null and b/public/tiktok/7615417396140068116.jpg differ diff --git a/public/tiktok/7615417606446632213.jpg b/public/tiktok/7615417606446632213.jpg new file mode 100644 index 0000000..5109443 Binary files /dev/null and b/public/tiktok/7615417606446632213.jpg differ diff --git a/public/tiktok/7615417898932243732.jpg b/public/tiktok/7615417898932243732.jpg new file mode 100644 index 0000000..3151660 Binary files /dev/null and b/public/tiktok/7615417898932243732.jpg differ diff --git a/public/tiktok/7615418100892192020.jpg b/public/tiktok/7615418100892192020.jpg new file mode 100644 index 0000000..3d1e022 Binary files /dev/null and b/public/tiktok/7615418100892192020.jpg differ diff --git a/public/tiktok/7615418268869741845.jpg b/public/tiktok/7615418268869741845.jpg new file mode 100644 index 0000000..ba824ba Binary files /dev/null and b/public/tiktok/7615418268869741845.jpg differ diff --git a/public/tiktok/7615447194316885256.jpg b/public/tiktok/7615447194316885256.jpg new file mode 100644 index 0000000..687bd7c Binary files /dev/null and b/public/tiktok/7615447194316885256.jpg differ diff --git a/public/tiktok/7615448674025442567.jpg b/public/tiktok/7615448674025442567.jpg new file mode 100644 index 0000000..1c8427c Binary files /dev/null and b/public/tiktok/7615448674025442567.jpg differ diff --git a/public/tiktok/7615450387285331208.jpg b/public/tiktok/7615450387285331208.jpg new file mode 100644 index 0000000..22139ea Binary files /dev/null and b/public/tiktok/7615450387285331208.jpg differ diff --git a/public/tiktok/7615452362475392264.jpg b/public/tiktok/7615452362475392264.jpg new file mode 100644 index 0000000..69cfbbc Binary files /dev/null and b/public/tiktok/7615452362475392264.jpg differ diff --git a/public/tiktok/7615454710916533511.jpg b/public/tiktok/7615454710916533511.jpg new file mode 100644 index 0000000..e8cae6e Binary files /dev/null and b/public/tiktok/7615454710916533511.jpg differ diff --git a/public/tiktok/7615791481458117895.jpg b/public/tiktok/7615791481458117895.jpg new file mode 100644 index 0000000..1f274a6 Binary files /dev/null and b/public/tiktok/7615791481458117895.jpg differ diff --git a/public/tiktok/7615792335791787285.jpg b/public/tiktok/7615792335791787285.jpg new file mode 100644 index 0000000..1307359 Binary files /dev/null and b/public/tiktok/7615792335791787285.jpg differ diff --git a/public/tiktok/7615792557251071253.jpg b/public/tiktok/7615792557251071253.jpg new file mode 100644 index 0000000..8636f80 Binary files /dev/null and b/public/tiktok/7615792557251071253.jpg differ diff --git a/public/tiktok/7615792741502487828.jpg b/public/tiktok/7615792741502487828.jpg new file mode 100644 index 0000000..bed899e Binary files /dev/null and b/public/tiktok/7615792741502487828.jpg differ diff --git a/public/tiktok/7615792939800808725.jpg b/public/tiktok/7615792939800808725.jpg new file mode 100644 index 0000000..9aff9cb Binary files /dev/null and b/public/tiktok/7615792939800808725.jpg differ diff --git a/public/tiktok/7615793139785288980.jpg b/public/tiktok/7615793139785288980.jpg new file mode 100644 index 0000000..c9f97fa Binary files /dev/null and b/public/tiktok/7615793139785288980.jpg differ diff --git a/public/tiktok/7615793545781267730.jpg b/public/tiktok/7615793545781267730.jpg new file mode 100644 index 0000000..99ae31a Binary files /dev/null and b/public/tiktok/7615793545781267730.jpg differ diff --git a/public/tiktok/7615795344672132360.jpg b/public/tiktok/7615795344672132360.jpg new file mode 100644 index 0000000..e958351 Binary files /dev/null and b/public/tiktok/7615795344672132360.jpg differ diff --git a/public/tiktok/7615798501636672776.jpg b/public/tiktok/7615798501636672776.jpg new file mode 100644 index 0000000..e4f3d96 Binary files /dev/null and b/public/tiktok/7615798501636672776.jpg differ diff --git a/public/tiktok/7615802426007801096.jpg b/public/tiktok/7615802426007801096.jpg new file mode 100644 index 0000000..0191dc6 Binary files /dev/null and b/public/tiktok/7615802426007801096.jpg differ diff --git a/public/tiktok/7616157825328254215.jpg b/public/tiktok/7616157825328254215.jpg new file mode 100644 index 0000000..ff81a68 Binary files /dev/null and b/public/tiktok/7616157825328254215.jpg differ diff --git a/public/tiktok/7616159717517167879.jpg b/public/tiktok/7616159717517167879.jpg new file mode 100644 index 0000000..4959180 Binary files /dev/null and b/public/tiktok/7616159717517167879.jpg differ diff --git a/public/tiktok/7616163461604609287.jpg b/public/tiktok/7616163461604609287.jpg new file mode 100644 index 0000000..0ee897c Binary files /dev/null and b/public/tiktok/7616163461604609287.jpg differ diff --git a/public/tiktok/7616164443701103893.jpg b/public/tiktok/7616164443701103893.jpg new file mode 100644 index 0000000..01b9b27 Binary files /dev/null and b/public/tiktok/7616164443701103893.jpg differ diff --git a/public/tiktok/7616164636920007957.jpg b/public/tiktok/7616164636920007957.jpg new file mode 100644 index 0000000..ab8f959 Binary files /dev/null and b/public/tiktok/7616164636920007957.jpg differ diff --git a/public/tiktok/7616164791706717461.jpg b/public/tiktok/7616164791706717461.jpg new file mode 100644 index 0000000..09c5857 Binary files /dev/null and b/public/tiktok/7616164791706717461.jpg differ diff --git a/public/tiktok/7616164954739182869.jpg b/public/tiktok/7616164954739182869.jpg new file mode 100644 index 0000000..8bd4c12 Binary files /dev/null and b/public/tiktok/7616164954739182869.jpg differ diff --git a/public/tiktok/7616165126718115093.jpg b/public/tiktok/7616165126718115093.jpg new file mode 100644 index 0000000..c3db034 Binary files /dev/null and b/public/tiktok/7616165126718115093.jpg differ diff --git a/public/tiktok/7616166257783606535.jpg b/public/tiktok/7616166257783606535.jpg new file mode 100644 index 0000000..275424a Binary files /dev/null and b/public/tiktok/7616166257783606535.jpg differ diff --git a/public/tiktok/7616168374267055378.jpg b/public/tiktok/7616168374267055378.jpg new file mode 100644 index 0000000..0510499 Binary files /dev/null and b/public/tiktok/7616168374267055378.jpg differ diff --git a/public/tiktok/7616535620449619220.jpg b/public/tiktok/7616535620449619220.jpg new file mode 100644 index 0000000..645d6bc Binary files /dev/null and b/public/tiktok/7616535620449619220.jpg differ diff --git a/public/tiktok/7616535801245093141.jpg b/public/tiktok/7616535801245093141.jpg new file mode 100644 index 0000000..470b56a Binary files /dev/null and b/public/tiktok/7616535801245093141.jpg differ diff --git a/public/tiktok/7616536083496455445.jpg b/public/tiktok/7616536083496455445.jpg new file mode 100644 index 0000000..2c8a9d3 Binary files /dev/null and b/public/tiktok/7616536083496455445.jpg differ diff --git a/public/tiktok/7616536245102972180.jpg b/public/tiktok/7616536245102972180.jpg new file mode 100644 index 0000000..51a3e1d Binary files /dev/null and b/public/tiktok/7616536245102972180.jpg differ diff --git a/public/tiktok/7616536467678055701.jpg b/public/tiktok/7616536467678055701.jpg new file mode 100644 index 0000000..cccd7e5 Binary files /dev/null and b/public/tiktok/7616536467678055701.jpg differ diff --git a/public/tiktok/7616902084092480789.jpg b/public/tiktok/7616902084092480789.jpg new file mode 100644 index 0000000..ef1eb84 Binary files /dev/null and b/public/tiktok/7616902084092480789.jpg differ diff --git a/public/tiktok/7616902289504193812.jpg b/public/tiktok/7616902289504193812.jpg new file mode 100644 index 0000000..e0b9bf2 Binary files /dev/null and b/public/tiktok/7616902289504193812.jpg differ diff --git a/public/tiktok/7616902503128501524.jpg b/public/tiktok/7616902503128501524.jpg new file mode 100644 index 0000000..ae51cef Binary files /dev/null and b/public/tiktok/7616902503128501524.jpg differ diff --git a/public/tiktok/7616902684678917397.jpg b/public/tiktok/7616902684678917397.jpg new file mode 100644 index 0000000..85b3c30 Binary files /dev/null and b/public/tiktok/7616902684678917397.jpg differ diff --git a/public/tiktok/7616902964158008596.jpg b/public/tiktok/7616902964158008596.jpg new file mode 100644 index 0000000..bdfcec8 Binary files /dev/null and b/public/tiktok/7616902964158008596.jpg differ diff --git a/public/tiktok/7618023226857311508.jpg b/public/tiktok/7618023226857311508.jpg new file mode 100644 index 0000000..592093d Binary files /dev/null and b/public/tiktok/7618023226857311508.jpg differ diff --git a/public/tiktok/7618023404637130005.jpg b/public/tiktok/7618023404637130005.jpg new file mode 100644 index 0000000..258e663 Binary files /dev/null and b/public/tiktok/7618023404637130005.jpg differ diff --git a/public/tiktok/7618023580994948372.jpg b/public/tiktok/7618023580994948372.jpg new file mode 100644 index 0000000..5b28a9a Binary files /dev/null and b/public/tiktok/7618023580994948372.jpg differ diff --git a/public/tiktok/7618023673798151444.jpg b/public/tiktok/7618023673798151444.jpg new file mode 100644 index 0000000..5b28a9a Binary files /dev/null and b/public/tiktok/7618023673798151444.jpg differ diff --git a/public/tiktok/7618023828597312788.jpg b/public/tiktok/7618023828597312788.jpg new file mode 100644 index 0000000..1f92bba Binary files /dev/null and b/public/tiktok/7618023828597312788.jpg differ diff --git a/public/tiktok/7618030206959340818.jpg b/public/tiktok/7618030206959340818.jpg new file mode 100644 index 0000000..b5da91b Binary files /dev/null and b/public/tiktok/7618030206959340818.jpg differ diff --git a/public/tiktok/7618031655797738760.jpg b/public/tiktok/7618031655797738760.jpg new file mode 100644 index 0000000..6461619 Binary files /dev/null and b/public/tiktok/7618031655797738760.jpg differ diff --git a/public/tiktok/7618032920892148999.jpg b/public/tiktok/7618032920892148999.jpg new file mode 100644 index 0000000..e5fa08f Binary files /dev/null and b/public/tiktok/7618032920892148999.jpg differ diff --git a/public/tiktok/7618034303322148114.jpg b/public/tiktok/7618034303322148114.jpg new file mode 100644 index 0000000..7204b29 Binary files /dev/null and b/public/tiktok/7618034303322148114.jpg differ diff --git a/public/tiktok/7618035152551218450.jpg b/public/tiktok/7618035152551218450.jpg new file mode 100644 index 0000000..1b0ea6c Binary files /dev/null and b/public/tiktok/7618035152551218450.jpg differ diff --git a/scripts/batch10-render.ts b/scripts/batch10-render.ts new file mode 100644 index 0000000..91b3995 --- /dev/null +++ b/scripts/batch10-render.ts @@ -0,0 +1,173 @@ +/** + * Batch InfiniteTalk render — one instance's share of the 10-video batch. + * + * npx tsx scripts/batch10-render.ts inst1 # renders v01..v05 + * npx tsx scripts/batch10-render.ts inst2 # renders v06..v10 + * + * Each instance run is independent; launch both for parallel overnight render. + * Uploads keyframe-v2.jpg + its 5 audios via scp, then submits one InfiniteTalk + * workflow per script, polls, and downloads to public/videos/ugc/batch10/. + * + * Workflow + schemas are the proven InfiniteTalk graph (see infinitetalk-render.ts). + */ +import { execSync } from 'child_process' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, + scripts: ['v01-howislegal', 'v02-shouldnt-share', 'v03-skeptic', 'v04-math', 'v05-friend-texted'] }, + inst2: { sshPort: 22378, sshHost: 'root@194.14.47.19', tunnelPort: 18891, + scripts: ['v06-stop-scrolling', 'v07-husband-surprise', 'v08-empty-rooms', 'v09-bucket-list', 'v10-dont-believe'] }, +} + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch10-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const REF_IMAGE = 'keyframe-v2.jpg' +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A 34-year-old woman with sun-kissed freckled skin and messy beach hair sits on a wicker chair on the terrace of a luxury Mexican resort. Golden hour sunlight. Soft natural smile. She talks casually to the camera in a conspiratorial way. Subtle natural head movements. Turquoise Caribbean ocean and palm trees softly blurred in background.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: REF_IMAGE } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_batch', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `batch-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { // 75 min ceiling + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch10/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + const cfgJson = JSON.parse(readFileSync('scripts/batch10-scripts.json', 'utf8')) + const byId: Record = {} + for (const s of cfgJson.scripts) byId[s.id] = s + + if (!existsSync('public/videos/ugc/batch10')) mkdirSync('public/videos/ugc/batch10', { recursive: true }) + + // Upload keyframe + this instance's audios + console.log(`[${which}] uploading inputs...`) + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch10/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} public/audio/fish/${REF_IMAGE} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch10/${id}.mp4`)) { console.log(`[${which}] ${id} already done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch10/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch10-scripts.json b/scripts/batch10-scripts.json new file mode 100644 index 0000000..30cbb75 --- /dev/null +++ b/scripts/batch10-scripts.json @@ -0,0 +1,67 @@ +{ + "_note": "10 TikTok UGC script variations for hi2b.com Mexico vacation certificate. Voice: Sarah (Fish ref 933563129e564b19a115bedd57b7406a). Each ~33-40s spoken. 'tts' is the continuous spoken text fed to Fish Audio (no stage directions). 'angle' and 'hook_text' are for tracking which hook wins.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { + "id": "v01-howislegal", + "angle": "Disbelief / price-shock hook", + "hook_text": "under $400 for the whole family - how is this legal", + "tts": "My husband booked our whole family's Mexico trip for under four hundred dollars, and I need someone to tell me how this is legal. It's a travel certificate. Five days, four nights, a real beachfront resort in Cancun. Two adults, two kids, and the kids stay completely free. I was sure it was a scam, so I read every single word. It's not. The resort just uses these to fill empty rooms. They'd rather have you down there spending at the bar than have the room sitting dark. We went, and it was everything they promised. It's hi2b dot com, link's in my bio. They only release these a batch at a time, so if it's up, don't think about it. Just go." + }, + { + "id": "v02-shouldnt-share", + "angle": "Conspiratorial / forbidden-knowledge hook", + "hook_text": "I probably shouldn't be sharing this", + "tts": "Okay, I probably shouldn't be sharing this, but I can't keep it to myself. There's a website called hi2b where you can get a real Mexico vacation, all-inclusive, five days and four nights, for about the price of one nice dinner out. A real beachfront resort. Two adults, and your kids under twelve stay free. I booked it twice because I was convinced the first one was fake. We just got back. Swim-up bar, tacos every morning, my daughter will not stop talking about the iguanas. Go to hi2b dot com and use the link in my bio before they cap it. You can thank me later." + }, + { + "id": "v03-skeptic", + "angle": "Converted-skeptic / objection-handling hook", + "hook_text": "I never fall for things online", + "tts": "I never fall for things online. I read every terms-and-conditions, I cancel free trials the same day I start them. So when my husband showed me this hi2b travel certificate, Mexico, all-inclusive, kids stay free, for under a few hundred dollars, I spent an hour trying to find the catch. There isn't a hidden one. Real resort, real dates, real food. We went, we came back, and it was exactly what they said it would be. The link is in my bio, it's hi2b dot com. Just look. That is all I'm asking." + }, + { + "id": "v04-math", + "angle": "Numbers-breakdown hook", + "hook_text": "let me do the math for you", + "tts": "Let me do the math on our family vacation, because it still doesn't make sense to me. Four nights, five days, beachfront resort in Cancun. All-inclusive food and drinks. Two adults, two kids under twelve free. Airport transfers included. You know what we paid? Three hundred and ninety-seven dollars. Total. Not per person, total. The trick is the resort uses these certificates to fill rooms that would otherwise sit empty. That's the whole secret. It's at hi2b dot com, the link is in my bio. Use it before they figure out how underpriced this is." + }, + { + "id": "v05-friend-texted", + "angle": "Word-of-mouth / story hook", + "hook_text": "my friend texted me this and I ignored it", + "tts": "My friend texted me this link three weeks ago and I completely ignored it. Big mistake. It's a Mexico vacation certificate from a site called hi2b. Five days, four nights, all-inclusive, at a real Caribbean resort. Two adults plus the kids, and the kids stay free. She finally called me and said just book it, so I did. We leave next month and the whole thing cost us less than our last grocery run felt like. Don't be me and wait. Go to hi2b dot com, the link is in my bio, and just do it now." + }, + { + "id": "v06-stop-scrolling", + "angle": "Direct pattern-interrupt hook", + "hook_text": "stop scrolling if you want a cheap family trip", + "tts": "Stop scrolling for one second if you have ever wanted to take your family somewhere warm. There's a travel certificate at hi2b that gets you five days and four nights in Mexico, all-inclusive, at a real beachfront resort. Two adults, and your kids under twelve come free. It costs a fraction of what you think, because the resort just wants the rooms filled. We did it, it was real, it was incredible. The link is in my bio, hi2b dot com. They release these in small batches, so if you see it, grab it." + }, + { + "id": "v07-husband-surprise", + "angle": "Emotional / surprise-reveal hook", + "hook_text": "my husband surprised me and I cried", + "tts": "My husband surprised me with a trip last week and I actually cried, and then I found out what he paid and I almost cried again, for a different reason. He used this site called hi2b. It's a vacation certificate, five days and four nights in Mexico, all-inclusive, beachfront resort. Two adults, kids stay free. It cost less than I spend on coffee in a month. I kept waiting for the catch the whole flight down. We just went and had the best week of our year. Go look, it's hi2b dot com, link in my bio." + }, + { + "id": "v08-empty-rooms", + "angle": "Insider-secret / explainer hook", + "hook_text": "here's why resorts do this", + "tts": "Here is something the travel industry doesn't really advertise. Resorts in Mexico make their money on food, drinks, and excursions, not the room. So an empty room is just lost money to them. That's why this site, hi2b, can sell you a five-day, four-night all-inclusive stay at a real beachfront resort for almost nothing. Two adults, kids under twelve free. They're filling rooms that would sit empty anyway. We used one, and it was completely real. It's hi2b dot com, link in my bio. Once you understand why it's cheap, it stops feeling like a scam." + }, + { + "id": "v09-bucket-list", + "angle": "Aspiration / permission hook", + "hook_text": "you can actually afford the trip now", + "tts": "If you've been telling your kids someday for that big beach trip, I need you to hear this. Someday can be this year. There's a vacation certificate at hi2b for five days and four nights in Mexico, all-inclusive, real beachfront resort. Two adults, and the kids stay free. It's priced so low it changed what we thought we could afford. We already went, and it was real. The link is in my bio, it's hi2b dot com. Stop waiting for someday. Book it." + }, + { + "id": "v10-dont-believe", + "angle": "Challenge / dare hook", + "hook_text": "you won't believe me but check anyway", + "tts": "You're not going to believe me, and that's fine, just check it yourself. There is a website, hi2b, selling Mexico vacation certificates. Five days, four nights, all-inclusive, beachfront resort. Two adults, kids under twelve free. For a price so low it sounds made up. I didn't believe it either. I booked it, we flew down, and it was a real resort with real food and a real beach. Best decision we made all year. The link is in my bio, hi2b dot com. Don't take my word for it, just go look right now." + } + ] +} diff --git a/scripts/batch10-tts.ts b/scripts/batch10-tts.ts new file mode 100644 index 0000000..885a705 --- /dev/null +++ b/scripts/batch10-tts.ts @@ -0,0 +1,49 @@ +/** + * Generate Fish Audio TTS for all 10 batch scripts (Sarah voice). + * + * npx tsx scripts/batch10-tts.ts + * + * Reads scripts/batch10-scripts.json, writes public/audio/fish/batch10/.mp3 + */ +import 'dotenv/config' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' +import { pack as msgpackPack } from 'msgpackr' + +const KEY = process.env.FISH_API_KEY +if (!KEY) { console.error('FISH_API_KEY missing'); process.exit(1) } + +const SCRIPTS_PATH = process.argv[2] || 'scripts/batch10-scripts.json' +const cfg = JSON.parse(readFileSync(SCRIPTS_PATH, 'utf8')) +const batchName = SCRIPTS_PATH.replace(/.*\//, '').replace(/-scripts\.json$/, '') +const OUT_DIR = `public/audio/fish/${batchName}` +if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }) + +async function tts(text: string, refId: string): Promise { + const body = { text, format: 'mp3', latency: 'normal', chunk_length: 200, mp3_bitrate: 192, reference_id: refId } + const res = await fetch('https://api.fish.audio/v1/tts', { + method: 'POST', + headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/msgpack', model: 's2-pro' }, + body: new Uint8Array(msgpackPack(body)), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) + return Buffer.from(new Uint8Array(await res.arrayBuffer())) +} + +async function main() { + const refId = cfg.voice_ref + console.log(`Voice: ${cfg.voice_name} (${refId})`) + for (const s of cfg.scripts) { + const dest = `${OUT_DIR}/${s.id}.mp3` + if (existsSync(dest)) { console.log(`✓ ${s.id} (exists)`); continue } + try { + const buf = await tts(s.tts, refId) + writeFileSync(dest, buf) + console.log(`✓ ${s.id} (${(buf.length / 1024).toFixed(0)} kb)`) + } catch (e: any) { + console.error(`✗ ${s.id}: ${e.message}`) + } + } + console.log('done') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch11-render.ts b/scripts/batch11-render.ts new file mode 100644 index 0000000..c9e88e4 --- /dev/null +++ b/scripts/batch11-render.ts @@ -0,0 +1,184 @@ +/** + * Batch 11 InfiniteTalk render — "Day 4 on the trip" experiential ads. + * + * npx tsx scripts/batch11-render.ts inst1 # renders b11-01..05 + * npx tsx scripts/batch11-render.ts inst2 # renders b11-06..10 + * + * Uses 5 rotating keyframes (keyframe-v3-1..5.jpg), 2 videos per keyframe, + * so the batch has 5 distinct poolside-buffet presenters. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, + scripts: ['b11-01-day4-update', 'b11-02-the-food', 'b11-03-skeptic-here', 'b11-04-what-we-paid', 'b11-05-pov-booked'] }, + inst2: { sshPort: 22378, sshHost: 'root@194.14.47.19', tunnelPort: 18891, + scripts: ['b11-06-honest-review', 'b11-07-almost-didnt', 'b11-08-things-nobody-said', 'b11-09-talking-to-you', 'b11-10-your-sign'] }, + // Fallback: render Inst 2's unfinished videos on Inst 1. + 'inst1-rest': { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, + scripts: ['b11-09-talking-to-you', 'b11-10-your-sign'] }, +} + +// 5 keyframes rotate, 2 videos each. +const KEYFRAME_MAP: Record = { + 'b11-01-day4-update': 'keyframe-v3-1.jpg', + 'b11-02-the-food': 'keyframe-v3-1.jpg', + 'b11-03-skeptic-here': 'keyframe-v3-2.jpg', + 'b11-04-what-we-paid': 'keyframe-v3-2.jpg', + 'b11-05-pov-booked': 'keyframe-v3-3.jpg', + 'b11-06-honest-review': 'keyframe-v3-3.jpg', + 'b11-07-almost-didnt': 'keyframe-v3-4.jpg', + 'b11-08-things-nobody-said': 'keyframe-v3-4.jpg', + 'b11-09-talking-to-you': 'keyframe-v3-5.jpg', + 'b11-10-your-sign': 'keyframe-v3-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v3-1.jpg', 'keyframe-v3-2.jpg', 'keyframe-v3-3.jpg', 'keyframe-v3-4.jpg', 'keyframe-v3-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch11-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A young curvy Black woman in her late twenties at the poolside buffet of a luxury all-inclusive Mexican beach resort, holding a plate of food. She talks casually and warmly to the camera, sharing her vacation experience. Natural head movements, relaxed candid expression. Resort buffet, pool, palm trees and ocean softly blurred in the background. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b11', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b11-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch11/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch11')) mkdirSync('public/videos/ugc/batch11', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch11/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch11/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch11/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch11-scripts.json b/scripts/batch11-scripts.json new file mode 100644 index 0000000..376be68 --- /dev/null +++ b/scripts/batch11-scripts.json @@ -0,0 +1,68 @@ +{ + "_note": "Batch 11 — 'Day 4 on the trip' experiential UGC scripts for hi2b.com. Presenter: young curvy Black woman, poolside dining, mid-vacation (keyframe-v3.jpg). These are in-the-moment testimonials (she is AT the resort), unlike batch10's pre-purchase discovery hooks. Voice: Sarah (Fish ref 933563129e564b19a115bedd57b7406a). 'tts' is the continuous spoken text. No script claims 'no timeshare presentation'.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "keyframe": "keyframe-v3.jpg", + "scripts": [ + { + "id": "b11-01-day4-update", + "angle": "Day-count / in-the-moment update", + "hook_text": "day 4 and I'm not emotionally ready to go home", + "tts": "Okay, it is day four here in Mexico and I am not emotionally ready to go home. I'm sitting by the pool, somebody just brought me lunch, and I keep doing the math on what this cost. We booked it through a site called hi2b. Five days, four nights, all-inclusive, this whole beachfront resort. The food has not stopped coming since we landed. If you have been waiting for a sign to book a trip, this is it. The link is in my bio, it's hi2b dot com. Go before you talk yourself out of it." + }, + { + "id": "b11-02-the-food", + "angle": "All-inclusive food reveal", + "hook_text": "let me show you what all-inclusive actually means", + "tts": "Let me show you what all-inclusive actually means, because I did not get it until I was here. It is day four and I have not paid for a single thing since I checked in. Breakfast, lunch by the pool, cocktails, dinner, all of it included. This whole trip, five days and four nights at a real beachfront resort in Mexico, came from a site called hi2b. I keep waiting to be handed a bill and it just never comes. If your idea of a vacation is not watching every dollar, you need this. It's hi2b dot com, link in my bio." + }, + { + "id": "b11-03-skeptic-here", + "angle": "Converted skeptic, from inside the trip", + "hook_text": "I assumed all the cheap vacation deals were fake", + "tts": "I am the person who assumed all those cheap vacation deals were fake. So the fact that I am sitting at this pool, on day four, in Mexico, is genuinely funny to me. My friend sent me this hi2b link, I rolled my eyes, and then I booked it anyway. Five days, four nights, all-inclusive, real beachfront resort. It is exactly what the photos showed, which never happens. I was wrong, and I am so glad I was. The link is in my bio, hi2b dot com. Stop assuming and go look." + }, + { + "id": "b11-04-what-we-paid", + "angle": "Value reveal from poolside", + "hook_text": "come sit by the pool while I tell you something crazy", + "tts": "Come sit by the pool with me while I tell you something a little crazy. It is day four of our Mexico vacation. All-inclusive. Five days, four nights. Beachfront resort. Food and drinks all day long. And what we paid for this whole thing would barely cover a weekend back home. We found it on hi2b. I keep looking around like, this cannot be the price, but it is. If you want the actual link, it is in my bio. It's hi2b dot com. Your turn." + }, + { + "id": "b11-05-pov-booked", + "angle": "Aspirational POV", + "hook_text": "POV: you finally stopped scrolling and booked it", + "tts": "POV: you finally stopped scrolling and booked the trip. This is day four. This is the pool I have been living at. This is the lunch that just appeared in front of me. All-inclusive, five days, four nights, on the Mexican coast, and I booked the whole thing through hi2b. The version of me that almost did not do this would be losing her mind right now. Do not be that version. The link is in my bio, hi2b dot com. Book it and come find your own pool." + }, + { + "id": "b11-06-honest-review", + "angle": "Honest review format", + "hook_text": "here's my honest review, day 4, no filter", + "tts": "Here is my honest review, day four, no filter. The resort is real. The beach is real. The food is genuinely good and it never stops. The pool is the kind you only see in ads. We booked this on a site called hi2b, all-inclusive, five days and four nights, and I went in fully expecting to be disappointed somewhere. I have not been yet. If you have been burned by a travel deal before, I get it, but this one held up. It's hi2b dot com, the link is in my bio." + }, + { + "id": "b11-07-almost-didnt", + "angle": "Regret-aversion", + "hook_text": "I almost didn't book this trip", + "tts": "I almost did not book this trip, and sitting here on day four, that genuinely scares me. I had the hi2b link open for two weeks. I kept finding reasons. Too good to be true, bad timing, all of it. Then I just did it. Five days, four nights, all-inclusive, this beachfront resort in Mexico. And now I am watching the sun go down over a pool instead of overthinking it at home. Whatever you are talking yourself out of, don't. The link is in my bio, hi2b dot com." + }, + { + "id": "b11-08-things-nobody-said", + "angle": "Insider tips / listicle", + "hook_text": "things nobody told me before this trip", + "tts": "Things nobody told me before this trip, day four edition. One, all-inclusive really does mean everything, I have not opened my wallet once. Two, the poolside food is somehow better than the restaurants. Three, four nights is the perfect length, long enough to fully relax, short enough to actually use. We booked this whole thing on hi2b, a real beachfront resort in Mexico. If you are even a little curious, the link is in my bio. It's hi2b dot com." + }, + { + "id": "b11-09-talking-to-you", + "angle": "Direct friend-to-friend", + "hook_text": "I need to talk to you, the one who never books", + "tts": "I need to talk to you for a second, the one who keeps saving travel videos and never booking. It is day four for me. I am at the pool, in Mexico, at an all-inclusive resort, and a week ago I was exactly you. The only thing I did differently was actually click the link. It is a site called hi2b. Five days, four nights, all of it included. That is the whole story. The link is in my bio, hi2b dot com. Your trip is one decision away." + }, + { + "id": "b11-10-your-sign", + "angle": "Permission / hard CTA", + "hook_text": "if you've been waiting for a sign, this is it", + "tts": "If you have been waiting for a sign, congratulations, this is it. Day four in Mexico. All-inclusive resort. This exact pool. I am not special, I did not get some secret deal, I just used a site called hi2b and booked it. Five days, four nights, beachfront, everything included. The only difference between you and this pool is that you have not clicked yet. The link is in my bio, hi2b dot com. Go." + } + ] +} diff --git a/scripts/batch12-render.ts b/scripts/batch12-render.ts new file mode 100644 index 0000000..23d23a6 --- /dev/null +++ b/scripts/batch12-render.ts @@ -0,0 +1,183 @@ +/** + * Batch 11 InfiniteTalk render — "Day 4 on the trip" experiential ads. + * + * npx tsx scripts/batch12-render.ts inst1 # renders b11-01..05 + * npx tsx scripts/batch12-render.ts inst2 # renders b11-06..10 + * + * Uses 5 rotating keyframes (keyframe-v4-1..5.jpg), 2 videos per keyframe, + * so the batch has 5 distinct poolside-buffet presenters. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +// Inst 2 is down — batch 12 renders all 10 sequentially on Inst 1. +const ALL_B12 = [ + 'b12-01-day2-food', 'b12-02-buffet-tour', 'b12-03-skeptic-food', 'b12-04-buffet-math', 'b12-05-pov-breakfast', + 'b12-06-honest-review', 'b12-07-almost-didnt', 'b12-08-what-i-wish', 'b12-09-talking-to-you', 'b12-10-your-sign', +] +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, scripts: ALL_B12 }, +} + +// 5 keyframes rotate, 2 videos each. +const KEYFRAME_MAP: Record = { + 'b12-01-day2-food': 'keyframe-v4-1.jpg', + 'b12-02-buffet-tour': 'keyframe-v4-1.jpg', + 'b12-03-skeptic-food': 'keyframe-v4-2.jpg', + 'b12-04-buffet-math': 'keyframe-v4-2.jpg', + 'b12-05-pov-breakfast': 'keyframe-v4-3.jpg', + 'b12-06-honest-review': 'keyframe-v4-3.jpg', + 'b12-07-almost-didnt': 'keyframe-v4-4.jpg', + 'b12-08-what-i-wish': 'keyframe-v4-4.jpg', + 'b12-09-talking-to-you': 'keyframe-v4-5.jpg', + 'b12-10-your-sign': 'keyframe-v4-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v4-1.jpg', 'keyframe-v4-2.jpg', 'keyframe-v4-3.jpg', 'keyframe-v4-4.jpg', 'keyframe-v4-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch12-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A young curvy Black woman in her late twenties at the poolside buffet of a luxury all-inclusive Mexican beach resort, holding a plate of food. She talks casually and warmly to the camera, sharing her vacation experience. Natural head movements, relaxed candid expression. Resort buffet, pool, palm trees and ocean softly blurred in the background. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b12', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b11-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch12/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch12')) mkdirSync('public/videos/ugc/batch12', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch12/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch12/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch12/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch12-scripts.json b/scripts/batch12-scripts.json new file mode 100644 index 0000000..e09fbfb --- /dev/null +++ b/scripts/batch12-scripts.json @@ -0,0 +1,67 @@ +{ + "_note": "Batch 12 — 'Day 2 at the buffet' experiential UGC scripts for hi2b.com. Presenter: young Filipino woman eating at the resort buffet (keyframe-v4-1..5.jpg). Food/buffet-forward, day-2 framing. Voice: Sarah (Fish ref 933563129e564b19a115bedd57b7406a). 'tts' is the continuous spoken text. No script claims 'no timeshare presentation'.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { + "id": "b12-01-day2-food", + "angle": "Day-2 food shock", + "hook_text": "day 2 and I've already eaten my money's worth", + "tts": "It is day two here in Mexico and I have genuinely already eaten my money's worth at this buffet. Look at this plate. This is all-inclusive, which I did not fully understand until I got here. Breakfast, lunch, dinner, snacks by the pool, all of it, included. We booked this whole trip on a site called hi2b. Five days, four nights, a real beachfront resort. And the food just does not stop. If you love to eat on vacation, this is the one. The link is in my bio, it's hi2b dot com." + }, + { + "id": "b12-02-buffet-tour", + "angle": "Buffet tour", + "hook_text": "let me give you a tour of this buffet", + "tts": "Let me give you a tour of this buffet, because it is day two and I am still not over it. There is a grill station. There is a whole fruit section. There is a noodle bar. Pancakes in the morning, fresh fish at lunch. And every single bit of it is included, because this is an all-inclusive resort we booked through hi2b. Five days, four nights, on the Mexican coast. I have not paid for one meal since I landed. The link is in my bio, hi2b dot com. Come eat." + }, + { + "id": "b12-03-skeptic-food", + "angle": "Skeptic on the food", + "hook_text": "I assumed cheap all-inclusive food would be sad", + "tts": "I will be honest, I assumed the food at a cheaper all-inclusive would be sad. It is day two, and I owe somebody an apology. This buffet is incredible. Fresh, hot, endless, and it changes every single day. We booked this trip on hi2b, five days and four nights at a real beachfront resort in Mexico. I came in expecting mediocre and I am eating like a queen. If the food was your worry, let it go. It's hi2b dot com, the link is in my bio." + }, + { + "id": "b12-04-buffet-math", + "angle": "Value reveal from the buffet", + "hook_text": "stand at this buffet while I tell you the crazy part", + "tts": "Come stand at this buffet with me while I tell you the crazy part. It is day two. Everything you see, all this food, all the drinks, the room, the beach, it was all one price. We booked it on a site called hi2b. Five days, four nights. And what we paid would not even cover a month of groceries back home. I keep going back for another plate just because I can. The link is in my bio, it's hi2b dot com. Your turn to eat." + }, + { + "id": "b12-05-pov-breakfast", + "angle": "POV breakfast buffet", + "hook_text": "POV: day 2 and this is your breakfast", + "tts": "POV: it is day two of your Mexico trip and this is your breakfast. Eggs, pancakes, fresh fruit, all of it, made for you, included. I booked this whole thing through hi2b. Five days, four nights, all-inclusive, a real beachfront resort. The hardest decision I have made all morning is what to put on my plate. If this is the kind of morning you want, you can have it. The link is in my bio, hi2b dot com. Go book it." + }, + { + "id": "b12-06-honest-review", + "angle": "Honest day-2 review", + "hook_text": "honest review, day 2, standing at the buffet", + "tts": "Honest review, day two, standing right at the buffet. The food is genuinely good, not just good-for-the-price good, actually good. There is way more variety than I expected. The resort is real, the beach is real, the staff is lovely. We booked this on hi2b, all-inclusive, five days, four nights. I went in cautious and so far it has held up completely. If you have been burned by a travel deal before, I get it, but this one is real. It's hi2b dot com, link in my bio." + }, + { + "id": "b12-07-almost-didnt", + "angle": "Regret-aversion", + "hook_text": "I almost didn't book this trip", + "tts": "I almost did not book this trip, and on day two, eating this, that genuinely upsets me. I had the hi2b link sitting open for weeks. Too good to be true, I kept telling myself. Then I just did it. Five days, four nights, all-inclusive, this whole beachfront resort in Mexico. And now I am at a buffet by the ocean instead of overthinking it at home. Whatever you are talking yourself out of, do not. The link is in my bio, hi2b dot com." + }, + { + "id": "b12-08-what-i-wish", + "angle": "What I wish I knew", + "hook_text": "things I wish I knew before this trip", + "tts": "Things I wish I knew before this trip, day two, straight from the buffet. One, all-inclusive truly means everything, I have not touched my wallet once. Two, come hungry, the food genuinely never stops. Three, do not rush it, four nights goes fast. We found this whole trip on hi2b, a real beachfront resort in Mexico. If you are even a little curious, the link is in my bio. It's hi2b dot com." + }, + { + "id": "b12-09-talking-to-you", + "angle": "Direct friend-to-friend", + "hook_text": "I need to talk to the one who never books", + "tts": "I need to talk to you for a second, the one who keeps saving travel videos and never booking. It is day two for me. I am at a buffet, in Mexico, at an all-inclusive resort, and last week I was exactly you. The only thing I did differently was actually click the link. It is a site called hi2b. Five days, four nights, every meal included. That is the whole story. The link is in my bio, hi2b dot com. Your trip is one decision away." + }, + { + "id": "b12-10-your-sign", + "angle": "Permission / hard CTA", + "hook_text": "if you needed a sign, this is it", + "tts": "If you needed a sign, this is it, and it is holding a plate of buffet food. Day two in Mexico. All-inclusive resort. I am not special, I did not get a secret deal, I just used a site called hi2b and booked it. Five days, four nights, beachfront, every meal included. The only difference between you and this buffet is that you have not clicked yet. The link is in my bio, hi2b dot com. Go." + } + ] +} diff --git a/scripts/batch13-multitalk-render.ts b/scripts/batch13-multitalk-render.ts new file mode 100644 index 0000000..7e52978 --- /dev/null +++ b/scripts/batch13-multitalk-render.ts @@ -0,0 +1,160 @@ +/** + * Batch 13 re-render — couples, MultiTalk multi-speaker (Instance 2). + * + * npx tsx scripts/batch13-multitalk-render.ts + * + * Each video: woman masked to the VO, man masked to a silent track, so only + * the speaker is lip-synced. 5 keyframes × 2 scripts. Output overwrites + * public/videos/ugc/batch13/.mp4. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` + +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +// script -> keyframe stem (5 keyframes, 2 scripts each) +const KF: Record = { + 'b13-01-anniversary': 'v5-1', 'b13-02-best-decision': 'v5-1', + 'b13-03-skeptic': 'v5-2', 'b13-04-math': 'v5-2', + 'b13-05-pov-couple': 'v5-3', 'b13-06-honest-review': 'v5-3', + 'b13-07-almost-didnt': 'v5-4', 'b13-08-date-night': 'v5-4', + 'b13-09-other-couples': 'v5-5', 'b13-10-your-sign': 'v5-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A happy couple standing close together on a beach at a luxury Mexican beach resort, each holding a tropical drink. The woman in the foreground talks warmly to the camera while her partner stands beside her smiling, listening. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and white sand softly blurred behind them. Warm golden sunlight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(frame: string, maskW: string, maskM: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: frame } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: maskW } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: maskM } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 2, mask_1: ['10', 0], mask_2: ['12', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_mt', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch13/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(`${stem}-frame.jpg`, `${stem}-mask-w.png`, `${stem}-mask-m.png`, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b13mt-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 1200; i++) { // 100 min ceiling + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch13/${id}.mp4`, buf) + console.log(` ✓ saved batch13/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch13')) mkdirSync('public/videos/ugc/batch13', { recursive: true }) + + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v5-*-frame.jpg public/audio/fish/couple-masks/v5-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch13/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + + for (const id of SCRIPTS) { + await renderOne(id) + } + console.log('\nBATCH13 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch13-render.ts b/scripts/batch13-render.ts new file mode 100644 index 0000000..789a470 --- /dev/null +++ b/scripts/batch13-render.ts @@ -0,0 +1,183 @@ +/** + * Batch 11 InfiniteTalk render — "Day 4 on the trip" experiential ads. + * + * npx tsx scripts/batch13-render.ts inst1 # renders b11-01..05 + * npx tsx scripts/batch13-render.ts inst2 # renders b11-06..10 + * + * Uses 5 rotating keyframes (keyframe-v5-1..5.jpg), 2 videos per keyframe, + * so the batch has 5 distinct poolside-buffet presenters. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +// Inst 2 is down — batch 12 renders all 10 sequentially on Inst 1. +const ALL_B13 = [ + 'b13-01-anniversary', 'b13-02-best-decision', 'b13-03-skeptic', 'b13-04-math', 'b13-05-pov-couple', + 'b13-06-honest-review', 'b13-07-almost-didnt', 'b13-08-date-night', 'b13-09-other-couples', 'b13-10-your-sign', +] +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, scripts: ALL_B13 }, +} + +// 5 keyframes rotate, 2 videos each. +const KEYFRAME_MAP: Record = { + 'b13-01-anniversary': 'keyframe-v5-1.jpg', + 'b13-02-best-decision': 'keyframe-v5-1.jpg', + 'b13-03-skeptic': 'keyframe-v5-2.jpg', + 'b13-04-math': 'keyframe-v5-2.jpg', + 'b13-05-pov-couple': 'keyframe-v5-3.jpg', + 'b13-06-honest-review': 'keyframe-v5-3.jpg', + 'b13-07-almost-didnt': 'keyframe-v5-4.jpg', + 'b13-08-date-night': 'keyframe-v5-4.jpg', + 'b13-09-other-couples': 'keyframe-v5-5.jpg', + 'b13-10-your-sign': 'keyframe-v5-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v5-1.jpg', 'keyframe-v5-2.jpg', 'keyframe-v5-3.jpg', 'keyframe-v5-4.jpg', 'keyframe-v5-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch13-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A happy couple standing close together on a beach at a luxury Mexican beach resort, each holding a tropical drink. The woman in the foreground talks warmly and casually to the camera, sharing their vacation; her partner stands beside her smiling. Natural head movements, relaxed candid expressions. Turquoise ocean, gentle waves, palm trees and white sand softly blurred in the background. Warm golden sunlight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b13', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b11-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch13/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch13')) mkdirSync('public/videos/ugc/batch13', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch13/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch13/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch13/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch13-scripts.json b/scripts/batch13-scripts.json new file mode 100644 index 0000000..f04bad5 --- /dev/null +++ b/scripts/batch13-scripts.json @@ -0,0 +1,67 @@ +{ + "_note": "Batch 13 — 'Couple on the beach with drinks' UGC scripts for hi2b.com. Presenter: a couple, woman foreground speaker (keyframe-v5-1..5.jpg). 'We/us' couple framing. Voice: Sarah (Fish ref 933563129e564b19a115bedd57b7406a). 'tts' is the continuous spoken text. No script claims 'no timeshare presentation'.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { + "id": "b13-01-anniversary", + "angle": "Anniversary getaway", + "hook_text": "we booked a whole trip for our anniversary", + "tts": "We just booked a whole trip to Mexico to celebrate our anniversary, and I still cannot believe what it cost. Five days, four nights, all-inclusive, on a real beachfront resort. Drinks like this one, included. Dinners on the beach, included. We found it on a site called hi2b. We had been wanting a real getaway for years and kept putting it off because of the price. Turns out we did not have to. The link is in my bio, it's hi2b dot com. Book the trip with your person." + }, + { + "id": "b13-02-best-decision", + "angle": "Best decision as a couple", + "hook_text": "best decision we've made as a couple", + "tts": "Booking this trip is the best decision we have made as a couple in a long time. We are standing on a beach in Mexico with drinks in our hands, and a week ago we were arguing about whether we could even afford a vacation. We used a site called hi2b. Five days, four nights, all-inclusive, beachfront resort. It cost a fraction of what we expected. Stop putting off the trip. The link is in my bio, hi2b dot com." + }, + { + "id": "b13-03-skeptic", + "angle": "Skeptic — partner found it", + "hook_text": "my husband found this, I said it was a scam", + "tts": "My husband found this and I told him it was definitely a scam. I am now standing on a beach in Mexico holding a cocktail, so I owe him an apology. It is a site called hi2b. Five days, four nights, all-inclusive, a real beachfront resort. He booked it, I doubted it the entire flight down, and it is exactly what they promised. If your partner sent you something like this, maybe listen to them. The link is in my bio, hi2b dot com." + }, + { + "id": "b13-04-math", + "angle": "Value reveal for two", + "hook_text": "what we actually paid, for both of us", + "tts": "Let me tell you what we actually paid for this, because we are still laughing about it. Five days, four nights in Mexico. All-inclusive. This beach, this resort, these drinks, all of it. We booked it on hi2b. And the total for the two of us was less than one nice dinner date back home. We keep saying it out loud because it does not sound real. The link is in my bio, it's hi2b dot com. Go plan yours." + }, + { + "id": "b13-05-pov-couple", + "angle": "Couple POV", + "hook_text": "POV: you and your person finally booked it", + "tts": "POV: you and your person finally booked the trip. This is the beach. These are the drinks. This is day three and neither of us wants to leave. We booked the whole thing through hi2b. Five days, four nights, all-inclusive, beachfront resort. The hardest thing we have done all day is decide which cocktail to order next. If you want this with someone, you can have it. The link is in my bio, hi2b dot com." + }, + { + "id": "b13-06-honest-review", + "angle": "Honest couple review", + "hook_text": "honest review of our couple's trip", + "tts": "Honest review of our couple's trip, drink in hand. The resort is real. The beach is exactly like the photos. The food and the drinks genuinely never stop. We booked this on hi2b, all-inclusive, five days, four nights. We came in a little nervous because the price seemed too low, and it has completely held up. If you have wanted to take your partner somewhere, this is a real option. It's hi2b dot com, link in my bio." + }, + { + "id": "b13-07-almost-didnt", + "angle": "Regret-aversion", + "hook_text": "we almost didn't book this trip", + "tts": "We almost did not book this trip, and standing here, that is hard to admit. We had the hi2b link saved for months. Wrong time, too expensive, maybe later. Then we just did it. Five days, four nights, all-inclusive, this beach in Mexico. And now we are watching the sunset with drinks instead of finding reasons to wait. Do not do what we almost did. The link is in my bio, hi2b dot com." + }, + { + "id": "b13-08-date-night", + "angle": "Romance / reconnect", + "hook_text": "when did you two last actually get away?", + "tts": "When was the last time you and your partner actually got away, just the two of you? For us it had been way too long, so we fixed it. We booked five days and four nights in Mexico, all-inclusive, on a real beachfront resort, through a site called hi2b. Sunset, drinks, no schedule, no stress. It cost so much less than we expected. You and your person deserve this. The link is in my bio, hi2b dot com." + }, + { + "id": "b13-09-other-couples", + "angle": "Speaking to other couples", + "hook_text": "for the couples who say 'someday'", + "tts": "This one is for the couples who keep saying we will travel someday. We were you. Someday kept not coming. Then we found hi2b and just booked it. Five days, four nights, all-inclusive, a real beachfront resort in Mexico. We are standing on that beach right now with drinks in our hands. Someday can be a date on the calendar. The link is in my bio, hi2b dot com. Book it together." + }, + { + "id": "b13-10-your-sign", + "angle": "Permission / hard CTA", + "hook_text": "if you needed a sign to plan the trip", + "tts": "If you and your partner needed a sign to finally plan the trip, here it is, on a beach in Mexico with two drinks. We are not special. We did not get a secret deal. We used a site called hi2b and booked it. Five days, four nights, all-inclusive, beachfront. The only difference between you and this beach is one decision. The link is in my bio, hi2b dot com. Go." + } + ] +} diff --git a/scripts/batch14-render.ts b/scripts/batch14-render.ts new file mode 100644 index 0000000..eea065d --- /dev/null +++ b/scripts/batch14-render.ts @@ -0,0 +1,183 @@ +/** + * Batch 11 InfiniteTalk render — "Day 4 on the trip" experiential ads. + * + * npx tsx scripts/batch14-render.ts inst1 # renders b11-01..05 + * npx tsx scripts/batch14-render.ts inst2 # renders b11-06..10 + * + * Uses 5 rotating keyframes (keyframe-v7-1..5.jpg), 2 videos per keyframe, + * so the batch has 5 distinct poolside-buffet presenters. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +// Inst 2 is down — batch 12 renders all 10 sequentially on Inst 1. +const ALL_B14 = [ + 'b14-01-arrival', 'b14-02-tour', 'b14-03-pool-bar', 'b14-04-drinks', 'b14-05-room', + 'b14-06-view', 'b14-07-food', 'b14-08-dinner', 'b14-09-pool', 'b14-10-cta', +] +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, scripts: ALL_B14 }, +} + +// 5 keyframes rotate, 2 videos each. +const KEYFRAME_MAP: Record = { + 'b14-01-arrival': 'keyframe-v7-1.jpg', + 'b14-02-tour': 'keyframe-v7-1.jpg', + 'b14-03-pool-bar': 'keyframe-v7-2.jpg', + 'b14-04-drinks': 'keyframe-v7-2.jpg', + 'b14-05-room': 'keyframe-v7-3.jpg', + 'b14-06-view': 'keyframe-v7-3.jpg', + 'b14-07-food': 'keyframe-v7-4.jpg', + 'b14-08-dinner': 'keyframe-v7-4.jpg', + 'b14-09-pool': 'keyframe-v7-5.jpg', + 'b14-10-cta': 'keyframe-v7-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v7-1.jpg', 'keyframe-v7-2.jpg', 'keyframe-v7-3.jpg', 'keyframe-v7-4.jpg', 'keyframe-v7-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch14-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her late twenties at a luxury all-inclusive Mexican beach resort, taking a casual selfie video and talking warmly to the camera about her vacation. Natural head movements, relaxed candid expression. Resort interior or pool, palm trees and ocean softly blurred in the background. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b14', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b11-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch14/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch14')) mkdirSync('public/videos/ugc/batch14', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch14/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch14/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch14/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch14-scripts.json b/scripts/batch14-scripts.json new file mode 100644 index 0000000..5a1b717 --- /dev/null +++ b/scripts/batch14-scripts.json @@ -0,0 +1,67 @@ +{ + "_note": "Batch 14 — 'Touring the all-inclusive resort' UGC scripts for hi2b.com. Single presenter at 5 resort locations (keyframe-v7-1..5: lobby, swim-up bar, ocean-view suite, beachfront restaurant, infinity pool). Voice: Sarah (Fish ref 933563129e564b19a115bedd57b7406a). 'tts' is the continuous spoken text. No script claims 'no timeshare presentation'.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { + "id": "b14-01-arrival", + "angle": "Arrival / lobby", + "hook_text": "we just walked in, you have to see this lobby", + "tts": "We just walked into our resort in Mexico and I have to show you this lobby. Open-air, palm trees growing right inside, and somebody just handed me a welcome cocktail. This is an all-inclusive trip we booked on a site called hi2b. Five days, four nights, and from the second you arrive everything is taken care of. I have not reached for my wallet once. If you have been waiting to do a trip like this, the link is in my bio. It's hi2b dot com." + }, + { + "id": "b14-02-tour", + "angle": "Tour intro / lobby", + "hook_text": "come with me, let me show you around", + "tts": "Come with me, let me show you around the all-inclusive resort we booked. This is the lobby, and honestly it only gets better from here. We found this whole trip on a site called hi2b. Five days, four nights in Mexico, every meal and every drink included, a real beachfront resort. I am going to take you through all of it. But first, the link is in my bio if you want your own. It's hi2b dot com." + }, + { + "id": "b14-03-pool-bar", + "angle": "Swim-up bar", + "hook_text": "this is the swim-up bar, I'm not leaving", + "tts": "This is the swim-up bar, and I do not think I am leaving this pool today. You literally swim up, order a drink, and it is included, because this is an all-inclusive resort. We booked the whole trip on hi2b. Five days, four nights in Mexico. Cocktails in the pool, all day, no tab, no thinking about it. If this looks like your kind of vacation, the link is in my bio, hi2b dot com." + }, + { + "id": "b14-04-drinks", + "angle": "Drinks / pool", + "hook_text": "I've lost count of the drinks, that's the point", + "tts": "Day two at the swim-up bar and I have completely lost count of how many of these I have had, which is kind of the point, because every drink here is included. All-inclusive, five days, four nights, a real beachfront resort in Mexico. We booked it on a site called hi2b for way less than you would think. The pool, the bar, the sun, all of it, handled. The link is in my bio, hi2b dot com." + }, + { + "id": "b14-05-room", + "angle": "Suite reveal", + "hook_text": "you have to see our room", + "tts": "Okay, you have to see our room. Ocean view, a huge bed, and a balcony right over the water. This is the suite that came with our all-inclusive package. We booked the whole trip through hi2b. Five days, four nights in Mexico. I keep opening the balcony door just to look at it. For what this cost, I genuinely cannot believe this is the room. The link is in my bio, hi2b dot com." + }, + { + "id": "b14-06-view", + "angle": "The view", + "hook_text": "I woke up to this", + "tts": "I woke up to this. The ocean, right outside our room, at an all-inclusive resort in Mexico. This is the view we get for five days and four nights, and we booked the whole thing on a site called hi2b. I was honestly bracing for a tiny room with a parking-lot view. Instead, this. The link is in my bio if you want to wake up to it too. It's hi2b dot com." + }, + { + "id": "b14-07-food", + "angle": "The food", + "hook_text": "the food here has been unreal", + "tts": "Let me show you dinner, because the food here has been unreal. This is one of the restaurants at our all-inclusive resort, right on the beach, and a meal like this is just included. We booked the trip on hi2b. Five days, four nights in Mexico, every restaurant, every meal, covered. I have not seen a single bill since we got here. The link is in my bio, hi2b dot com." + }, + { + "id": "b14-08-dinner", + "angle": "Beach dinner", + "hook_text": "dinner on the beach, didn't pay a cent", + "tts": "Dinner on the beach, candles, the ocean right there, and I did not pay a cent for any of it. This is what all-inclusive actually means, and I did not really get it until I was here. We booked five days and four nights in Mexico through hi2b, a real beachfront resort. Every dinner like this, included. The link is in my bio, hi2b dot com. Go eat like this." + }, + { + "id": "b14-09-pool", + "angle": "Infinity pool", + "hook_text": "this infinity pool falls into the ocean", + "tts": "This is the infinity pool, where the water just falls off into the ocean, and this is where you will find me for the rest of this trip. It came with our all-inclusive package, five days, four nights in Mexico, booked on a site called hi2b. Pools like this, the restaurants, the beach, the room, all one price. The link is in my bio, hi2b dot com." + }, + { + "id": "b14-10-cta", + "angle": "Full recap / hard CTA", + "hook_text": "that's the whole resort, all included", + "tts": "So that is the whole resort. The lobby, the pools, the food, the room, the beach, and every single bit of it was included in one all-inclusive package. We booked it on hi2b. Five days, four nights in Mexico, for a price that genuinely does not make sense until you are standing here. If you want this, stop waiting. The link is in my bio, it's hi2b dot com. Go." + } + ] +} diff --git a/scripts/batch15-multitalk-render.ts b/scripts/batch15-multitalk-render.ts new file mode 100644 index 0000000..df4b2ae --- /dev/null +++ b/scripts/batch15-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 15 — older Black couple, MAN speaking. MultiTalk multi-speaker (Instance 2). + * + * npx tsx scripts/batch15-multitalk-render.ts + * + * The man is the foreground selfie-taker → segment_couple.py labels him + * '-mask-w' (foreground). audio_1 (his VO) → mask_1 (-mask-w = man), + * audio_2 (silence) → mask_2 (-mask-w's partner = woman), mask_3 = background. + * Output: public/videos/ugc/batch15/.mp4 (clean VO; kids ambiance is + * mixed in afterwards by mix-ambiance.sh). + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +// script -> keyframe stem (5 keyframes, 2 scripts each) +const KF: Record = { + 'b15-01-finally': 'v9-1', 'b15-02-someday': 'v9-1', + 'b15-03-value': 'v9-2', 'b15-04-retirement': 'v9-2', + 'b15-05-skeptic': 'v9-3', 'b15-06-look-where': 'v9-3', + 'b15-07-other-couples': 'v9-4', 'b15-08-my-wife': 'v9-4', + 'b15-09-honest': 'v9-5', 'b15-10-cta': 'v9-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'An older Black couple standing together in shallow ocean water at a Mexican beach resort, each holding a tropical drink. The man talks warmly to the camera, sharing their vacation, while his wife stands beside him smiling. Natural relaxed expressions, gentle head movements, small fish in the clear water, kids playing in the background. Bright tropical sunlight, palm trees and ocean softly blurred behind them.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + // masks: 9/10 = man (-mask-w foreground selfie-taker), 11/12 = woman, 25/26 = background + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b15', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch15/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b15-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch15/${id}.mp4`, buf) + console.log(` ✓ saved batch15/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch15')) mkdirSync('public/videos/ugc/batch15', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v9-*-frame.jpg public/audio/fish/couple-masks/v9-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch15/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) await renderOne(id) + console.log('\nBATCH15 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch15-scripts.json b/scripts/batch15-scripts.json new file mode 100644 index 0000000..2455314 --- /dev/null +++ b/scripts/batch15-scripts.json @@ -0,0 +1,67 @@ +{ + "_note": "Batch 15 \u2014 older Black couple, the MAN speaking. Standing in shallow ocean water (fish around them), drinks in hand, kids playing in background. keyframe-v8-1..5. Voice: TBD (mature male, see male-voice-audition.html). 'tts' = continuous spoken text. MultiTalk multi-speaker: man masked to VO, woman masked to silence. No script claims 'no timeshare presentation'.", + "voice_ref": "a60cb2ef5d15412c8c4e63545640eadb", + "voice_name": "Deep Male (old, calm)", + "scripts": [ + { + "id": "b15-01-finally", + "angle": "Finally took the trip", + "hook_text": "after 30 years we finally did it", + "tts": "After thirty years of marriage, my wife and I finally took the trip we always promised ourselves. We are standing in the ocean in Mexico right now, little fish swimming around our feet, a drink in each hand. We booked this through a site called hi2b. Five days, four nights, all-inclusive. We waited a long time for this. Do not wait as long as we did. The link is in my bio, it's hi2b dot com." + }, + { + "id": "b15-02-someday", + "angle": "Someday is today", + "hook_text": "for years I said someday", + "tts": "For years I kept telling my wife, someday we will take that big trip. Someday. Well, someday is today, and today we are standing in the warm water in Mexico. We booked it on hi2b. Five days, four nights, all-inclusive, a real beachfront resort. It cost so much less than we always assumed. If you have a someday trip, stop waiting on it. The link is in my bio, hi2b dot com." + }, + { + "id": "b15-03-value", + "angle": "Value reveal", + "hook_text": "let me tell you what this cost", + "tts": "Let me tell you what this trip cost, because at my age I do not impress easily. Five days, four nights in Mexico. All-inclusive. The resort, the food, the drinks, all of it. We booked it on a site called hi2b, and the price for the two of us was less than we used to spend on a weekend away. We are standing in the ocean because of it. The link is in my bio, hi2b dot com." + }, + { + "id": "b15-04-retirement", + "angle": "Retirement reward", + "hook_text": "best thing we've done since I retired", + "tts": "Best thing we have done since I retired. My wife and I are in Mexico, in the ocean, fish swimming right past us, and we are not thinking about a single bill. It is all-inclusive. We found it on hi2b. Five days, four nights at a real beachfront resort. This is what we worked all those years for. The link is in my bio, hi2b dot com. Go enjoy yours." + }, + { + "id": "b15-05-skeptic", + "angle": "Older skeptic convinced", + "hook_text": "I don't trust deals that sound too good", + "tts": "I have been around long enough to not trust a deal that sounds too good. So when my wife showed me this, I read every word of it. It is real. A site called hi2b, five days and four nights in Mexico, all-inclusive, a real beachfront resort. We are standing in that ocean right now. I was wrong to doubt it. The link is in my bio, hi2b dot com." + }, + { + "id": "b15-06-look-where", + "angle": "In-the-moment", + "hook_text": "look where we are", + "tts": "Look where we are. My wife and I are standing in the Caribbean, in Mexico, little fish swimming around our legs, a drink in each hand. A week ago we were home wondering if we could afford a trip like this. We booked it on hi2b. All-inclusive, five days, four nights. Turns out we could. The link is in my bio, hi2b dot com." + }, + { + "id": "b15-07-other-couples", + "angle": "To older couples", + "hook_text": "for the couples my age still waiting", + "tts": "This is for the couples my age who keep putting the trip off. I am in my sixties, standing in the ocean in Mexico with my wife, and I promise you, you can still do this. We booked it on a site called hi2b. Five days, four nights, all-inclusive. Do not keep waiting for the perfect time. The link is in my bio, hi2b dot com. Take your person somewhere." + }, + { + "id": "b15-08-my-wife", + "angle": "Honoring his wife", + "hook_text": "my wife deserved this", + "tts": "My wife has done everything for this family for forty years. This trip is the least she deserved. So I booked us five days in Mexico, all-inclusive, on a site called hi2b. Now we are standing in the ocean together, no schedule, no stress, just this. If you have someone who deserves it, the link is in my bio. It's hi2b dot com." + }, + { + "id": "b15-09-honest", + "angle": "Honest, it's real", + "hook_text": "I'm not one to make videos, but", + "tts": "I am not one to make videos, but I had to share this. This trip is real. The resort is real, the beach is real, and the all-inclusive is genuinely all-inclusive. We booked it on hi2b, five days and four nights in Mexico. My wife and I are standing in the ocean right now, and it cost far less than you would think. The link is in my bio, hi2b dot com." + }, + { + "id": "b15-10-cta", + "angle": "Hard CTA / no perfect time", + "hook_text": "there is no perfect time", + "tts": "If you have been waiting for the right time to take that trip, let me save you the years we lost. There is no perfect time. There is just hi2b dot com, five days and four nights in Mexico, all-inclusive, and a decision. My wife and I are standing in the ocean because we finally made it. The link is in my bio. Make yours." + } + ] +} \ No newline at end of file diff --git a/scripts/batch16-multitalk-render.ts b/scripts/batch16-multitalk-render.ts new file mode 100644 index 0000000..2fed3a5 --- /dev/null +++ b/scripts/batch16-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 15 — older Black couple, MAN speaking. MultiTalk multi-speaker (Instance 2). + * + * npx tsx scripts/batch16-multitalk-render.ts + * + * The man is the foreground selfie-taker → segment_couple.py labels him + * '-mask-w' (foreground). audio_1 (his VO) → mask_1 (-mask-w = man), + * audio_2 (silence) → mask_2 (-mask-w's partner = woman), mask_3 = background. + * Output: public/videos/ugc/batch16/.mp4 (clean VO; kids ambiance is + * mixed in afterwards by mix-ambiance.sh). + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +// script -> keyframe stem (5 keyframes, 2 scripts each) +const KF: Record = { + 'b16-01-just-married': 'v10-1', 'b16-02-best-decision': 'v10-1', + 'b16-03-couldnt-afford': 'v10-2', 'b16-04-husband-best': 'v10-2', + 'b16-05-pov-morning': 'v10-3', 'b16-06-skeptic': 'v10-3', + 'b16-07-almost-waited': 'v10-4', 'b16-08-honest-review': 'v10-4', + 'b16-09-to-newlyweds': 'v10-5', 'b16-10-cta': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A happy young newlywed couple in their late twenties on their honeymoon at a luxury Mexican beach resort, each holding a tropical drink. The woman in the foreground talks warmly to the camera, sharing about their honeymoon, while her husband stands beside her smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and beach softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + // masks: 9/10 = man (-mask-w foreground selfie-taker), 11/12 = woman, 25/26 = background + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b16', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch16/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b15-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch16/${id}.mp4`, buf) + console.log(` ✓ saved batch16/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch16')) mkdirSync('public/videos/ugc/batch16', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch16/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) await renderOne(id) + console.log('\nBATCH15 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch16-scripts.json b/scripts/batch16-scripts.json new file mode 100644 index 0000000..578f5c5 --- /dev/null +++ b/scripts/batch16-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 16 — honeymoon couple (woman speaking, husband beside her). 5 keyframes v10-1..5. Sarah voice. MultiTalk multi-speaker: woman masked to VO, husband masked to silence.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b16-01-just-married", "angle": "Just got married", "hook_text": "we just got married and", + "tts": "We just got married and we are spending our honeymoon in Mexico. I always thought a honeymoon meant a credit card I would still be paying off in two years. We found this site called hi2b, booked five days and four nights all-inclusive on a real beachfront resort, and the whole thing cost less than the wedding cake. The link is in my bio, hi2b dot com. Your honeymoon does not have to put you in debt." }, + { "id": "b16-02-best-decision", "angle": "Best decision", "hook_text": "best honeymoon decision", + "tts": "Best decision we made for our honeymoon was not blowing the budget. We are standing on a beach in Mexico, drinks in hand, an all-inclusive resort behind us, and we paid a fraction of what every honeymoon planner told us we had to spend. We used hi2b. Five days, four nights, every meal and drink included. If you are getting married, write down this link before you book anything. It's hi2b dot com, in my bio." }, + { "id": "b16-03-couldnt-afford", "angle": "Couldn't afford a fancy one", "hook_text": "couldn't afford the honeymoon everyone said we had to have", + "tts": "We could not afford the honeymoon everyone said we had to have. So we did this instead. Five days, four nights, all-inclusive Mexico, real beachfront resort, two adults, on a payment plan that costs less than our gym memberships. We booked it on a site called hi2b. We are here right now and it is genuinely better than the trip we were too scared to book. The link is in my bio, hi2b dot com." }, + { "id": "b16-04-husband-best", "angle": "Husband found it", "hook_text": "my husband is officially the best", + "tts": "My husband is officially the best because he is the one who found this. He booked our entire honeymoon, five days in Mexico, all-inclusive, on a site called hi2b. I doubted him the whole flight down. We are now standing on a beach with cocktails in our hands and I owe him an apology. If your partner sent you something like this, listen to them. The link is in my bio, hi2b dot com." }, + { "id": "b16-05-pov-morning", "angle": "POV honeymoon morning", "hook_text": "POV: morning two of your honeymoon", + "tts": "POV: it is morning two of your honeymoon and this is the view. Mexico. Beachfront resort. Breakfast already brought to the room because that is what all-inclusive means. We booked five days and four nights on hi2b for less than we expected to spend on one dinner out. I am never going back to the kind of vacation where I am counting drinks. The link is in my bio, hi2b dot com." }, + { "id": "b16-06-skeptic", "angle": "Skeptic on cheap honeymoon", "hook_text": "I assumed a cheap honeymoon would be sad", + "tts": "I assumed a cheap honeymoon would feel cheap. Day three in Mexico, I owe somebody an apology. The resort is real, the food is incredible, the beach is exactly what you picture. We booked it on a site called hi2b, five days and four nights, all-inclusive. If price is the only thing stopping you from booking a real honeymoon, please go look. The link is in my bio, hi2b dot com." }, + { "id": "b16-07-almost-waited", "angle": "Almost waited a year", "hook_text": "we almost waited a year for our honeymoon", + "tts": "We almost waited a whole year to take our honeymoon because we thought we needed to save more. Standing on this beach in Mexico right now, that decision is genuinely sad. We booked it on hi2b. Five days, four nights, all-inclusive, beachfront. It cost so much less than we thought a honeymoon had to. Do not wait. You and your person deserve this now. The link is in my bio, hi2b dot com." }, + { "id": "b16-08-honest-review", "angle": "Honest honeymoon review", "hook_text": "honest review from our honeymoon", + "tts": "Honest review of our honeymoon trip, drinks in hand. The resort is real. The beach is exactly like the photos. The room is huge, the food never stops, and the staff has been lovely. We booked this on hi2b, all-inclusive, five days, four nights. I went in nervous because of the price, and it has completely held up. If you are planning a honeymoon, this is a real option. It's hi2b dot com, link in my bio." }, + { "id": "b16-09-to-newlyweds", "angle": "To other newlyweds", "hook_text": "for the couples who just got married", + "tts": "This is for the couples who just got married and are looking at honeymoon prices and panicking. We did the same thing. Then we found this. A site called hi2b, five days and four nights in Mexico, all-inclusive, real beachfront resort, for a price that does not require a second loan. We are here right now and we did not have to sacrifice anything. The link is in my bio, hi2b dot com. Start your marriage right." }, + { "id": "b16-10-cta", "angle": "Hard CTA", "hook_text": "your honeymoon doesn't have to wait", + "tts": "Your honeymoon does not have to wait until you can afford the version everyone tells you to book. We are standing on a beach in Mexico because we found a smarter way. Five days, four nights, all-inclusive, on a site called hi2b. The price is not a typo. We booked, we flew, we are here. The link is in my bio. It's hi2b dot com. Go." } + ] +} diff --git a/scripts/batch17-render.ts b/scripts/batch17-render.ts new file mode 100644 index 0000000..7fc4417 --- /dev/null +++ b/scripts/batch17-render.ts @@ -0,0 +1,183 @@ +/** + * Batch 11 InfiniteTalk render — "Day 4 on the trip" experiential ads. + * + * npx tsx scripts/batch17-render.ts inst1 # renders b11-01..05 + * npx tsx scripts/batch17-render.ts inst2 # renders b11-06..10 + * + * Uses 5 rotating keyframes (keyframe-v11-1..5.jpg), 2 videos per keyframe, + * so the batch has 5 distinct poolside-buffet presenters. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +// Inst 2 is down — batch 12 renders all 10 sequentially on Inst 1. +const ALL_B17 = [ + 'b17-01-i-made-this', 'b17-02-since-divorce', 'b17-03-thought-couldnt', 'b17-04-kids-faces', 'b17-05-math', + 'b17-06-skeptic', 'b17-07-before-grown', 'b17-08-honest-review', 'b17-09-to-other-moms', 'b17-10-cta', +] +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, scripts: ALL_B17 }, +} + +// 5 keyframes rotate, 2 videos each. +const KEYFRAME_MAP: Record = { + 'b17-01-i-made-this': 'keyframe-v11-1.jpg', + 'b17-02-since-divorce': 'keyframe-v11-1.jpg', + 'b17-03-thought-couldnt': 'keyframe-v11-2.jpg', + 'b17-04-kids-faces': 'keyframe-v11-2.jpg', + 'b17-05-math': 'keyframe-v11-3.jpg', + 'b17-06-skeptic': 'keyframe-v11-3.jpg', + 'b17-07-before-grown': 'keyframe-v11-4.jpg', + 'b17-08-honest-review': 'keyframe-v11-4.jpg', + 'b17-09-to-other-moms': 'keyframe-v11-5.jpg', + 'b17-10-cta': 'keyframe-v11-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v11-1.jpg', 'keyframe-v11-2.jpg', 'keyframe-v11-3.jpg', 'keyframe-v11-4.jpg', 'keyframe-v11-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch17-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly single mother in her late thirties to early forties at a luxury all-inclusive Mexican beach resort with her two young children, taking a casual selfie video and talking warmly to the camera about the trip. Natural head movements, relaxed candid expression. Kids beside or behind her, playing happily. Resort interior, pool, or beach softly blurred in the background. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b17', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b11-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch17/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch17')) mkdirSync('public/videos/ugc/batch17', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch17/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch17/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch17/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch17-scripts.json b/scripts/batch17-scripts.json new file mode 100644 index 0000000..40a6342 --- /dev/null +++ b/scripts/batch17-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 17 — single mom + 2 kids (mom speaking). 5 keyframes v11-1..5. Sarah voice. Single-speaker InfiniteTalk (mom is the dominant front-facing face; kids in frame but turned/playing).", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b17-01-i-made-this", "angle": "Single mom made it happen", "hook_text": "I'm a single mom and I made this happen", + "tts": "I am a single mom and I made this happen all on my own. We are in Mexico. My kids are in the ocean. I booked the whole thing on a site called hi2b for less than I usually spend on back-to-school. Five days, four nights, all-inclusive. If you are doing this alone like me and you keep thinking your kids will not get a real vacation, please listen. The link is in my bio, hi2b dot com." }, + { "id": "b17-02-since-divorce", "angle": "First trip since divorce", "hook_text": "first vacation since the divorce", + "tts": "This is our first real vacation since the divorce. I have been telling my kids someday for years. Someday is right now. We are in Mexico, at an all-inclusive resort, and the whole trip cost less than I was budgeting for one rough month. We booked it on hi2b. Five days, four nights. The link is in my bio, hi2b dot com. Stop waiting for someday." }, + { "id": "b17-03-thought-couldnt", "angle": "Thought it was out of reach", "hook_text": "I thought single moms couldn't afford this", + "tts": "I genuinely thought a real vacation with my kids was something single moms like me had to skip. I was wrong. We are in Mexico right now, all-inclusive, beachfront resort, and I paid for it on a regular working-mom budget. We booked through hi2b. Five days, four nights. Kids stay free. If you have been telling yourself the same story I told myself, please go look. The link is in my bio, hi2b dot com." }, + { "id": "b17-04-kids-faces", "angle": "Watching the kids' faces", "hook_text": "watching my kids' faces", + "tts": "Watching my kids see the ocean for the first time is something I cannot unsee. They are running, they are laughing, they are not asking for anything because everything is just here. We booked five days and four nights in Mexico on a site called hi2b. All-inclusive. Their meals, my meals, all of it. The price would have shocked me a year ago. The link is in my bio, hi2b dot com." }, + { "id": "b17-05-math", "angle": "Math for one income", "hook_text": "the math for a single-income family", + "tts": "Let me do the math out loud for the other single-income families. Five days, four nights, in Mexico. All-inclusive. Real beachfront resort. Two kids, free. We booked it on a site called hi2b for less than I spend on one month of after-school activities. I keep saying it because I am still not over it. The link is in my bio, hi2b dot com. Take your kids somewhere." }, + { "id": "b17-06-skeptic", "angle": "Mom skeptic", "hook_text": "I don't trust deals online, especially as a mom", + "tts": "As a single mom I do not trust deals online. There is always a catch and I do not have time for the catch. So I read every word of this one. There is no catch. A site called hi2b, five days and four nights in Mexico, all-inclusive, a real beachfront resort. Kids under twelve free. I booked, we flew, we are here. The link is in my bio, hi2b dot com." }, + { "id": "b17-07-before-grown", "angle": "Don't wait until they're grown", "hook_text": "don't wait until they're grown", + "tts": "Do not wait until they are grown. I almost did. My kids are nine and eleven and they will not let me hold their hand in three years. We are at this resort in Mexico because I finally stopped putting it off. We booked it on hi2b for an amount that did not require a second job. Five days, four nights, all-inclusive. The link is in my bio, hi2b dot com. Take them now." }, + { "id": "b17-08-honest-review", "angle": "Honest review", "hook_text": "honest review from a single mom", + "tts": "Honest review from a single mom who was scared to book this. The resort is real. The kids are happy. The food never stops and I have not paid for one extra thing since we landed. We booked through hi2b. Five days, four nights, all-inclusive. If you have been worried it would not be safe or clean or good enough for your kids, it is. The link is in my bio, hi2b dot com." }, + { "id": "b17-09-to-other-moms", "angle": "To other single moms", "hook_text": "for the single moms still waiting", + "tts": "This one is for the other single moms who are tired of giving up the things they wanted to give their kids. I am you. I just stopped waiting. We are in Mexico, at an all-inclusive resort, because I used a site called hi2b and booked five days for a price I could actually afford. The link is in my bio, hi2b dot com. You can do this." }, + { "id": "b17-10-cta", "angle": "Hard CTA", "hook_text": "book the one for them", + "tts": "If you needed a sign, this is it. My kids are in the ocean behind me. We are in Mexico because their single mom stopped waiting for the perfect time and booked it. Five days, four nights, all-inclusive, on a site called hi2b. It is real. It is affordable. They will remember this trip forever. The link is in my bio, hi2b dot com. Book the one for them." } + ] +} diff --git a/scripts/batch18-multitalk-render.ts b/scripts/batch18-multitalk-render.ts new file mode 100644 index 0000000..c73e2a0 --- /dev/null +++ b/scripts/batch18-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 15 — older Black couple, MAN speaking. MultiTalk multi-speaker (Instance 2). + * + * npx tsx scripts/batch18-multitalk-render.ts + * + * The man is the foreground selfie-taker → segment_couple.py labels him + * '-mask-w' (foreground). audio_1 (his VO) → mask_1 (-mask-w = man), + * audio_2 (silence) → mask_2 (-mask-w's partner = woman), mask_3 = background. + * Output: public/videos/ugc/batch18/.mp4 (clean VO; kids ambiance is + * mixed in afterwards by mix-ambiance.sh). + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +// script -> keyframe stem (5 keyframes, 2 scripts each) +const KF: Record = { + 'b18-01-escaped-winter': 'v12-1', 'b18-02-best-retirement': 'v12-1', + 'b18-03-older-skeptic': 'v12-2', 'b18-04-vs-cruises': 'v12-2', + 'b18-05-shoulda-sooner': 'v12-3', 'b18-06-honest-review': 'v12-3', + 'b18-07-other-snowbirds': 'v12-4', 'b18-08-pov-first-morning': 'v12-4', + 'b18-09-forty-years': 'v12-5', 'b18-10-cta': 'v12-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A retired couple in their mid-sixties at a luxury Mexican beach resort, escaping winter, each holding a tropical drink. The woman in the foreground talks warmly to the camera while her husband stands beside her smiling. Natural relaxed expressions, gentle head movements. Sunny resort scene with beach, palm trees, and ocean softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + // masks: 9/10 = man (-mask-w foreground selfie-taker), 11/12 = woman, 25/26 = background + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b18', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch18/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b15-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch18/${id}.mp4`, buf) + console.log(` ✓ saved batch18/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch18')) mkdirSync('public/videos/ugc/batch18', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v12-*-frame.jpg public/audio/fish/couple-masks/v12-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch18/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) await renderOne(id) + console.log('\nBATCH15 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch18-scripts.json b/scripts/batch18-scripts.json new file mode 100644 index 0000000..6133497 --- /dev/null +++ b/scripts/batch18-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 18 — snowbird retirees couple, woman speaking. 5 keyframes v12-1..5. Laura voice (mature warm female). MultiTalk multi-speaker: woman masked to VO, husband masked to silence.", + "voice_ref": "e3cd384158934cc9a01029cd7d278634", + "voice_name": "Laura (mature warm female)", + "scripts": [ + { "id": "b18-01-escaped-winter", "angle": "Escaped winter", "hook_text": "we escaped winter and we're not going back", + "tts": "We escaped the winter and I am not entirely sure we are going back. My husband and I are retired in Minnesota, which means six months of looking at the snow. We booked five days in Mexico on a site called hi2b, all-inclusive, beachfront resort. The whole trip cost less than one heating bill. The link is in my bio, hi2b dot com. If you are tired of being cold, this is your sign." }, + { "id": "b18-02-best-retirement", "angle": "Best retirement decision", "hook_text": "best thing we've done since we retired", + "tts": "Best thing we have done since we retired. Five days in Mexico, all-inclusive, on a real beachfront resort. We booked it on hi2b. The price was almost embarrassing. We have spent more on a weekend at a casino. Now we are standing on this beach and asking each other why we waited so long. The link is in my bio, hi2b dot com." }, + { "id": "b18-03-older-skeptic", "angle": "Older skeptic", "hook_text": "people my age don't trust internet deals", + "tts": "People my age do not trust deals on the internet, and we are right not to. So I read every word of this one before I gave them a dollar. It is real. A site called hi2b, five days in Mexico, all-inclusive, a real beachfront resort. We are here right now. The link is in my bio, hi2b dot com. Read it yourself before you decide." }, + { "id": "b18-04-vs-cruises", "angle": "Better than the cruises we did", "hook_text": "we used to pay thousands for cruises", + "tts": "For years my husband and I paid thousands for cruises that left us tired and seasick. This trip cost a fraction of that and we have not packed and unpacked once. Five days, four nights, in Mexico, all-inclusive, on a real beachfront resort. We booked it on hi2b. The link is in my bio, hi2b dot com. If you are doing cruises out of habit, please consider this instead." }, + { "id": "b18-05-shoulda-sooner", "angle": "Should've done it sooner", "hook_text": "we should have done this years ago", + "tts": "We should have done this years ago. My husband and I have been together forty years, and we have spent most of those years putting trips off for next year. Now next year is here. We are standing in Mexico because we used a site called hi2b. Five days, all-inclusive, real beachfront resort. Do not wait the way we did. The link is in my bio, hi2b dot com." }, + { "id": "b18-06-honest-review", "angle": "Honest review", "hook_text": "honest review from a sixty-something couple", + "tts": "Honest review from a couple in their mid-sixties. The resort is real and clean, the food is excellent, and the staff has been kind from the moment we arrived. We booked through hi2b. All-inclusive, five days, four nights. We came in cautious and have been pleasantly surprised at every turn. The link is in my bio, hi2b dot com." }, + { "id": "b18-07-other-snowbirds", "angle": "To other snowbirds", "hook_text": "for the snowbirds still driving to Florida", + "tts": "This is for the snowbirds still driving down to Florida every winter. Save yourself the gas. We flew to Mexico for less than that drive would cost in tolls and motels. Five days, all-inclusive, on the Caribbean. We booked it on hi2b. The link is in my bio, hi2b dot com. There is a warmer, easier, cheaper option, and I just told you about it." }, + { "id": "b18-08-pov-first-morning", "angle": "POV first morning vs home", "hook_text": "POV: first morning here vs first morning in Minneapolis", + "tts": "POV: this is our first morning here. The view from our balcony is the ocean. The temperature is warm. Breakfast is downstairs and already paid for because that is what all-inclusive means. A week ago we were shoveling snow. We booked five days through hi2b for less than what we used to spend on one weekend in Chicago. The link is in my bio, hi2b dot com." }, + { "id": "b18-09-forty-years", "angle": "After 40 years together", "hook_text": "after forty years he still surprises me", + "tts": "After forty years together my husband still surprises me. He found this little site called hi2b and booked us five days in Mexico without telling me how cheap it was, because he knew I would not believe it. We are at the resort right now. It is real and it is everything he said. The link is in my bio, hi2b dot com." }, + { "id": "b18-10-cta", "angle": "Hard CTA", "hook_text": "your retirement deserves this", + "tts": "Your retirement deserves this. You worked your whole life and you should not be priced out of a real vacation. We are standing on this beach in Mexico because we used a site called hi2b. Five days, four nights, all-inclusive. The price is not a typo. The link is in my bio, hi2b dot com. Treat yourselves. You earned it." } + ] +} diff --git a/scripts/batch19-render.ts b/scripts/batch19-render.ts new file mode 100644 index 0000000..bd89d36 --- /dev/null +++ b/scripts/batch19-render.ts @@ -0,0 +1,183 @@ +/** + * Batch 11 InfiniteTalk render — "Day 4 on the trip" experiential ads. + * + * npx tsx scripts/batch19-render.ts inst1 # renders b11-01..05 + * npx tsx scripts/batch19-render.ts inst2 # renders b11-06..10 + * + * Uses 5 rotating keyframes (keyframe-v13-1..5.jpg), 2 videos per keyframe, + * so the batch has 5 distinct poolside-buffet presenters. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +// Inst 2 is down — batch 12 renders all 10 sequentially on Inst 1. +const ALL_B19 = [ + 'b19-01-which-one', 'b19-02-vibe-split', 'b19-03-same-cert', 'b19-04-why-cancun-first', 'b19-05-why-cabo-next', + 'b19-06-with-kids', 'b19-07-grown-ups', 'b19-08-food-drinks', 'b19-09-pacific-vs-caribbean', 'b19-10-cta', +] +const INSTANCES: Record = { + inst1: { sshPort: 30306, sshHost: 'root@120.238.149.205', tunnelPort: 18890, scripts: ALL_B19 }, +} + +// 5 keyframes rotate, 2 videos each. +const KEYFRAME_MAP: Record = { + 'b19-01-which-one': 'keyframe-v13-1.jpg', + 'b19-02-vibe-split': 'keyframe-v13-1.jpg', + 'b19-03-same-cert': 'keyframe-v13-2.jpg', + 'b19-04-why-cancun-first': 'keyframe-v13-2.jpg', + 'b19-05-why-cabo-next': 'keyframe-v13-3.jpg', + 'b19-06-with-kids': 'keyframe-v13-3.jpg', + 'b19-07-grown-ups': 'keyframe-v13-4.jpg', + 'b19-08-food-drinks': 'keyframe-v13-4.jpg', + 'b19-09-pacific-vs-caribbean': 'keyframe-v13-5.jpg', + 'b19-10-cta': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch19-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera about her trip, comparing destinations. Natural head movements, relaxed candid expression. Resort beach, pool, or destination-specific backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b19', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b11-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch19/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch19')) mkdirSync('public/videos/ugc/batch19', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch19/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch19/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch19/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch19-scripts.json b/scripts/batch19-scripts.json new file mode 100644 index 0000000..2900e70 --- /dev/null +++ b/scripts/batch19-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 19 — Cancun vs Cabo comparison. 5 keyframes v13-1..5 (Cancun, Cabo, Riviera Maya, Puerto Vallarta, generic). Sarah voice. Single-speaker InfiniteTalk.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b19-01-which-one", "angle": "Which is right for you", "hook_text": "Cancun or Cabo — which is right for you", + "tts": "Cancun or Cabo, which one is right for you. I have done both this year on the same hi2b certificate, so I can actually answer this. Cancun is calm Caribbean water, white sand, party energy, family-friendly. Cabo is dramatic Pacific coastline, sea-lion drama at El Arco, quieter for grown-ups. Pick by vibe. The link to the certificate is in my bio, it's hi2b dot com." }, + { "id": "b19-02-vibe-split", "angle": "Vibe split", "hook_text": "Cancun is party + family. Cabo is quiet + dramatic.", + "tts": "Easiest way to pick. Cancun is bright water, soft sand, families everywhere, party energy at night if you want it. Cabo is dramatic cliffs, the famous arch, quieter resorts, more adults than kids. Both come on the same hi2b certificate, same all-inclusive deal. So you are not paying more for the better fit. The link is in my bio, hi2b dot com. Pick the vibe." }, + { "id": "b19-03-same-cert", "angle": "Same certificate, two trips", "hook_text": "you don't have to choose forever", + "tts": "You do not have to choose forever. The hi2b certificate gives you four destinations to pick from when you book — Cancun, Cabo, Riviera Maya, and Puerto Vallarta. We did Cancun on our first one and Cabo the next year. Different feel, same all-inclusive deal. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b19-04-why-cancun-first","angle": "Why I picked Cancun first", "hook_text": "we picked Cancun first, here's why", + "tts": "We picked Cancun for our first trip and I would recommend it for anyone whose first all-inclusive this is. The water is the easiest water I have ever swum in. The resorts are huge, so there is always a pool open. Food is great. Cabo would have been a steeper learning curve. The certificate that covers both is on hi2b. The link is in my bio, hi2b dot com." }, + { "id": "b19-05-why-cabo-next", "angle": "Why I picked Cabo next", "hook_text": "we picked Cabo the second year", + "tts": "We picked Cabo for our second year and we loved it for completely different reasons. The water is colder and rougher than Cancun, but the views are unreal. The arch, the marina, the sunsets are different. It feels more grown-up. Same hi2b certificate covered it. The link is in my bio, hi2b dot com. Try one, then try the other." }, + { "id": "b19-06-with-kids", "angle": "With kids = Cancun", "hook_text": "if you're bringing kids, pick Cancun", + "tts": "If you are bringing kids, pick Cancun. The water is calm and warm, the sand is soft, the kids clubs at the resorts are actually good. Cabo is gorgeous but the Pacific is rougher and not as forgiving for little ones. The certificate that includes both is hi2b. The link is in my bio, hi2b dot com." }, + { "id": "b19-07-grown-ups", "angle": "Adults-only = Cabo", "hook_text": "for a quieter trip, Cabo wins", + "tts": "For a quieter trip, Cabo wins. Smaller resorts, more adults than kids, fewer big parties. If you want to read a book at the pool without somebody doing a cannonball, Cabo. If you want a vibe, Cancun. Same hi2b certificate covers both. The link is in my bio, hi2b dot com." }, + { "id": "b19-08-food-drinks", "angle": "Food and drinks", "hook_text": "food and drinks — which wins", + "tts": "Food and drinks. Cancun resorts are usually huge with eight or nine restaurants, so the variety is wild. Cabo has fewer restaurants per resort but the quality tends to be higher and the cocktails are more inventive. I gained the same amount of weight at both. The hi2b certificate covers either, the link is in my bio, hi2b dot com." }, + { "id": "b19-09-pacific-vs-caribbean","angle": "Pacific vs Caribbean water", "hook_text": "real difference between the two oceans", + "tts": "Real talk about the water. The Caribbean side at Cancun is calm, warm, and that almost-fake turquoise color. The Pacific side at Cabo is colder, with bigger waves, and a deeper blue. If you want to actually swim and float for hours, Cancun. If you want dramatic photos and to watch the ocean from your room, Cabo. Both on one hi2b certificate, link in my bio, hi2b dot com." }, + { "id": "b19-10-cta", "angle": "Hard CTA", "hook_text": "pick one, do the other next year", + "tts": "You do not have to decide forever today. The hi2b certificate gives you four destinations and eighteen months to use it. Pick the one that fits this year, save the other for next year. We have done two of them on two separate certificates and would do it again. The link is in my bio, hi2b dot com. Go pick yours." } + ] +} diff --git a/scripts/batch20-render.ts b/scripts/batch20-render.ts new file mode 100644 index 0000000..9083461 --- /dev/null +++ b/scripts/batch20-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 20 InfiniteTalk render — "Hour Test" Hour-to-Paradise spin. + * + * npx tsx scripts/batch20-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B20 = [ + 'b20-01-the-test', 'b20-02-90-minutes', 'b20-03-what-they-ask', 'b20-04-easy-test', 'b20-05-skeptic-test', + 'b20-06-bring-something', 'b20-07-honest-pitch', 'b20-08-no-pressure', 'b20-09-hour-vs-airbnb', 'b20-10-cta', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18892, scripts: ALL_B20 }, +} + +const KEYFRAME_MAP: Record = { + 'b20-01-the-test': 'keyframe-v13-1.jpg', + 'b20-02-90-minutes': 'keyframe-v13-1.jpg', + 'b20-03-what-they-ask': 'keyframe-v13-2.jpg', + 'b20-04-easy-test': 'keyframe-v13-2.jpg', + 'b20-05-skeptic-test': 'keyframe-v13-3.jpg', + 'b20-06-bring-something': 'keyframe-v13-3.jpg', + 'b20-07-honest-pitch': 'keyframe-v13-4.jpg', + 'b20-08-no-pressure': 'keyframe-v13-4.jpg', + 'b20-09-hour-vs-airbnb': 'keyframe-v13-5.jpg', + 'b20-10-cta': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch20-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b20', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b20-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch20/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch20')) mkdirSync('public/videos/ugc/batch20', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch20/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch20/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch20/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch20-scripts.json b/scripts/batch20-scripts.json new file mode 100644 index 0000000..88c1a89 --- /dev/null +++ b/scripts/batch20-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 20 — 'Hour Test'. Hour-to-Paradise spin. 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 1.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b20-01-the-test", "angle": "The test you pass", "hook_text": "there's a test you take to unlock 5 days in Mexico", + "tts": "Real talk. There's a test you take to unlock five days in Mexico, and the test is one hour long. The hi2b certificate I bought includes a sixty to ninety minute presentation at the resort. That's the catch, and that's the entire catch. Most travel companies hide it in small print. I'd rather just tell you. Link in my bio, hi2b dot com." }, + { "id": "b20-02-90-minutes", "angle": "90 minutes vs 7200 minutes", "hook_text": "do the math — 90 min vs 7,200 min", + "tts": "Ninety minutes is the longest version. Five days is one hundred and twenty hours. So you trade about one and a half percent of your trip time for the resort, the room, the food, and the beach. I did that math at the airport and laughed. Link to the certificate in my bio, hi2b dot com." }, + { "id": "b20-03-what-they-ask", "angle": "What they actually ask", "hook_text": "here's what they actually ask in the hour", + "tts": "What do they actually ask you in the hour. They show you the resort, ask if you want to join the vacation membership, and that's it. No trapped doors, no hidden fees. You say yes or no, you walk out, you're checked in. Link in my bio if you want it, hi2b dot com." }, + { "id": "b20-04-easy-test", "angle": "Easiest test ever", "hook_text": "easiest test I've ever taken", + "tts": "This is the easiest test I have ever taken in my life. Sit down, listen, answer questions if you want, decline the upsell, get the wristband, go to your room. The hi2b certificate I used costs two forty nine. Link in my bio, hi2b dot com." }, + { "id": "b20-05-skeptic-test", "angle": "Skeptic of the test", "hook_text": "I went in skeptical of the hour test", + "tts": "I went in skeptical of the whole hour-long test thing. I was waiting for the trick. There is no trick. They give you a sales pitch, you say no thanks, they shake your hand. Then you have five days at the resort. Link in my bio, hi2b dot com." }, + { "id": "b20-06-bring-something", "angle": "Pro tip for the hour", "hook_text": "pro tip — bring something to read", + "tts": "Pro tip for the hour. Bring something to read or scroll. Headphones are fine. You're not in a courtroom. They walk you through the membership, you nod or shake your head, you're done. Then real vacation. Link in my bio for the certificate, hi2b dot com." }, + { "id": "b20-07-honest-pitch", "angle": "Yes there is a pitch", "hook_text": "yes there's a pitch — here's exactly what it is", + "tts": "Yes there is a pitch. They want you to buy a vacation membership for future trips. You can decline. I declined. I still got my five day all-inclusive trip exactly as promised. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b20-08-no-pressure", "angle": "No they don't lock the door","hook_text": "no, they don't lock the door", + "tts": "No, they don't lock the door. No, they don't hold your passport. No, they don't follow you to your room. They ask once, maybe twice, then they let you go enjoy paradise. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b20-09-hour-vs-airbnb", "angle": "Hour vs Airbnb checkout", "hook_text": "one hour vs the average Airbnb argument", + "tts": "Trade. One hour of resort presentation for five days, four nights, all-inclusive, two adults, kids free. I spent more time arguing with my last Airbnb about checkout. The link is in my bio, hi2b dot com." }, + { "id": "b20-10-cta", "angle": "Take the test", "hook_text": "if you can sit through one hour — paradise", + "tts": "If you can sit through one hour of someone pitching you something, you can have a five day all-inclusive Mexican vacation for under three hundred dollars. That's the whole offer. The link to the certificate is in my bio, hi2b dot com. Go take the test." } + ] +} diff --git a/scripts/batch21-multitalk-render.ts b/scripts/batch21-multitalk-render.ts new file mode 100644 index 0000000..a18ae2d --- /dev/null +++ b/scripts/batch21-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 21 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch21-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch21/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b21-01-the-room': 'v10-1', 'b21-02-coffee-snacks': 'v10-1', + 'b21-03-the-pitch': 'v10-2', 'b21-04-no-pressure-couple': 'v10-2', + 'b21-05-questions': 'v10-3', 'b21-06-honest-not-scary': 'v10-3', + 'b21-07-hour-15-min': 'v10-4', 'b21-08-then-paradise': 'v10-4', + 'b21-09-friends-asked': 'v10-5', 'b21-10-cta-couple': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b21', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch21/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b21-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch21/${id}.mp4`, buf) + console.log(` ✓ saved batch21/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch21')) mkdirSync('public/videos/ugc/batch21', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch21/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch21/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH21 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch21-scripts.json b/scripts/batch21-scripts.json new file mode 100644 index 0000000..42a6709 --- /dev/null +++ b/scripts/batch21-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 21 — 'What Happens In That Hour'. Couple walkthrough of the actual presentation. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b21-01-the-room", "angle": "What the room looks like", "hook_text": "okay we did the hour — here's the room", + "tts": "Okay we did the hour. Here's what the room actually looks like. Bright, conference-room style, with snacks and coffee on a side table. Eight other couples around us, all looking just as curious. Not what we expected. Link to the certificate in my bio, hi2b dot com." }, + { "id": "b21-02-coffee-snacks", "angle": "Coffee and snacks first", "hook_text": "they literally give you coffee and snacks", + "tts": "They literally hand you coffee, juice, and pastries first. Sit down with us, they say. It feels more like an open house tour than a sales pitch. Then they walk you through what the membership is. The link is in my bio, hi2b dot com." }, + { "id": "b21-03-the-pitch", "angle": "The actual pitch", "hook_text": "the pitch is for a membership — totally optional", + "tts": "The pitch is for a long term vacation membership. Future trips, better rates, more destinations. Totally optional. We did not buy it and we still got our five day all-inclusive certificate exactly as promised. Link in my bio, hi2b dot com." }, + { "id": "b21-04-no-pressure-couple","angle": "We said no twice", "hook_text": "we said no twice — they were chill", + "tts": "We said no twice. They said okay, here are your wristbands, enjoy the resort. That was it. No pressure, no follow ups, no calls. The certificate is on hi2b dot com, link in my bio." }, + { "id": "b21-05-questions", "angle": "Ask them anything", "hook_text": "ask them anything — they'll answer", + "tts": "Ask them anything. We asked about the fine print on the membership, the resort rules, the room upgrade. They answered everything. It's a conversation, not a confrontation. The link is in my bio, hi2b dot com." }, + { "id": "b21-06-honest-not-scary", "angle": "Not as scary as Reddit says", "hook_text": "honestly we expected it to be way scarier", + "tts": "Honestly we expected it to be way scarier than it was. Every Reddit thread makes it sound like a hostage situation. It was a meeting. A meeting with juice. Link in my bio, hi2b dot com." }, + { "id": "b21-07-hour-15-min", "angle": "Out in 75 min", "hook_text": "we were out in an hour and fifteen", + "tts": "We were out in an hour and fifteen minutes. They told us up front the max was ninety minutes. They actually finished early. Then we checked in to our room and the trip started. Link in my bio, hi2b dot com." }, + { "id": "b21-08-then-paradise", "angle": "Then they handed us wristbands","hook_text": "then they handed us the wristbands", + "tts": "After the hour, they walked us to reception, handed us the wristbands, and that was it. Five days of all-inclusive started right there. Pool, beach, food, drinks. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b21-09-friends-asked", "angle": "Friends ask if we got scammed", "hook_text": "friends asked if we got scammed", + "tts": "Our friends keep asking if we got scammed. We did not. The presentation is real, the catch is real, the trip is real. It's all exactly what they promise. Link in my bio if you want one, hi2b dot com." }, + { "id": "b21-10-cta-couple", "angle": "Hard CTA couples", "hook_text": "if you and your partner can sit for an hour", + "tts": "If you and your partner can sit in a room for an hour and say no thank you, you can have a five day all-inclusive Mexican vacation. Two of you. Kids free. The link is in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch22-render.ts b/scripts/batch22-render.ts new file mode 100644 index 0000000..500dd0d --- /dev/null +++ b/scripts/batch22-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 22 InfiniteTalk render — "I Set a Timer" humor angle. + * + * npx tsx scripts/batch22-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B22 = [ + 'b22-01-i-set-a-timer', 'b22-02-15-min-in', 'b22-03-30-min-coffee', 'b22-04-45-the-numbers', 'b22-05-60-min-pitch', + 'b22-06-said-no', 'b22-07-72-min-done', 'b22-08-the-other-shoe', 'b22-09-five-days-later', 'b22-10-cta-timer', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18893, scripts: ALL_B22 }, +} + +const KEYFRAME_MAP: Record = { + 'b22-01-i-set-a-timer': 'keyframe-v13-1.jpg', + 'b22-02-15-min-in': 'keyframe-v13-1.jpg', + 'b22-03-30-min-coffee': 'keyframe-v13-2.jpg', + 'b22-04-45-the-numbers': 'keyframe-v13-2.jpg', + 'b22-05-60-min-pitch': 'keyframe-v13-3.jpg', + 'b22-06-said-no': 'keyframe-v13-3.jpg', + 'b22-07-72-min-done': 'keyframe-v13-4.jpg', + 'b22-08-the-other-shoe': 'keyframe-v13-4.jpg', + 'b22-09-five-days-later':'keyframe-v13-5.jpg', + 'b22-10-cta-timer': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch22-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video, deadpan-humor energy, narrating a story to camera with slight smirks and dry expressions. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b22', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b22-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch22/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch22')) mkdirSync('public/videos/ugc/batch22', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch22/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch22/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch22/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch22-scripts.json b/scripts/batch22-scripts.json new file mode 100644 index 0000000..8dbbc05 --- /dev/null +++ b/scripts/batch22-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 22 — 'I Set a Timer'. Humor angle. Sarah times the presentation, deadpans as it counts down. 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 1.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b22-01-i-set-a-timer", "angle": "I set a 90 min timer", "hook_text": "I set a timer for 90 minutes when they sat us down", + "tts": "I set a timer on my phone for ninety minutes the second they sat us down. Half curiosity, half insurance policy. The hi2b certificate said sixty to ninety minute presentation. I wanted to see if they actually meant it. Link in my bio, hi2b dot com." }, + { "id": "b22-02-15-min-in", "angle": "15 min — still welcoming", "hook_text": "15 min in, still on the welcome slide", + "tts": "Fifteen minutes in, they were still on the welcome slide. Just talking about the resort and the company. No pitch yet. Timer kept counting. I started to relax. Link in my bio, hi2b dot com." }, + { "id": "b22-03-30-min-coffee", "angle": "30 min — coffee refill", "hook_text": "30 min mark — they refilled my coffee", + "tts": "Thirty minute mark. They refilled my coffee, kept the slides going. Showed us the rooms, the pools, the included restaurants. Still no hard sell. Timer ticking. Link in my bio, hi2b dot com." }, + { "id": "b22-04-45-the-numbers", "angle": "45 min — numbers came out", "hook_text": "45 min — this is when the numbers came out", + "tts": "Forty-five minutes in is when the actual numbers came out. The membership pricing, what it includes, what it does not. They asked if we had questions. We did. They answered. Link in my bio, hi2b dot com." }, + { "id": "b22-05-60-min-pitch", "angle": "60 min — the actual ask", "hook_text": "60 min — they asked, I said no", + "tts": "Sixty minute mark. The ask. Do you want to join the vacation membership. I said no thanks. He said no worries. The timer kept going. Link in my bio, hi2b dot com." }, + { "id": "b22-06-said-no", "angle": "I said no, they said ok", "hook_text": "I said no — they said okay and kept going", + "tts": "I said no. They said okay. They literally just kept helping us with the rest of the check in process. No guilt trip, no second pitch, no manager swap. Link in my bio, hi2b dot com." }, + { "id": "b22-07-72-min-done", "angle": "72 min — keys in hand", "hook_text": "timer hit 72 min — they handed us the keys", + "tts": "Timer hit seventy two minutes when they handed us the wristbands. Twelve minutes under the max. I almost felt bad for being braced. Link in my bio, hi2b dot com." }, + { "id": "b22-08-the-other-shoe", "angle": "Waiting for the catch", "hook_text": "I kept waiting for the other shoe to drop", + "tts": "The whole hour I was waiting for the other shoe to drop. The hidden fee, the locked door, the lawyer. Never happened. Link in my bio for the certificate, hi2b dot com." }, + { "id": "b22-09-five-days-later", "angle": "5 days later — felt free", "hook_text": "5 days later, the hour felt free", + "tts": "Five days later, walking out, the hour felt free. Like a coupon you scan at the register. The trade was completely worth it. Link in my bio, hi2b dot com." }, + { "id": "b22-10-cta-timer", "angle": "Set your own timer", "hook_text": "set your own timer — worst case 90 min", + "tts": "Set your own timer. Worst case you waste ninety minutes of your life. Best case, you get five days, four nights, all-inclusive, two adults, kids free, in Mexico. Link in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch23-render.ts b/scripts/batch23-render.ts new file mode 100644 index 0000000..a3a7159 --- /dev/null +++ b/scripts/batch23-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 23 InfiniteTalk render — "Hour-Per-Day Math". Mature voice (Laura). + * + * npx tsx scripts/batch23-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg. 5 keyframes × 2 scripts. Math/explainer tone. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B23 = [ + 'b23-01-the-math', 'b23-02-18-min-per-day', 'b23-03-vs-airbnb-fee', 'b23-04-vs-tsa-line', 'b23-05-per-meal', + 'b23-06-skeptic-math', 'b23-07-vs-cruise-line', 'b23-08-hour-i-have', 'b23-09-the-real-cost', 'b23-10-cta-math', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18894, scripts: ALL_B23 }, +} + +const KEYFRAME_MAP: Record = { + 'b23-01-the-math': 'keyframe-v13-1.jpg', + 'b23-02-18-min-per-day': 'keyframe-v13-1.jpg', + 'b23-03-vs-airbnb-fee': 'keyframe-v13-2.jpg', + 'b23-04-vs-tsa-line': 'keyframe-v13-2.jpg', + 'b23-05-per-meal': 'keyframe-v13-3.jpg', + 'b23-06-skeptic-math': 'keyframe-v13-3.jpg', + 'b23-07-vs-cruise-line': 'keyframe-v13-4.jpg', + 'b23-08-hour-i-have': 'keyframe-v13-4.jpg', + 'b23-09-the-real-cost': 'keyframe-v13-5.jpg', + 'b23-10-cta-math': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch23-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A warm mature woman in her forties at a luxury Mexican beach resort, taking a casual selfie video and explaining the math of a deal to camera in a friendly knowledgeable tone — like an aunt sharing a trick. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b23', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b23-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch23/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch23')) mkdirSync('public/videos/ugc/batch23', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch23/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch23/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch23/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch23-scripts.json b/scripts/batch23-scripts.json new file mode 100644 index 0000000..9797a75 --- /dev/null +++ b/scripts/batch23-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 23 — 'Hour-Per-Day Math'. Mature warm female (Laura) doing the math out loud. 5 keyframes v13-1..5 (reused). Laura voice. Single-speaker InfiniteTalk on Inst 1.", + "voice_ref": "e3cd384158934cc9a01029cd7d278634", + "voice_name": "Laura (mature warm female)", + "scripts": [ + { "id": "b23-01-the-math", "angle": "Let me do the math", "hook_text": "let me do the math out loud for you", + "tts": "Let me do the math out loud for you. The hi2b certificate gives you five days, four nights, all-inclusive in Mexico for two adults. The catch is a sixty to ninety minute presentation at the resort. Let's run the numbers. Link in my bio, hi2b dot com." }, + { "id": "b23-02-18-min-per-day", "angle": "18 min per day", "hook_text": "18 min of presentation per day of vacation", + "tts": "Ninety minutes spread across five days is eighteen minutes per day. That is less than your morning coffee routine. For an all-inclusive resort. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b23-03-vs-airbnb-fee", "angle": "vs Airbnb cleaning fee", "hook_text": "less than your Airbnb cleaning fee — in time", + "tts": "For comparison, the average Airbnb cleaning fee is about a hundred dollars. The hi2b certificate is two forty nine total for both of you. And the only fee in time is an hour. Link in my bio, hi2b dot com." }, + { "id": "b23-04-vs-tsa-line", "angle": "vs TSA line at LAX", "hook_text": "shorter than the TSA line at LAX", + "tts": "The TSA line at LAX took us longer than this presentation. Not even close. If you can stand in line at the airport, you can sit through this. The link is in my bio, hi2b dot com." }, + { "id": "b23-05-per-meal", "angle": "Less than one nice dinner", "hook_text": "less than one nice dinner out", + "tts": "Sixty to ninety minutes is less than one nice dinner out. And on the other side of it is five days of dinners, drinks, room service, all included. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b23-06-skeptic-math", "angle": "Checked the math 3x", "hook_text": "I checked the math 3 times — it holds", + "tts": "I checked the math three times before booking. Five days, four nights, all-inclusive, two adults, kids free, two forty nine. Plus one hour of attention. It still made sense the third time. Link in my bio, hi2b dot com." }, + { "id": "b23-07-vs-cruise-line", "angle": "Cruises waste more time", "hook_text": "cruise lines waste an hour just boarding", + "tts": "A cruise wastes an hour just on boarding. Another hour on the safety drill. The presentation here is the only structured thing you have to do all week. Link in my bio, hi2b dot com." }, + { "id": "b23-08-hour-i-have", "angle": "Hour + $249 = paradise", "hook_text": "if you have one hour and $249", + "tts": "If you have one hour and two hundred forty nine dollars, you have a five day vacation. That is the entire deal. No hidden minimums, no upsell required. The link is in my bio, hi2b dot com." }, + { "id": "b23-09-the-real-cost", "angle": "Real cost is attention", "hook_text": "the real cost of paradise is 60 min of attention", + "tts": "The real cost of paradise is sixty minutes of your attention. Not your money, not your dignity, not your patience past one hour. Just your attention. The link is in my bio, hi2b dot com." }, + { "id": "b23-10-cta-math", "angle": "Run the math", "hook_text": "run the math yourself — then go", + "tts": "Run the math yourself. Look up the resort. Compare it to what you would pay direct. Then book the certificate. Take the hour. Go. The link is in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch24-multitalk-render.ts b/scripts/batch24-multitalk-render.ts new file mode 100644 index 0000000..93fdaf7 --- /dev/null +++ b/scripts/batch24-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 24 — "Hour Worth Five Days" couple banter (he was skeptical, she insisted). + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch24-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes. Woman is foreground selfie-taker → -mask-w. + * audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch24/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b24-01-he-said-no': 'v10-1', 'b24-02-i-said-yes': 'v10-1', + 'b24-03-we-went': 'v10-2', 'b24-04-his-face': 'v10-2', + 'b24-05-his-turn': 'v10-3', 'b24-06-fair-trade': 'v10-3', + 'b24-07-presentation-honest': 'v10-4', 'b24-08-not-pressure': 'v10-4', + 'b24-09-best-decision': 'v10-5', 'b24-10-cta-pair': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A happy young couple in their late twenties at a luxury Mexican beach resort, each holding a tropical drink. The woman in the foreground talks warmly to the camera with playful confidence — telling a story about her husband — while he stands beside her grinning and shaking his head good-naturedly. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and beach softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b24', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch24/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b24-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch24/${id}.mp4`, buf) + console.log(` ✓ saved batch24/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch24')) mkdirSync('public/videos/ugc/batch24', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch24/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch24/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH24 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch24-scripts.json b/scripts/batch24-scripts.json new file mode 100644 index 0000000..b8dab28 --- /dev/null +++ b/scripts/batch24-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 24 — 'Hour Worth Five Days'. Couple. He was skeptical, she insisted. Resolves to 'best hour we ever spent'. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b24-01-he-said-no", "angle": "He said no way", "hook_text": "my husband said no way an hour is worth this", + "tts": "My husband said no way an hour-long presentation is worth all this. He has done the timeshare thing before. He had bad memories. I told him this one is different, just trust me. Link in my bio, hi2b dot com." }, + { "id": "b24-02-i-said-yes", "angle": "Just give it the hour", "hook_text": "I told him just give it the hour", + "tts": "I told him, just give it the hour. If it sucks we walk out and we still get the trip. The certificate guarantees the five days regardless. He grumbled, he came. Link in my bio, hi2b dot com." }, + { "id": "b24-03-we-went", "angle": "We went, we sat, we left", "hook_text": "we went, we sat, we listened, we left", + "tts": "We went, we sat, we listened, we left. The whole thing took about seventy minutes. They were polite, they were honest, they were not pushy. He was shocked. Link in my bio, hi2b dot com." }, + { "id": "b24-04-his-face", "angle": "His face at the resort", "hook_text": "his face when he saw the room", + "tts": "His face when we got to the resort and saw the room was worth recording. He kept saying, that was it. That was really it. Yes, that was really it. The link is in my bio, hi2b dot com." }, + { "id": "b24-05-his-turn", "angle": "Now he's the evangelist", "hook_text": "now HE'S the one telling his friends", + "tts": "Now he is the one telling his friends about it. He went from skeptic to evangelist in seventy minutes. The certificate is on hi2b dot com, link in my bio." }, + { "id": "b24-06-fair-trade", "angle": "Fair trade — his time", "hook_text": "an hour of his attention for 5 days of mine", + "tts": "An hour of his attention for five days of my happiness. Fair trade. He says he would do it again. He has said that twice this week. Link in my bio, hi2b dot com." }, + { "id": "b24-07-presentation-honest","angle": "Presentation was honest", "hook_text": "the presentation was actually honest", + "tts": "The presentation was actually honest. They told us up front what they were going to ask. They explained the membership. They told us we could say no. We did. They moved on. Link in my bio, hi2b dot com." }, + { "id": "b24-08-not-pressure", "angle": "Nobody pressured us", "hook_text": "nobody pressured us into the membership", + "tts": "Nobody pressured us. There was one ask, we declined, they said okay, here are your wristbands. I was waiting for a manager to come over. Never happened. Link in my bio, hi2b dot com." }, + { "id": "b24-09-best-decision", "angle": "Best hour we ever spent", "hook_text": "best hour we've ever spent together", + "tts": "Best hour we have ever spent together. We got a five day trip out of it. We got a good story out of it. And we got my husband to admit I was right. Win win win. Link in my bio, hi2b dot com." }, + { "id": "b24-10-cta-pair", "angle": "Bring your partner", "hook_text": "bring your partner — give them an hour", + "tts": "Bring your partner. Give them the hour. Take the trip. The certificate is two forty nine, covers both of you, kids free. The link is in my bio, hi2b dot com. Pick a date." } + ] +} diff --git a/scripts/batch25-render.ts b/scripts/batch25-render.ts new file mode 100644 index 0000000..271d8d9 --- /dev/null +++ b/scripts/batch25-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 25 InfiniteTalk render — "Hour Test" Hour-to-Paradise spin. + * + * npx tsx scripts/batch25-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B25 = [ + 'b25-01-i-was-warned', 'b25-02-no-locked-doors', 'b25-03-no-bait-switch', 'b25-04-no-shouting', 'b25-05-reddit-was-wrong', + 'b25-06-mom-warned', 'b25-07-no-followups', 'b25-08-one-ask', 'b25-09-walking-out', 'b25-10-cta-no-fear', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18895, scripts: ALL_B25 }, +} + +const KEYFRAME_MAP: Record = { + 'b25-01-i-was-warned': 'keyframe-v13-1.jpg', + 'b25-02-no-locked-doors': 'keyframe-v13-1.jpg', + 'b25-03-no-bait-switch': 'keyframe-v13-2.jpg', + 'b25-04-no-shouting': 'keyframe-v13-2.jpg', + 'b25-05-reddit-was-wrong': 'keyframe-v13-3.jpg', + 'b25-06-mom-warned': 'keyframe-v13-3.jpg', + 'b25-07-no-followups': 'keyframe-v13-4.jpg', + 'b25-08-one-ask': 'keyframe-v13-4.jpg', + 'b25-09-walking-out': 'keyframe-v13-5.jpg', + 'b25-10-cta-no-fear': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch25-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b25', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b25-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch25/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch25')) mkdirSync('public/videos/ugc/batch25', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch25/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch25/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch25/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch25-scripts.json b/scripts/batch25-scripts.json new file mode 100644 index 0000000..a59e39e --- /dev/null +++ b/scripts/batch25-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 25 — 'Hour from Hell? Nope'. Inversion of timeshare horror stories. 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 1.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b25-01-i-was-warned", "angle": "Everyone warned me", "hook_text": "everyone warned me about the hour — this wasn't that", + "tts": "Everyone in my comments warned me about the timeshare presentation thing. Hours of pressure, lies, locked rooms. So I went in braced. This was not that. At all. Sixty minutes, polite people, a no thank you, and a wristband. Link in my bio, hi2b dot com." }, + { "id": "b25-02-no-locked-doors", "angle": "No locked doors", "hook_text": "no locked doors, no shouting, no four hour marathon", + "tts": "No locked doors. No four hour marathon. No three managers stacking on top of each other to wear me down. One presenter, one ask, one no, one handshake. The certificate they sold me was exactly what they delivered. Link in my bio, hi2b dot com." }, + { "id": "b25-03-no-bait-switch", "angle": "No bait and switch", "hook_text": "no bait and switch — what they sell is what you get", + "tts": "What I bought online is what I got at the resort. Five days, four nights, all-inclusive, two adults, kids free. Nobody changed the deal at check in. Nobody added a hidden resort fee. They actually meant it. Link in my bio, hi2b dot com." }, + { "id": "b25-04-no-shouting", "angle": "Salesman didn't shout", "hook_text": "the salesman did NOT shout when I said no", + "tts": "The salesman did not shout when I said no thanks. He didn't roll his eyes. He didn't go get a manager. He said okay, walked us to reception, and that was it. I was honestly stunned. Link in my bio, hi2b dot com." }, + { "id": "b25-05-reddit-was-wrong", "angle": "Reddit horror stories", "hook_text": "every Reddit horror story — none happened", + "tts": "I read every Reddit horror story before going. The crying lady, the held passports, the six hour standoff. None of that happened. Not even close. The presentation was a calm sixty five minutes and we left. Link in my bio, hi2b dot com." }, + { "id": "b25-06-mom-warned", "angle": "Mom warned me", "hook_text": "my mom warned me — different era", + "tts": "My mom warned me about timeshare presentations. She was thinking of nineteen ninety three. The version in twenty twenty six is way more boring and way more honest. It is just a meeting now. Link in my bio, hi2b dot com." }, + { "id": "b25-07-no-followups", "angle": "Zero follow ups", "hook_text": "zero follow up calls, zero emails — they moved on", + "tts": "It's been two weeks. Zero follow up calls. Zero emails. Zero begging texts. They literally moved on. The relationship ended when I said no thank you at the hour mark. Link in my bio, hi2b dot com." }, + { "id": "b25-08-one-ask", "angle": "One ask, one no", "hook_text": "one ask, one 'no thanks,' done", + "tts": "One ask. One no thanks. Done. The whole feared sales loop just did not loop. There was a question. There was an answer. There was a wristband. Link in my bio, hi2b dot com." }, + { "id": "b25-09-walking-out", "angle": "Almost felt cheated", "hook_text": "walking out I almost felt cheated — by how easy", + "tts": "Walking out, I almost felt cheated. Not by them. By all the warnings. I'd hyped this up to be a battle. It was a meeting. I wasted a week of dread for a sixty five minute conversation. Link in my bio, hi2b dot com." }, + { "id": "b25-10-cta-no-fear", "angle": "Stop being scared", "hook_text": "stop being scared of an hour — take the test", + "tts": "Stop being scared of an hour. It is not the trap your mom or Reddit told you it would be. Sixty to ninety minutes, one polite ask, one easy no, five days of paradise. Link in my bio, hi2b dot com. Just go." } + ] +} diff --git a/scripts/batch26-multitalk-render.ts b/scripts/batch26-multitalk-render.ts new file mode 100644 index 0000000..b4c4328 --- /dev/null +++ b/scripts/batch26-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 26 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch26-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch26/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b26-01-the-date': 'v10-1', 'b26-02-coffee-bar': 'v10-1', + 'b26-03-they-talked': 'v10-2', 'b26-04-no-pressure-vibe': 'v10-2', + 'b26-05-his-relax': 'v10-3', 'b26-06-shared-jokes': 'v10-3', + 'b26-07-better-than-dinner': 'v10-4', 'b26-08-talked-after': 'v10-4', + 'b26-09-best-meeting': 'v10-5', 'b26-10-cta-date': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b26', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch26/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b26-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch26/${id}.mp4`, buf) + console.log(` ✓ saved batch26/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch26')) mkdirSync('public/videos/ugc/batch26', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch26/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch26/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH21 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch26-scripts.json b/scripts/batch26-scripts.json new file mode 100644 index 0000000..3bc2b0f --- /dev/null +++ b/scripts/batch26-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 26 — 'The 90-Minute Date'. Couple treats the presentation like a date — coffee, snacks, conversation. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b26-01-the-date", "angle": "We made it a date", "hook_text": "we decided to treat the hour like a date", + "tts": "We decided to treat the ninety minute presentation like a date. Got dressed up, had coffee, made it a thing. It was honestly more fun than half the dinners we have been on lately. Link in my bio, hi2b dot com." }, + { "id": "b26-02-coffee-bar", "angle": "Coffee bar setup", "hook_text": "there's literally a coffee bar at the back", + "tts": "There is literally a coffee bar at the back of the room. Real coffee, pastries, juice. We picked up two lattes and a croissant on the way in. The vibe was less car dealership, more hotel breakfast. Link in my bio, hi2b dot com." }, + { "id": "b26-03-they-talked", "angle": "We just chatted", "hook_text": "the presenter chatted with us, didn't lecture", + "tts": "The presenter chatted with us. Asked about our jobs, where we were from, what we like to do on vacation. We told her. She actually listened. Then she explained the resort and the membership. It was a conversation. Link in my bio, hi2b dot com." }, + { "id": "b26-04-no-pressure-vibe", "angle": "No pressure vibe", "hook_text": "no pressure vibe, no closing tricks", + "tts": "There was no pressure vibe. No closing tricks. No today only price. She gave us the membership cost, gave us the math, gave us a no obligation way to think about it. We said no for now. She said cool. Link in my bio, hi2b dot com." }, + { "id": "b26-05-his-relax", "angle": "He finally relaxed", "hook_text": "I watched my husband finally relax", + "tts": "I watched my husband finally relax around minute twenty. He stopped clenching the coffee cup. He laughed at one of her jokes. By the end he was making jokes back. It was an hour with a stranger that did not feel like an hour with a stranger. Link in my bio, hi2b dot com." }, + { "id": "b26-06-shared-jokes", "angle": "We left laughing", "hook_text": "we left the room laughing — not what I expected", + "tts": "We left the room laughing. The presenter walked us to reception, said enjoy your trip, and we hugged. It was the weirdest sales experience of my life because it was not a sales experience. It was a chat. Link in my bio, hi2b dot com." }, + { "id": "b26-07-better-than-dinner","angle": "Better than half our dinners","hook_text": "better than half our recent dinner dates", + "tts": "Honestly. Better than half of our recent dinner dates. We were forced to be present, no phones, just talking to a person and to each other. Free coffee. A free vacation at the end. Tell me one bad dinner that did that. Link in my bio, hi2b dot com." }, + { "id": "b26-08-talked-after", "angle": "We talked all the way back","hook_text": "we talked all the way to our room", + "tts": "We talked all the way to our room about the presentation. Not about whether to buy the membership. About how strange it was that they did not push us. We have been pushed harder buying a car wash subscription. Link in my bio, hi2b dot com." }, + { "id": "b26-09-best-meeting", "angle": "Best meeting we attended", "hook_text": "best business meeting we've ever attended", + "tts": "It might be the best business meeting we have ever attended. Clear agenda, good coffee, ended on time, exited with a clear next step. Most companies could learn from how they run that hour. Link in my bio, hi2b dot com." }, + { "id": "b26-10-cta-date", "angle": "Make it a date", "hook_text": "make it a date — bring your partner", + "tts": "Make it a date. Bring your partner. Dress up a little. Treat the hour like a coffee meeting with a stranger. Then walk out with a five day all-inclusive trip. The certificate is in my bio, hi2b dot com. Pick a Saturday." } + ] +} diff --git a/scripts/batch27-render.ts b/scripts/batch27-render.ts new file mode 100644 index 0000000..04fccc4 --- /dev/null +++ b/scripts/batch27-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 27 InfiniteTalk render — "Hour Test" Hour-to-Paradise spin. + * + * npx tsx scripts/batch27-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B27 = [ + 'b27-01-the-countdown', 'b27-02-bags-still-packed', 'b27-03-they-said-90', 'b27-04-i-can-see-it', 'b27-05-watching-clock', + 'b27-06-husband-pacing', 'b27-07-this-is-the-trade', 'b27-08-coffee-and-questions', 'b27-09-30-min-left', 'b27-10-cta-countdown', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18896, scripts: ALL_B27 }, +} + +const KEYFRAME_MAP: Record = { + 'b27-01-the-countdown': 'keyframe-v13-1.jpg', + 'b27-02-bags-still-packed': 'keyframe-v13-1.jpg', + 'b27-03-they-said-90': 'keyframe-v13-2.jpg', + 'b27-04-i-can-see-it': 'keyframe-v13-2.jpg', + 'b27-05-watching-clock': 'keyframe-v13-3.jpg', + 'b27-06-husband-pacing': 'keyframe-v13-3.jpg', + 'b27-07-this-is-the-trade': 'keyframe-v13-4.jpg', + 'b27-08-coffee-and-questions': 'keyframe-v13-4.jpg', + 'b27-09-30-min-left': 'keyframe-v13-5.jpg', + 'b27-10-cta-countdown': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch27-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b27', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b27-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch27/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch27')) mkdirSync('public/videos/ugc/batch27', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch27/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch27/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch27/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch27-scripts.json b/scripts/batch27-scripts.json new file mode 100644 index 0000000..b716217 --- /dev/null +++ b/scripts/batch27-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 27 — 'Hour Before Paradise'. Anticipation/countdown angle: POV at the welcome room with pool visible through the window. 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 1.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b27-01-the-countdown", "angle": "T-minus 90 to paradise", "hook_text": "T-minus 90 minutes to paradise", + "tts": "T-minus ninety minutes to paradise. I'm sitting in the welcome room at the resort with a coffee in my hand. Ninety minutes between me and the actual pool. The hi2b certificate I bought made this whole thing possible. The link is in my bio, hi2b dot com." }, + { "id": "b27-02-bags-still-packed", "angle": "Bags at the front desk", "hook_text": "bags still by the front desk", + "tts": "My bags are still parked by the front desk. The presentation room is two doors down from check in. They walked us straight here from the lobby. The hotel rooms are just a hallway away. Link in my bio for the certificate, hi2b dot com." }, + { "id": "b27-03-they-said-90", "angle": "90 min max", "hook_text": "they said 90 minutes max — for 5 days", + "tts": "They told me up front, ninety minutes max. That's all that stands between me and the pool. Ninety minutes for five days. I've waited longer for a salad at lunch. Link in my bio, hi2b dot com." }, + { "id": "b27-04-i-can-see-it", "angle": "Pool through the window", "hook_text": "I can literally see the pool through the window", + "tts": "I can literally see the pool through the window behind the presenter. Turquoise water, palm trees, a swim up bar. I'm trying to focus on what she's saying but my brain is already in the water. Link in my bio, hi2b dot com." }, + { "id": "b27-05-watching-clock", "angle": "Last day of school energy", "hook_text": "watching the clock like it's the last day of school", + "tts": "Watching the clock like it's the last day of school. Forty seven minutes to go. The presenter just asked us a question and I had no idea what she said. Link in my bio for the certificate, hi2b dot com." }, + { "id": "b27-06-husband-pacing", "angle": "Husband ready to swim", "hook_text": "my husband is mentally already in his swim shorts", + "tts": "My husband is mentally already in his swim shorts. He's tapping his foot. He keeps looking at me with a little are-we-done-yet face. Forty minutes left of the hour. Link in my bio, hi2b dot com." }, + { "id": "b27-07-this-is-the-trade", "angle": "The trade", "hook_text": "this is the trade — 1 hour for 5 days", + "tts": "This is the trade. This one hour. For five days, four nights, all-inclusive, two adults, kids free. When you frame it that way the hour goes by faster. Link in my bio, hi2b dot com." }, + { "id": "b27-08-coffee-and-questions","angle": "Real questions about membership","hook_text": "drinking coffee, asking my real questions", + "tts": "I'm drinking their coffee, asking my real questions about the membership, taking notes. Trying not to look at the clock every thirty seconds. The presenter doesn't seem rushed at all. Link in my bio, hi2b dot com." }, + { "id": "b27-09-30-min-left", "angle": "Music from pool bar", "hook_text": "30 min left — I can hear the music from the pool", + "tts": "Thirty minutes left. I can hear the music from the pool bar through the wall. Someone outside just laughed and splashed. My eye contact with the presenter is one hundred percent fake at this point. Link in my bio, hi2b dot com." }, + { "id": "b27-10-cta-countdown", "angle": "Your countdown starts now", "hook_text": "your hour to paradise starts the moment you book", + "tts": "Your hour to paradise starts the moment you book. Then it's just a countdown — the flight, the airport, the lobby, the presentation room, the door. Click the link in my bio, hi2b dot com. Set your countdown." } + ] +} diff --git a/scripts/batch28-multitalk-render.ts b/scripts/batch28-multitalk-render.ts new file mode 100644 index 0000000..7f28fbe --- /dev/null +++ b/scripts/batch28-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 28 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch28-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch28/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b28-01-just-finished': 'v10-1', 'b28-02-the-room': 'v10-1', + 'b28-03-they-were-pros': 'v10-2', 'b28-04-we-said-no': 'v10-2', + 'b28-05-walked-out-here': 'v10-3', 'b28-06-husband-grin': 'v10-3', + 'b28-07-bracelet': 'v10-4', 'b28-08-shorter-than-airport': 'v10-4', + 'b28-09-pool-time': 'v10-5', 'b28-10-cta-just-do': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b28', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch28/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b28-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch28/${id}.mp4`, buf) + console.log(` ✓ saved batch28/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch28')) mkdirSync('public/videos/ugc/batch28', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch28/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch28/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH28 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch28-scripts.json b/scripts/batch28-scripts.json new file mode 100644 index 0000000..82d08b4 --- /dev/null +++ b/scripts/batch28-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 28 — 'Just Came Out'. Couple walks out of the presentation room, woman selfie-streams real-time reportage. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b28-01-just-finished", "angle": "Just finished the hour", "hook_text": "just finished — we made it — here's how it went", + "tts": "Okay, just finished. We literally just walked out of the welcome presentation. Sixty seven minutes start to finish. We got the wristbands, we have the room key in our pocket, and I'm filming this in the hallway right now. Link in my bio, hi2b dot com." }, + { "id": "b28-02-the-room", "angle": "Describing the room", "hook_text": "the room was a normal conference room", + "tts": "The room was a normal hotel conference room. Sunlight through the blinds, water bottles on the tables, eight other couples around us. Coffee bar at the back. Nothing weird, nothing intimidating. Link in my bio, hi2b dot com." }, + { "id": "b28-03-they-were-pros", "angle": "Polite, organized, fast", "hook_text": "they were polite, organized, didn't waste our time", + "tts": "The presenters were polite, organized, and they did not waste our time. They told us the agenda up front. They stuck to it. They asked us questions and listened to our answers. No tricks, no theatrics. Link in my bio, hi2b dot com." }, + { "id": "b28-04-we-said-no", "angle": "We said no, they nodded", "hook_text": "we said no thank you — they nodded", + "tts": "When the ask came, we said no thank you. The presenter literally nodded, smiled, said no problem, and signed our paperwork. The whole no thank you took maybe four minutes. Link in my bio, hi2b dot com." }, + { "id": "b28-05-walked-out-here", "angle": "Out at minute 67", "hook_text": "we walked out at minute 67 — here we are", + "tts": "We walked out at minute sixty seven. Twenty three minutes under the ninety minute max. Here we are in the hallway. Husband is behind me. Wristbands are on. We are officially on vacation. Link in my bio, hi2b dot com." }, + { "id": "b28-06-husband-grin", "angle": "His grin says it all", "hook_text": "my husband's grin tells you everything", + "tts": "Look at my husband's face. That grin tells you everything you need to know. He was the skeptic. He was the one bracing for the worst. He's the one who looks the most relieved right now. Link in my bio, hi2b dot com." }, + { "id": "b28-07-bracelet", "angle": "Wristbands + room key", "hook_text": "wristbands on, room key in pocket", + "tts": "Wristbands are on. Room key is in my pocket. We have a real check in at the desk in like ten minutes. Then food, then pool. All on the hi2b certificate. Link in my bio, hi2b dot com." }, + { "id": "b28-08-shorter-than-airport","angle": "Shorter than the airport wait","hook_text": "shorter than our airport wait yesterday", + "tts": "Quick fact. This presentation was shorter than the wait at the gate yesterday for our flight. Sixty seven minutes here. Ninety five minutes at the gate. The trip already feels worth it. Link in my bio, hi2b dot com." }, + { "id": "b28-09-pool-time", "angle": "And now — pool", "hook_text": "and now... pool", + "tts": "And now. Pool. Bag drop, change into something comfortable, get to the swim up bar before sunset. Five days of this start right now. Link in my bio, hi2b dot com." }, + { "id": "b28-10-cta-just-do", "angle": "Just do the hour", "hook_text": "if you're hesitating — just do the hour", + "tts": "If you are hesitating, just do the hour. We just did it. It was easy. It was honest. It was over fast. And now we have five days of all-inclusive paradise on the other side. Link in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch29-multitalk-render.ts b/scripts/batch29-multitalk-render.ts new file mode 100644 index 0000000..6a53afc --- /dev/null +++ b/scripts/batch29-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 29 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch29-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch29/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b29-01-we-came-back': 'v10-1', 'b29-02-knew-the-drill': 'v10-1', + 'b29-03-same-room': 'v10-2', 'b29-04-faster-this-time': 'v10-2', + 'b29-05-different-resort': 'v10-3', 'b29-06-second-time-easier': 'v10-3', + 'b29-07-told-friends': 'v10-4', 'b29-08-husband-relaxed': 'v10-4', + 'b29-09-anniversary': 'v10-5', 'b29-10-cta-repeat': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b29', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch29/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b29-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch29/${id}.mp4`, buf) + console.log(` ✓ saved batch29/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch29')) mkdirSync('public/videos/ugc/batch29', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch29/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch29/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH29 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch29-scripts.json b/scripts/batch29-scripts.json new file mode 100644 index 0000000..cd8fdaf --- /dev/null +++ b/scripts/batch29-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 29 — 'We Came Back'. Anniversary couple returning for second hi2b certificate (different destination). 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b29-01-we-came-back", "angle": "Second time, second cert", "hook_text": "we came back — second time, second certificate", + "tts": "We came back. Second time. Second certificate. Different destination, same deal, same hour. This time we are at the Cabo property. Last year we were in Cancun. The link to the certificate is in my bio, hi2b dot com." }, + { "id": "b29-02-knew-the-drill", "angle": "Knew exactly what to expect", "hook_text": "this time we knew exactly what to expect", + "tts": "This time we knew exactly what to expect. The welcome room, the coffee, the presentation, the ask, the polite no, the wristband. Knowing what was coming made the hour feel like ten minutes. Link in my bio, hi2b dot com." }, + { "id": "b29-03-same-room", "angle": "Same setup, different presenter","hook_text": "same welcome room, different presenter", + "tts": "Same kind of welcome room. Different presenter. Different city. Same calm, polite, organized vibe. They have a process. The process works. Link in my bio, hi2b dot com." }, + { "id": "b29-04-faster-this-time", "angle": "Faster this time", "hook_text": "faster this time — 55 minutes, not 75", + "tts": "Faster this time. Fifty five minutes start to finish. Last year was seventy five. We were less wide eyed this round, asked fewer side questions, signed faster. Time saved equals more pool time. Link in my bio, hi2b dot com." }, + { "id": "b29-05-different-resort", "angle": "Same offer, Cabo this round", "hook_text": "same offer, picked Cabo this time", + "tts": "Same offer covered both years. Cancun first, Cabo this time. The certificate gives you four destinations and a long redemption window. You do not have to repeat the same trip. The link is in my bio, hi2b dot com." }, + { "id": "b29-06-second-time-easier", "angle": "Second time = no surprises", "hook_text": "the second time the hour is easier — no surprises", + "tts": "The second time the hour is easier. There are no surprises. The pitch is the same pitch. The polite no is the same polite no. The wristband is the same wristband. The pool is a different pool. Link in my bio, hi2b dot com." }, + { "id": "b29-07-told-friends", "angle": "Referred 3 friends", "hook_text": "we've referred 3 friends since the first trip", + "tts": "Since our first trip we have referred three friends. All three did the presentation. All three said it was honest. All three came back with photos. This is becoming a small social thing in our circle. Link in my bio, hi2b dot com." }, + { "id": "b29-08-husband-relaxed", "angle": "Husband walked in relaxed", "hook_text": "husband walked in already relaxed this time", + "tts": "My husband walked into the room already relaxed this time. Last year he was bracing for a fight. This year he was bracing for the wristband. Knowing the hour ahead of time changes everything. Link in my bio, hi2b dot com." }, + { "id": "b29-09-anniversary", "angle": "Anniversary trip now an annual","hook_text": "10th anniversary trip — this is our thing now", + "tts": "Tenth anniversary trip. This is officially our thing now. One hi2b certificate, one new destination, one calm hour, five days of all-inclusive. We are penciling the eleventh year in for next year already. Link in my bio, hi2b dot com." }, + { "id": "b29-10-cta-repeat", "angle": "Once you do it, you'll do it again","hook_text": "once you do it once, you'll do it again", + "tts": "Once you do it once, you will do it again. The hour stops feeling like a hurdle and starts feeling like a ritual. Cheap, honest, repeatable. The certificate is on hi2b dot com, link in my bio. Try one." } + ] +} diff --git a/scripts/batch30-multitalk-render.ts b/scripts/batch30-multitalk-render.ts new file mode 100644 index 0000000..3c1b089 --- /dev/null +++ b/scripts/batch30-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 30 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch30-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch30/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b30-01-he-did-this': 'v10-1', 'b30-02-airport-twist': 'v10-1', + 'b30-03-the-presentation-too': 'v10-2', 'b30-04-we-just-did-it': 'v10-2', + 'b30-05-his-research': 'v10-3', 'b30-06-he-said-no-for-us': 'v10-3', + 'b30-07-best-husband': 'v10-4', 'b30-08-its-fully-paid': 'v10-4', + 'b30-09-tell-husbands': 'v10-5', 'b30-10-cta-surprise': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b30', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch30/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b30-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch30/${id}.mp4`, buf) + console.log(` ✓ saved batch30/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch30')) mkdirSync('public/videos/ugc/batch30', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch30/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch30/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH30 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch30-scripts.json b/scripts/batch30-scripts.json new file mode 100644 index 0000000..0af5b7d --- /dev/null +++ b/scripts/batch30-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 30 — 'Surprise Trip'. Husband bought the certificate, wife narrating from the resort. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b30-01-he-did-this", "angle": "He bought it without telling me", "hook_text": "he did this — bought the certificate without telling me", + "tts": "He did this. He bought the hi2b certificate without telling me. I had no idea we were going anywhere. He handed me an envelope on Friday night and said pack a bag. We are at the resort right now. Link in my bio, hi2b dot com." }, + { "id": "b30-02-airport-twist", "angle": "Airport reveal", "hook_text": "we're at the airport, he says Cancun, I cry", + "tts": "We get to the airport. He hands me a boarding pass. Cancun. I literally cried at the gate. He said babe I bought the certificate three weeks ago, I have been keeping the secret. Best Friday of my year. Link in my bio, hi2b dot com." }, + { "id": "b30-03-the-presentation-too","angle": "He prepped for the hour", "hook_text": "he even did the presentation prep — he had a plan", + "tts": "He even did the presentation prep. He had read about the sixty to ninety minute welcome thing. He had a plan for how to handle it. We sat together, he answered, I just enjoyed the coffee. He had it. Link in my bio, hi2b dot com." }, + { "id": "b30-04-we-just-did-it", "angle": "Finished hour together", "hook_text": "we just finished the hour — he had it all figured out", + "tts": "We just finished the hour. He had it all figured out. He let them know up front we were not buying. They were great about it. We got the wristbands. We are on vacation. Surprise vacation. Link in my bio, hi2b dot com." }, + { "id": "b30-05-his-research", "angle": "Two months of reading reviews", "hook_text": "he'd been reading reviews for two months", + "tts": "He had been reading hi2b reviews for two months. He knew the catch was the presentation. He knew the trip was legit. He just did not tell me. Two months of secret research for one Friday night reveal. Link in my bio, hi2b dot com." }, + { "id": "b30-06-he-said-no-for-us","angle": "He said no thanks for both", "hook_text": "he said no thanks to the membership for both of us", + "tts": "When the membership ask came, he said no thanks for both of us. They asked once. They moved on. He had the answer ready. Decisive husband energy. Link in my bio, hi2b dot com." }, + { "id": "b30-07-best-husband", "angle": "Best move in 7 years", "hook_text": "best husband move in 7 years of marriage", + "tts": "Best husband move in seven years of marriage. He combined a surprise trip with full prep for the hour, and we are now at an all-inclusive resort eating off a menu. I am still not over it. Link in my bio, hi2b dot com." }, + { "id": "b30-08-its-fully-paid", "angle": "Everything included", "hook_text": "everything is included — flight, room, food, drinks", + "tts": "Everything is included. Flight, room, food, drinks. He pre-paid it all weeks ago with the certificate. I have not opened my wallet since the airport. Total surprise trip with zero hidden charges. Link in my bio, hi2b dot com." }, + { "id": "b30-09-tell-husbands", "angle": "Tell your husbands", "hook_text": "tell your husbands this exists", + "tts": "Tell your husbands this exists. Tell your wives, tell your partners. The hi2b certificate is the easiest surprise trip you can buy for someone. One hour of presentation, five days of paradise, no hidden anything. Link in my bio, hi2b dot com." }, + { "id": "b30-10-cta-surprise", "angle": "Surprise your partner", "hook_text": "surprise your partner — link in bio", + "tts": "Surprise your partner. Plan it for three weeks out. Do the prep. Hand them the envelope on a Friday. Then take them to Mexico. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch31-multitalk-render.ts b/scripts/batch31-multitalk-render.ts new file mode 100644 index 0000000..d7b1654 --- /dev/null +++ b/scripts/batch31-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 31 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch31-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch31/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b31-01-empty-nest': 'v10-1', 'b31-02-the-quiet-hour': 'v10-1', + 'b31-03-we-talked': 'v10-2', 'b31-04-no-buying': 'v10-2', + 'b31-05-his-hand': 'v10-3', 'b31-06-felt-young': 'v10-3', + 'b31-07-90-min-date': 'v10-4', 'b31-08-do-the-hour': 'v10-4', + 'b31-09-not-too-late': 'v10-5', 'b31-10-cta-empty-nest': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b31', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch31/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b31-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch31/${id}.mp4`, buf) + console.log(` ✓ saved batch31/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch31')) mkdirSync('public/videos/ugc/batch31', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch31/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch31/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH31 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch31-scripts.json b/scripts/batch31-scripts.json new file mode 100644 index 0000000..19b838e --- /dev/null +++ b/scripts/batch31-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 31 — 'Empty Nesters Reclaim'. 60yo couple, kids in college, first trip just them in 20 years. Presentation hour became their reset. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b31-01-empty-nest", "angle": "First trip just us in 20 years","hook_text": "empty nest — first trip just us in 20 years", + "tts": "Empty nest. Both kids in college. This is our first trip just the two of us in twenty years. Twenty years. We almost forgot how to do this. The hi2b certificate made it cheap enough to actually book. Link in my bio, hi2b dot com." }, + { "id": "b31-02-the-quiet-hour", "angle": "Longest sit without kids", "hook_text": "the quiet hour — longest we'd sat together without kids", + "tts": "The welcome presentation was the longest stretch we have sat next to each other quietly without kids since two thousand and four. Ninety minutes. A coffee. Each other. We did not know what to do with the silence at first. Link in my bio, hi2b dot com." }, + { "id": "b31-03-we-talked", "angle": "We actually talked", "hook_text": "we actually talked — like the twenties we used to be", + "tts": "We actually talked. Like the couple in their twenties we used to be before the strollers and the carpool schedules. About retirement. About our wedding. About what we want this next chapter to look like. All during a sales presentation. Link in my bio, hi2b dot com." }, + { "id": "b31-04-no-buying", "angle": "We're using this to reset", "hook_text": "we're not buying a membership — we're using this to reset", + "tts": "We are not buying the membership. We told them up front. They were nice about it. We are using this trip to reset, not commit to more travel right now. They moved on. We moved on. Wristbands on, link in my bio, hi2b dot com." }, + { "id": "b31-05-his-hand", "angle": "He held my hand at minute 30", "hook_text": "he held my hand at minute 30 — we don't do that anymore", + "tts": "Look. He held my hand at minute thirty of the presentation. We do not do that anymore in normal life. The kids were always between us. The phones were always out. This room with no kids and no phones gave us our hands back. Link in my bio, hi2b dot com." }, + { "id": "b31-06-felt-young", "angle": "Felt 35 for first time in years","hook_text": "felt 35 again for the first time in years", + "tts": "I felt thirty five again for the first time in years. Sitting in a foreign hotel with my husband, drinking coffee that someone else made, planning our days. That is what the hour bought us. Link in my bio, hi2b dot com." }, + { "id": "b31-07-90-min-date", "angle": "Best 90-min date since kids", "hook_text": "best 90-min date we've had since the kids were born", + "tts": "Best ninety minute date we have had since our kids were born. It was a sales presentation. Technically. We did not see it that way. We saw it as a tunnel we had to walk through to reach the vacation. The tunnel turned out to be a reset. Link in my bio, hi2b dot com." }, + { "id": "b31-08-do-the-hour", "angle": "Older couples — do the hour", "hook_text": "older couples — do the hour, you need this quiet time", + "tts": "Older couples. Do the hour. You need this quiet time more than anyone reading this. There is no other affordable way to be alone in a room with your partner for ninety uninterrupted minutes in twenty twenty six. Take the hour, take the trip. Link in my bio, hi2b dot com." }, + { "id": "b31-09-not-too-late", "angle": "Not too late for the 30s trip", "hook_text": "it's not too late — take the trip you skipped at 30", + "tts": "It is not too late. The trip you skipped at thirty because of the baby. The trip you skipped at forty because of college tuition. You can take it at sixty. Maybe better at sixty. We have more to talk about now. Link in my bio, hi2b dot com." }, + { "id": "b31-10-cta-empty-nest", "angle": "Book tonight", "hook_text": "if your kids just moved out — book this tonight", + "tts": "If your kids just moved out, book this tonight. Not next month. Not after you adjust. Tonight. The quiet has a way of getting comfortable too fast. Get the certificate, do the hour, take the trip. Link in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch32-multitalk-render.ts b/scripts/batch32-multitalk-render.ts new file mode 100644 index 0000000..70cdc0a --- /dev/null +++ b/scripts/batch32-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 32 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch32-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch32/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b32-01-the-gift': 'v10-1', 'b32-02-finally-using-it': 'v10-1', + 'b32-03-saved-it': 'v10-2', 'b32-04-still-valid': 'v10-2', + 'b32-05-aunt-pat': 'v10-3', 'b32-06-honor-the-gift': 'v10-3', + 'b32-07-the-hour': 'v10-4', 'b32-08-she-meant-it': 'v10-4', + 'b32-09-best-gift': 'v10-5', 'b32-10-cta-gift': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b32', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch32/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b32-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch32/${id}.mp4`, buf) + console.log(` ✓ saved batch32/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch32')) mkdirSync('public/videos/ugc/batch32', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch32/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch32/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH32 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch32-scripts.json b/scripts/batch32-scripts.json new file mode 100644 index 0000000..8d15def --- /dev/null +++ b/scripts/batch32-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 32 — 'Wedding Gift, 5 Years Later'. Couple finally using the hi2b certificate Aunt Pat gave at their wedding. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b32-01-the-gift", "angle": "Got this as a wedding gift", "hook_text": "got this as a wedding gift 5 years ago", + "tts": "We got this trip as a wedding gift five years ago. A hi2b certificate. From my aunt Pat. We slid it into a drawer with the cards. We honestly forgot about it for two years. The link is in my bio, hi2b dot com." }, + { "id": "b32-02-finally-using-it","angle": "Finally redeeming it", "hook_text": "finally using it — Aunt Pat will be thrilled", + "tts": "Finally using it. Aunt Pat is going to be so happy when she sees these photos. The certificate had a long redemption window. Five years later, here we are at the Cancun resort. Link in my bio, hi2b dot com." }, + { "id": "b32-03-saved-it", "angle": "Saved for 5-year anniversary", "hook_text": "we saved it for our 5-year anniversary", + "tts": "We saved it on purpose. Five year anniversary trip. Felt like the right milestone. Better than redeeming it the month after the wedding when we were too tired to enjoy it. Link in my bio, hi2b dot com." }, + { "id": "b32-04-still-valid", "angle": "Still valid years later", "hook_text": "still valid — they accept it years later", + "tts": "Still valid. We were nervous at check in that they would say sorry, expired. They scanned the certificate, smiled, handed us a room key. The window is long. Link in my bio, hi2b dot com." }, + { "id": "b32-05-aunt-pat", "angle": "Aunt Pat's reasoning", "hook_text": "Aunt Pat saw the receipt and said 'use this'", + "tts": "Aunt Pat told us at our wedding, you two need a real honeymoon someday and you will not be able to afford it. So she bought one. Folded the certificate into the card. She is not subtle. We love her. Link in my bio, hi2b dot com." }, + { "id": "b32-06-honor-the-gift", "angle": "Postcards from every meal", "hook_text": "we owe her a postcard from every meal", + "tts": "We owe her a postcard from every restaurant on the resort. That is the deal we made before flying out. Honor the gift. So far we have hit four meals. Six to go. Link in my bio, hi2b dot com." }, + { "id": "b32-07-the-hour", "angle": "Did the hour, took the trip", "hook_text": "did the hour, said no thanks, got our wristbands", + "tts": "Did the welcome presentation. Sixty five minutes. Said no thanks to the membership. Got our wristbands. The gift was the whole trip. We did not need anything more. Link in my bio, hi2b dot com." }, + { "id": "b32-08-she-meant-it", "angle": "Experience over object", "hook_text": "she didn't pick an object — she picked an experience", + "tts": "Aunt Pat did not pick a blender. She did not pick a vase. She picked an experience. Five years later we are still talking about it. That is what gifts are supposed to do. Link in my bio, hi2b dot com." }, + { "id": "b32-09-best-gift", "angle": "Best gift, we got a lot", "hook_text": "best wedding gift we got — and we got a lot", + "tts": "Best wedding gift we got. And we got a lot of gifts. Five years out, the only one we actively remember and use is this one. The rest are in cupboards. Link in my bio, hi2b dot com." }, + { "id": "b32-10-cta-gift", "angle": "Buy it as a gift", "hook_text": "buy it as a gift — Aunt Pat is on to something", + "tts": "Buy it as a gift for a couple you love. Wedding, anniversary, engagement, retirement. Aunt Pat is on to something. The certificate is on hi2b dot com, link in my bio. Be the aunt." } + ] +} diff --git a/scripts/batch33-multitalk-render.ts b/scripts/batch33-multitalk-render.ts new file mode 100644 index 0000000..a0f70d3 --- /dev/null +++ b/scripts/batch33-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 33 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch33-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch33/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b33-01-the-rule': 'v10-1', 'b33-02-no-buying': 'v10-1', + 'b33-03-stay-quiet': 'v10-2', 'b33-04-eye-contact': 'v10-2', + 'b33-05-his-rule': 'v10-3', 'b33-06-it-worked': 'v10-3', + 'b33-07-presenter-noticed': 'v10-4', 'b33-08-she-respected': 'v10-4', + 'b33-09-friends-rule': 'v10-5', 'b33-10-cta-pact': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b33', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch33/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b33-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch33/${id}.mp4`, buf) + console.log(` ✓ saved batch33/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch33')) mkdirSync('public/videos/ugc/batch33', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch33/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch33/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH21 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch33-scripts.json b/scripts/batch33-scripts.json new file mode 100644 index 0000000..25025e1 --- /dev/null +++ b/scripts/batch33-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 33 — 'Couple's Pact (the 60-min rule)'. Couple made rules before the presentation, executed the plan. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b33-01-the-rule", "angle": "We made a rule before", "hook_text": "we made a rule before walking into the presentation", + "tts": "We made a rule before walking into the welcome presentation. Three rules actually. Wrote them down on a napkin at the airport. And then we followed them. Sixty five minutes later we were at our room. Link in my bio, hi2b dot com." }, + { "id": "b33-02-no-buying", "angle": "Rule 1: No buying anything", "hook_text": "rule one: we are not buying ANY membership", + "tts": "Rule one. We are not buying anything. No matter what numbers come out, no matter how good the deal sounds, we are not buying the membership today. The hi2b certificate already covers this trip. We are here for the trip. Link in my bio, hi2b dot com." }, + { "id": "b33-03-stay-quiet", "angle": "Rule 2: Keep it short", "hook_text": "rule two: keep it short, don't dig into details", + "tts": "Rule two. Keep it short. Don't ask deep questions about every benefit. The longer we engage, the longer we are in the room. We had a pool to get to. Link in my bio for the certificate, hi2b dot com." }, + { "id": "b33-04-eye-contact", "angle": "Rule 3: Polite, not engaged", "hook_text": "rule three: polite, not engaged", + "tts": "Rule three. Be polite. Nod. Smile. Make eye contact. But do not lean in. Do not say wow that is interesting. Do not give the closer anything to grab. Link in my bio, hi2b dot com." }, + { "id": "b33-05-his-rule", "angle": "His secret 4th rule", "hook_text": "his secret rule: bring a snack", + "tts": "My husband added a secret fourth rule. Bring a snack. He had a granola bar in his pocket. Slid it to me at minute forty. Saved my mood. Pack a snack for your hour. Link in my bio, hi2b dot com." }, + { "id": "b33-06-it-worked", "angle": "65 minutes flat", "hook_text": "it worked — 65 minutes flat", + "tts": "It worked. Sixty five minutes flat. They walked us through everything, we said no thank you once, signed the no thank you form, got our wristbands. The plan we made on a napkin held perfectly. Link in my bio, hi2b dot com." }, + { "id": "b33-07-presenter-noticed","angle": "Presenter noticed we had a system","hook_text": "the presenter noticed we had a system", + "tts": "I think the presenter even noticed we had a system. She smiled when my husband shook his head exactly the same way to every benefit slide. She was not offended. She was kind of impressed. Link in my bio, hi2b dot com." }, + { "id": "b33-08-she-respected", "angle": "She respected the pact", "hook_text": "she actually respected our pact", + "tts": "She respected the pact. No second push. No manager call. She closed the binder, walked us to reception, wished us a good trip. The whole thing was so professional I was a little disappointed at how much I had braced for it. Link in my bio, hi2b dot com." }, + { "id": "b33-09-friends-rule", "angle": "Friends are making one too", "hook_text": "told our friends — they're making a pact too", + "tts": "We told our friends back home about the napkin pact. Now they are doing it too for their trip in August. The pact is portable. The certificate is on hi2b. Link in my bio, hi2b dot com." }, + { "id": "b33-10-cta-pact", "angle": "Make a pact, take the hour", "hook_text": "make a pact — take the hour — get the trip", + "tts": "Make a pact. Write it on a napkin. Hand it to your partner. Then take the hour, follow the rules, and get the five day trip. The hi2b certificate is in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch34-multitalk-render.ts b/scripts/batch34-multitalk-render.ts new file mode 100644 index 0000000..547da32 --- /dev/null +++ b/scripts/batch34-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 34 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch34-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch34/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b34-01-engaged-trip': 'v10-1', 'b34-02-test-travel': 'v10-1', + 'b34-03-the-hour': 'v10-2', 'b34-04-no-stress': 'v10-2', + 'b34-05-fight-style': 'v10-3', 'b34-06-pre-wedding': 'v10-3', + 'b34-07-his-input': 'v10-4', 'b34-08-the-cert-itself': 'v10-4', + 'b34-09-ring-photos': 'v10-5', 'b34-10-cta-engagement': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b34', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch34/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b34-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch34/${id}.mp4`, buf) + console.log(` ✓ saved batch34/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch34')) mkdirSync('public/videos/ugc/batch34', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch34/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch34/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH34 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch34-scripts.json b/scripts/batch34-scripts.json new file mode 100644 index 0000000..5991e97 --- /dev/null +++ b/scripts/batch34-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 34 — 'Just Engaged'. Newly engaged couple taking an engagement trip before the wedding to test traveling together. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b34-01-engaged-trip", "angle": "Engagement trip first", "hook_text": "just engaged — taking the trip BEFORE the wedding", + "tts": "We just got engaged. And we are taking our trip before the wedding, not after. Engagement trip. We want to see how we travel together before we sign the paperwork. The hi2b certificate made it affordable enough to do. Link in my bio, hi2b dot com." }, + { "id": "b34-02-test-travel", "angle": "Test how we travel", "hook_text": "test how we travel together — smart move", + "tts": "Test how we travel together before the wedding. Honestly the smartest decision we have made yet as a couple. Five days at a resort tells you a lot about a person. Link in my bio, hi2b dot com." }, + { "id": "b34-03-the-hour", "angle": "Hour was the test pass", "hook_text": "we did the hour together — relationship test passed", + "tts": "We did the welcome presentation together. He was patient. I was patient. We did not snap at each other when the slides went long. Relationship test passed at minute sixty seven. Link in my bio, hi2b dot com." }, + { "id": "b34-04-no-stress", "angle": "Watched him handle pressure", "hook_text": "I watched how he handled the no thank you", + "tts": "I watched how my fiance handled the no thank you. He was calm, polite, clear, did not get apologetic. Good signal. That is the energy I want at our wedding when his uncle starts a story. Link in my bio, hi2b dot com." }, + { "id": "b34-05-fight-style", "angle": "Survive marriage prep", "hook_text": "if we survive 90 min of sales, we survive marriage", + "tts": "If you can survive ninety minutes of a sales presentation as a couple without snipping at each other, you can survive marriage. We treated it as a stress test. We passed. Link in my bio, hi2b dot com." }, + { "id": "b34-06-pre-wedding", "angle": "Budget-tight pre-wedding cert","hook_text": "pre-wedding trip on a budget = hi2b cert", + "tts": "Pre-wedding life is expensive. Venue deposits, photographer deposits, dress. The hi2b certificate is two forty nine total for the trip. The cheapest week away we will get for a while. Link in my bio, hi2b dot com." }, + { "id": "b34-07-his-input", "angle": "Control sharing practice", "hook_text": "letting him plan some — control sharing practice", + "tts": "I am letting him plan some of the activities. Practice in sharing control. He picked the snorkeling day. I picked the lazy pool day. We figured out our split, and it is going to keep working after the wedding. Link in my bio, hi2b dot com." }, + { "id": "b34-08-the-cert-itself", "angle": "$249 covers both", "hook_text": "$249 for both of us — pre-wedding budget hero", + "tts": "Two hundred forty nine dollars covered both of us. Five days, four nights, all-inclusive. The pre-wedding budget hero. Saved enough to upgrade the wedding flowers. Link in my bio, hi2b dot com." }, + { "id": "b34-09-ring-photos", "angle": "Ring photos on the beach", "hook_text": "got ring photos on the beach — caption ready", + "tts": "Got beach photos with the ring. The caption is already written in my head. Save the date is going to hit different with these. Link in my bio, hi2b dot com." }, + { "id": "b34-10-cta-engagement", "angle": "Engaged friends do this", "hook_text": "engaged friends — do this before the wedding", + "tts": "Engaged friends. Do the engagement trip before the wedding. Test how you travel. Save the money on the honeymoon if you want. The hi2b certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch35-multitalk-render.ts b/scripts/batch35-multitalk-render.ts new file mode 100644 index 0000000..af8d909 --- /dev/null +++ b/scripts/batch35-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 35 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch35-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch35/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b35-01-i-bought-it': 'v10-1', 'b35-02-his-face': 'v10-1', + 'b35-03-flipping-script': 'v10-2', 'b35-04-prep-the-hour': 'v10-2', + 'b35-05-led-the-no': 'v10-3', 'b35-06-wife-mode': 'v10-3', + 'b35-07-husband-grateful': 'v10-4', 'b35-08-women-do-this': 'v10-4', + 'b35-09-affordable': 'v10-5', 'b35-10-cta-wife': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b35', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch35/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b35-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch35/${id}.mp4`, buf) + console.log(` ✓ saved batch35/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch35')) mkdirSync('public/videos/ugc/batch35', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch35/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch35/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH35 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch35-scripts.json b/scripts/batch35-scripts.json new file mode 100644 index 0000000..eab25eb --- /dev/null +++ b/scripts/batch35-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 35 — 'Wife Surprised Husband'. Gender-flipped surprise trip: wife bought the certificate, prepped the hour, husband along for the ride. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b35-01-i-bought-it", "angle": "I bought it, he had no idea", "hook_text": "I bought this — he had no idea until the airport", + "tts": "I bought this. He had absolutely no idea until we got to the airport. Three weeks of secret browsing, one Friday morning reveal. I am sitting at the resort with him right now and he is still in shock. Link in my bio, hi2b dot com." }, + { "id": "b35-02-his-face", "angle": "His face at the boarding pass", "hook_text": "his face when I handed him the boarding pass", + "tts": "His face when I handed him the boarding pass at the curb. Pure confusion, then huge smile. He kept asking, you did this. You did this. Yes I did this. Five days, Cancun, certificate, hour presentation, all included. Link in my bio, hi2b dot com." }, + { "id": "b35-03-flipping-script", "angle": "Flipped the planning script", "hook_text": "we flipped the script — usually he plans the trips", + "tts": "We flipped the script. He usually plans our trips. I picked this one. I researched it. I bought it. I made the packing list. He just walked through doors I held open. Different vibe. Link in my bio, hi2b dot com." }, + { "id": "b35-04-prep-the-hour", "angle": "I prepped the hour solo", "hook_text": "I prepped for the hour solo — read 30 reviews", + "tts": "I prepped for the welcome presentation solo. Read thirty reviews. Watched a couple of TikToks. Knew exactly what to expect. Sixty to ninety minutes, optional membership pitch, polite no. I went in armed. Link in my bio, hi2b dot com." }, + { "id": "b35-05-led-the-no", "angle": "I said no thanks first", "hook_text": "I said the no thanks before he could — his face", + "tts": "When the membership question came, I said no thank you before he had time to think. He looked at me with these wide eyes like, who is this woman. Take charge mode. Link in my bio, hi2b dot com." }, + { "id": "b35-06-wife-mode", "angle": "Take-charge energy unlocked", "hook_text": "take-charge wife energy unlocked", + "tts": "Take charge wife energy. Apparently unlocked. We have been married for nine years and this is the first big thing I have ever surprised him with that involved travel and money. He likes it. I might do this more often. Link in my bio, hi2b dot com." }, + { "id": "b35-07-husband-grateful","angle": "Constant thank-yous", "hook_text": "he keeps saying thank you — he hasn't said that in months", + "tts": "He keeps saying thank you. Like, really saying it. He has not said that to me in this tone of voice in months. The trip is making him soft in a good way. Link in my bio, hi2b dot com." }, + { "id": "b35-08-women-do-this", "angle": "Women — surprise your husbands","hook_text": "women — surprise your husbands with this", + "tts": "Women. Surprise your husbands with this. They surprise us with flowers and earrings. We can surprise them with a trip. The hi2b certificate is the cheapest way to be a hero in your marriage this year. Link in my bio, hi2b dot com." }, + { "id": "b35-09-affordable", "angle": "$249 for both, easy", "hook_text": "$249 for both — it's not even expensive", + "tts": "Two forty nine for both of us. It is not even expensive. Less than a nice date night per person. You can put it on a credit card today and surprise him on Friday. Link in my bio, hi2b dot com." }, + { "id": "b35-10-cta-wife", "angle": "Take the lead", "hook_text": "take the lead on the trip — he'll be relieved", + "tts": "Take the lead on the trip. He will not be hurt that you planned it. He will be relieved. Get the certificate, do the hour together, take the trip. Link in my bio, hi2b dot com. Go." } + ] +} diff --git a/scripts/batch36-multitalk-render.ts b/scripts/batch36-multitalk-render.ts new file mode 100644 index 0000000..8648439 --- /dev/null +++ b/scripts/batch36-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 36 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch36-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch36/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b36-01-renewing-vows': 'v10-1', 'b36-02-quiet-ceremony': 'v10-1', + 'b36-03-better-second': 'v10-2', 'b36-04-did-the-hour': 'v10-2', + 'b36-05-cheaper-than': 'v10-3', 'b36-06-felt-the-same': 'v10-3', + 'b36-07-the-vows': 'v10-4', 'b36-08-staff-helped': 'v10-4', + 'b36-09-do-it': 'v10-5', 'b36-10-cta-renew': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b36', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch36/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b36-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch36/${id}.mp4`, buf) + console.log(` ✓ saved batch36/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch36')) mkdirSync('public/videos/ugc/batch36', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch36/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch36/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH36 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch36-scripts.json b/scripts/batch36-scripts.json new file mode 100644 index 0000000..f3bc3b7 --- /dev/null +++ b/scripts/batch36-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 36 — 'Vow Renewal'. 20-year couple back for a beach vow renewal at the resort. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b36-01-renewing-vows", "angle": "20 years, renewing vows", "hook_text": "20 years married — renewing vows here on the beach", + "tts": "Twenty years married. We came back to Mexico to renew our vows on the beach. Used a hi2b certificate. The resort had a vow renewal package. The certificate covered our stay. Most affordable second wedding day we could have asked for. Link in my bio, hi2b dot com." }, + { "id": "b36-02-quiet-ceremony", "angle": "Just us, beach, officiant", "hook_text": "just us, the beach, the resort officiant", + "tts": "Just us. The beach. The resort officiant. No drama, no seating chart, no aunt fighting with cousin. The first wedding had a hundred and forty people. This one had three. Way better. Link in my bio, hi2b dot com." }, + { "id": "b36-03-better-second", "angle": "Better than original", "hook_text": "better than the original wedding — no parents fighting", + "tts": "Honestly better than the original wedding. We were too young to enjoy the first one. Now we are present. We mean every word. Nobody is fighting in the parking lot. Link in my bio, hi2b dot com." }, + { "id": "b36-04-did-the-hour", "angle": "Hour first, no membership", "hook_text": "yes we did the hour, no we didn't buy the membership", + "tts": "Yes we did the welcome presentation. Sixty five minutes. No we did not buy the membership. We just wanted the trip. Got the wristbands, then went to the planner desk to book the renewal ceremony. Link in my bio, hi2b dot com." }, + { "id": "b36-05-cheaper-than", "angle": "Cheaper than honeymoon", "hook_text": "cheaper than the original honeymoon", + "tts": "Adjusted for inflation, this whole trip cost about a third of what our original honeymoon cost. Cheaper to renew the marriage than to start it. Link in my bio, hi2b dot com." }, + { "id": "b36-06-felt-the-same", "angle": "He looked at me the same", "hook_text": "he looked at me the same way", + "tts": "Standing on the beach as we said the new vows. He looked at me the exact same way he looked at me twenty years ago. Same expression. Same eyes. The years just added weight to it. Link in my bio, hi2b dot com." }, + { "id": "b36-07-the-vows", "angle": "Wrote new vows on the plane", "hook_text": "we wrote new vows on the plane", + "tts": "We wrote the new vows on the plane down here. Used the flight attendants napkins. They actually came out really good. Twenty years of receipts and memories distilled into eight sentences each. Link in my bio, hi2b dot com." }, + { "id": "b36-08-staff-helped", "angle": "Staff treated it as real", "hook_text": "the resort staff treated it like a real ceremony", + "tts": "The resort staff did not phone this in. They brought flowers. They set up a real arch. They played the music we asked for. The bartender cried a little. It was a real ceremony. Link in my bio, hi2b dot com." }, + { "id": "b36-09-do-it", "angle": "Married couples — book it", "hook_text": "married couples — book the vow renewal trip", + "tts": "Married couples. Book the vow renewal trip. You do not need to wait for the twenty fifth or the fiftieth. Pick any year. The certificate is two forty nine. The renewal package is extra but cheap. Link in my bio, hi2b dot com." }, + { "id": "b36-10-cta-renew", "angle": "20 years deserves a redo", "hook_text": "20 years deserves a redo — link in bio", + "tts": "Twenty years deserves a redo. So does twelve. So does five. So does next year. Get the certificate, book the ceremony, write the new vows. The link is in my bio, hi2b dot com. Marry them again." } + ] +} diff --git a/scripts/batch37-multitalk-render.ts b/scripts/batch37-multitalk-render.ts new file mode 100644 index 0000000..1c565e3 --- /dev/null +++ b/scripts/batch37-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 37 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch37-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch37/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b37-01-first-trip': 'v10-1', 'b37-02-grandma': 'v10-1', + 'b37-03-the-hour': 'v10-2', 'b37-04-said-no': 'v10-2', + 'b37-05-relearning': 'v10-3', 'b37-06-dinner-together': 'v10-3', + 'b37-07-cried-on-beach': 'v10-4', 'b37-08-needed-this': 'v10-4', + 'b37-09-new-parents': 'v10-5', 'b37-10-cta-baby': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b37', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch37/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b37-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch37/${id}.mp4`, buf) + console.log(` ✓ saved batch37/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch37')) mkdirSync('public/videos/ugc/batch37', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch37/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch37/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH37 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch37-scripts.json b/scripts/batch37-scripts.json new file mode 100644 index 0000000..b7ab89d --- /dev/null +++ b/scripts/batch37-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 37 — 'First Trip After Baby'. New parents who left infant with grandma, relearning how to be a couple. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b37-01-first-trip", "angle": "First trip without the baby", "hook_text": "first trip without the baby — we needed this", + "tts": "First trip without the baby. He is eight months old. We have not been on a plane just the two of us since I was seven months pregnant. We needed this. We bought the hi2b certificate three weeks ago and stared at it on the fridge. Link in my bio, hi2b dot com." }, + { "id": "b37-02-grandma", "angle": "Grandma has the baby", "hook_text": "Grandma has the baby — he's fine — I keep checking", + "tts": "Grandma has the baby. He is fine. He is more than fine. He is in a baby spa for five days. I still check my phone every twenty minutes for updates. She finally turned on auto reply that just says he is fine. Link in my bio, hi2b dot com." }, + { "id": "b37-03-the-hour", "angle": "First quiet hour in 8 months", "hook_text": "the welcome hour — first quiet hour in 8 months", + "tts": "We did the welcome presentation. Sixty seven minutes. It was the first quiet hour either of us has had in eight months. I almost fell asleep at minute thirty. In the best way. Link in my bio, hi2b dot com." }, + { "id": "b37-04-said-no", "angle": "Postpartum mom said no", "hook_text": "said no to the membership, mid-cry, postpartum mom", + "tts": "I said no thank you to the membership while crying a little. Postpartum hormones plus first time alone with my husband in eight months. The presenter was very sweet about it. Said no thanks back. Wristbands. Link in my bio, hi2b dot com." }, + { "id": "b37-05-relearning", "angle": "Relearning to be a couple", "hook_text": "relearning how to be a couple, not just parents", + "tts": "We are relearning how to be a couple. Not co parents. A couple. We have not really had a conversation that did not include the baby in eight months. The resort gave us permission to talk about other things. Link in my bio, hi2b dot com." }, + { "id": "b37-06-dinner-together", "angle": "First quiet dinner", "hook_text": "first dinner together — no high chair, no spit-up", + "tts": "We ate dinner together last night. No high chair. No spit up. No bouncing him on a knee while the food got cold. Just us. Across the table. Talking. Eating. Looking each other in the eyes. Link in my bio, hi2b dot com." }, + { "id": "b37-07-cried-on-beach", "angle": "Cried on the beach", "hook_text": "I cried on the beach — happy cry — he cried too", + "tts": "I cried on the beach yesterday. Happy cry. The good kind. I love the baby. I missed the baby. And I also needed this. He cried too, a little. We do not tell anyone he cried. Link in my bio, hi2b dot com." }, + { "id": "b37-08-needed-this", "angle": "Marriage needed this", "hook_text": "we needed this — our marriage needed this", + "tts": "We needed this. Our marriage needed this. New parent marriage is hard. You forget the person you fell in love with because you are both running on three hours of sleep. Five days of remembering is not a luxury. It is maintenance. Link in my bio, hi2b dot com." }, + { "id": "b37-09-new-parents", "angle": "Book before you forget you exist","hook_text": "new parents — book this BEFORE you forget you exist", + "tts": "New parents. Book the trip before you forget you exist as a separate person. Not at six months. Not at a year. As soon as someone can hold the baby. The hi2b certificate is the cheapest way to do this. Link in my bio, hi2b dot com." }, + { "id": "b37-10-cta-baby", "angle": "Baby will be fine", "hook_text": "take the trip — the baby will be fine", + "tts": "Take the trip. The baby will be fine. You will not be fine if you do not. Five days, four nights, two adults, the certificate is two forty nine, link is in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch38-multitalk-render.ts b/scripts/batch38-multitalk-render.ts new file mode 100644 index 0000000..e10b7d5 --- /dev/null +++ b/scripts/batch38-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 38 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch38-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch38/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b38-01-almost-didnt': 'v10-1', 'b38-02-busy-life': 'v10-1', + 'b38-03-pushed-through': 'v10-2', 'b38-04-hour-was-nothing': 'v10-2', + 'b38-05-best-decision': 'v10-3', 'b38-06-friend-canceled': 'v10-3', + 'b38-07-they-regret': 'v10-4', 'b38-08-just-do-it': 'v10-4', + 'b38-09-249-bucks': 'v10-5', 'b38-10-cta-pull-trigger': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b38', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch38/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b38-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch38/${id}.mp4`, buf) + console.log(` ✓ saved batch38/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch38')) mkdirSync('public/videos/ugc/batch38', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch38/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch38/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH38 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch38-scripts.json b/scripts/batch38-scripts.json new file mode 100644 index 0000000..69a02d2 --- /dev/null +++ b/scripts/batch38-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 38 — 'Almost Didn't Book'. Couple who nearly cancelled the trip 3 times, friends who did cancel now pool-envying. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b38-01-almost-didnt", "angle": "Almost canceled 3 times", "hook_text": "we almost canceled this trip 3 times", + "tts": "We almost canceled this trip three different times. Three. Once for a work deadline, once because the weather looked dicey, once because I got nervous about leaving the house. We did not cancel. We are at the resort. Link in my bio, hi2b dot com." }, + { "id": "b38-02-busy-life", "angle": "Life kept getting in the way", "hook_text": "life kept getting in the way — work, schedules", + "tts": "Life kept getting in the way. Work calendars, kid schedules, his mom needing help, the AC breaking. There was always a reason. We almost let the reasons win. Then we did not. Link in my bio, hi2b dot com." }, + { "id": "b38-03-pushed-through", "angle": "Pushed through and booked", "hook_text": "we pushed through and booked the flights", + "tts": "We pushed through. Booked the flights on a Tuesday at midnight before either of us could chicken out. Paid for parking at the airport. Showed up. Got on the plane. Easiest hard decision we have made this year. Link in my bio, hi2b dot com." }, + { "id": "b38-04-hour-was-nothing","angle": "The hour we feared was nothing", "hook_text": "the hour we feared was nothing", + "tts": "The hour we feared, the presentation everyone warned us about, was nothing. Sixty four minutes. Coffee. A polite no thank you. A handshake. Wristbands. The dread was so much bigger than the actual thing. Link in my bio, hi2b dot com." }, + { "id": "b38-05-best-decision", "angle": "Best decision in months", "hook_text": "best decision we've made in months", + "tts": "Best decision we have made in months. Sitting at the swim up bar at three in the afternoon on a Tuesday. Both of us would have stayed home, miserable, productive, and miserable. Link in my bio, hi2b dot com." }, + { "id": "b38-06-friend-canceled", "angle": "Our friends canceled theirs", "hook_text": "our friends canceled their version of this trip", + "tts": "Our best friend couple canceled their version of this trip. Different reasons but same pattern. They are at home. We are here. They had the same certificate. They let life win. Link in my bio, hi2b dot com." }, + { "id": "b38-07-they-regret", "angle": "They're texting pool envy", "hook_text": "they're texting us pool envy now", + "tts": "Their texts are heart eyes and pool envy. Why did we not come. Should have come. Send us a beach photo. We are sending them every meal. Lovingly. They are going to book a new one when we get home. Link in my bio, hi2b dot com." }, + { "id": "b38-08-just-do-it", "angle": "On the fence — just do it", "hook_text": "if you're on the fence — just do it", + "tts": "If you are on the fence about this, just do it. The reasons you have not booked yet are the same reasons we almost did not. They will still be there when you get back. The water will not. Link in my bio, hi2b dot com." }, + { "id": "b38-09-249-bucks", "angle": "$249, stop overthinking", "hook_text": "$249 — stop overthinking it", + "tts": "Two hundred forty nine dollars total for both of us. Stop overthinking it. You spend more on a single grocery run. Just buy the certificate. Decide the destination later. Link in my bio, hi2b dot com." }, + { "id": "b38-10-cta-pull-trigger","angle": "Pull the trigger", "hook_text": "pull the trigger — take the trip", + "tts": "Pull the trigger. Take the trip. Do the hour. The reasons you have for waiting will be the same reasons you regret waiting. The link is in my bio, hi2b dot com. Book it tonight." } + ] +} diff --git a/scripts/batch39-multitalk-render.ts b/scripts/batch39-multitalk-render.ts new file mode 100644 index 0000000..b77a8bc --- /dev/null +++ b/scripts/batch39-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 39 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch39-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch39/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b39-01-they-came': 'v10-1', 'b39-02-2-rooms-1-resort': 'v10-1', + 'b39-03-his-mom-loved-it': 'v10-2', 'b39-04-the-hour-her-mom': 'v10-2', + 'b39-05-mom-bargaining': 'v10-3', 'b39-06-dad-on-pool-bar': 'v10-3', + 'b39-07-trip-finally-together': 'v10-4', 'b39-08-low-effort-coordination': 'v10-4', + 'b39-09-gen-x-loved-this': 'v10-5', 'b39-10-cta-parents': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b39', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch39/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b39-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch39/${id}.mp4`, buf) + console.log(` ✓ saved batch39/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch39')) mkdirSync('public/videos/ugc/batch39', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch39/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch39/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH39 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch39-scripts.json b/scripts/batch39-scripts.json new file mode 100644 index 0000000..d554a51 --- /dev/null +++ b/scripts/batch39-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 39 — 'Brought Our Parents'. Couple's parents got their own certificate too, came along as a side party. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b39-01-they-came", "angle": "We brought our parents", "hook_text": "we brought our parents — they got their own cert", + "tts": "We brought our parents on this trip. They got their own hi2b certificate. Four adults, two certificates, same resort. Five days of all-inclusive paradise for everyone. Link in my bio, hi2b dot com." }, + { "id": "b39-02-2-rooms-1-resort","angle": "2 rooms, 1 resort, 2 vibes", "hook_text": "two certificates, two rooms, same resort, different vibes", + "tts": "Two certificates. Two rooms. Same resort. Different vibes. We have the romantic young couple energy on one side, they have the retired-and-thriving energy on the other. We meet in the middle for dinner. Link in my bio, hi2b dot com." }, + { "id": "b39-03-his-mom-loved-it","angle": "His mom said oh", "hook_text": "his mom literally went 'oh' when she walked in", + "tts": "His mom literally said oh when she walked into the lobby. The expressive oh. The good oh. The oh you make when something is nicer than your expectations. Three days in she has been saying it about every meal. Link in my bio, hi2b dot com." }, + { "id": "b39-04-the-hour-her-mom","angle": "Her parents did the hour", "hook_text": "her parents did the hour without us — handled it", + "tts": "Her mom and dad did the welcome hour without us. Handled it perfectly. They are sixty seven and seventy one and they were polite, said no thanks once, got their wristbands. They sent us a thumbs up text from the hallway. Link in my bio, hi2b dot com." }, + { "id": "b39-05-mom-bargaining", "angle": "Mom tried to bargain", "hook_text": "her mom tried to bargain — the presenter laughed", + "tts": "Her mom tried to bargain with the presenter. Out of habit. The presenter laughed politely and said the price is the price. Mom said okay no problem, took the wristband, kept walking. That is sixty seven year old energy. Link in my bio, hi2b dot com." }, + { "id": "b39-06-dad-on-pool-bar", "angle": "Dad lives at the bar", "hook_text": "my dad has lived at the swim-up bar for 3 days", + "tts": "My dad has lived at the swim up bar for three straight days. He is making friends with the bartender. He has a regular order. He is sixty four years old and he just discovered that included drinks means included drinks. Link in my bio, hi2b dot com." }, + { "id": "b39-07-trip-finally-together","angle": "First 4-adult trip ever", "hook_text": "first trip the four of us have done together", + "tts": "This is the first trip the four of us have done together as adults. The two certificates were the unlock. Two forty nine per couple is not a hard ask to coordinate. Way cheaper than a family wedding. Link in my bio, hi2b dot com." }, + { "id": "b39-08-low-effort-coordination","angle": "Low coordination, sync at sunset","hook_text": "low coordination — eat separately, sync at sunset", + "tts": "Low coordination is the trick. We eat breakfast separately. They go to the spa. We swim. We text at four. We meet at sunset for dinner. Nobody is babysitting anybody. It works. Link in my bio, hi2b dot com." }, + { "id": "b39-09-gen-x-loved-this","angle": "Gen-X loved it more than us", "hook_text": "Gen-X parents loved it more than we did", + "tts": "Honestly, the Gen X parents loved it more than we did. They came from the era of expensive package trips. Two forty nine is wild to them. They are going to use the certificate again next year and bring their friends. Link in my bio, hi2b dot com." }, + { "id": "b39-10-cta-parents", "angle": "Bring your parents", "hook_text": "bring your parents — buy them their own cert", + "tts": "Bring your parents on the trip. Buy them their own certificate. Coordinate one travel day. Then go your separate ways at the resort. Best multi generational trip you can pull off for under five hundred dollars total. Link in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch40-multitalk-render.ts b/scripts/batch40-multitalk-render.ts new file mode 100644 index 0000000..d389fc0 --- /dev/null +++ b/scripts/batch40-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 40 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch40-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch40/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b40-01-he-proposed-here': 'v10-1', 'b40-02-same-spot': 'v10-1', + 'b40-03-cheaper-second': 'v10-2', 'b40-04-staff-remembered': 'v10-2', + 'b40-05-the-hour-recap': 'v10-3', 'b40-06-now-engaged': 'v10-3', + 'b40-07-wedding-soon': 'v10-4', 'b40-08-walked-by-spot': 'v10-4', + 'b40-09-pre-wedding-quiet': 'v10-5', 'b40-10-cta-engagement-place': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b40', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch40/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b40-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch40/${id}.mp4`, buf) + console.log(` ✓ saved batch40/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch40')) mkdirSync('public/videos/ugc/batch40', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch40/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch40/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH40 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch40-scripts.json b/scripts/batch40-scripts.json new file mode 100644 index 0000000..9ba5727 --- /dev/null +++ b/scripts/batch40-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 40 — 'He Proposed Here Last Year'. Couple returning to the engagement spot for one last quiet pre-wedding trip. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b40-01-he-proposed-here", "angle": "Engagement-anniversary trip", "hook_text": "he proposed at this resort last year — we're back", + "tts": "He proposed at this resort last year. Right on this beach. We came back to celebrate the engagement one year out, before the wedding wrecks the calendar. Used a hi2b certificate. Same trip, totally different feeling. Link in my bio, hi2b dot com." }, + { "id": "b40-02-same-spot", "angle": "Same beach, different feeling", "hook_text": "same beach, same sunset, different feeling now", + "tts": "Same beach. Same sunset. Different feeling now. Last year I was a girlfriend trying to figure out if he was going to do it. This year I am his fiancée and I know how the story turned out. Link in my bio, hi2b dot com." }, + { "id": "b40-03-cheaper-second", "angle": "Cheaper to come back", "hook_text": "cheaper to come back than fly anywhere new", + "tts": "Way cheaper to come back here on a certificate than to fly to a new destination. Two forty nine. We know the resort. We know the staff. The activation cost of a brand new place is real and we did not have it this year. Link in my bio, hi2b dot com." }, + { "id": "b40-04-staff-remembered", "angle": "Staff remembered, sent champagne","hook_text": "the staff remembered us — sent champagne to the room", + "tts": "The staff actually remembered us. The hostess who was there the night he proposed. The bartender who served us the celebration drinks. They sent champagne to the room with a card. Link in my bio, hi2b dot com." }, + { "id": "b40-05-the-hour-recap", "angle": "Did the hour again", "hook_text": "did the welcome hour again — same calm vibe", + "tts": "Did the welcome presentation again. Same calm vibe as last year. Different presenter. Same script roughly. We said no thanks again. Took less than fifty minutes this time. Repeat customer discount on time spent. Link in my bio, hi2b dot com." }, + { "id": "b40-06-now-engaged", "angle": "Girlfriend last year, fiancée now","hook_text": "last year a girlfriend — today a fiancée", + "tts": "Last year I came as a girlfriend nervous about a ring. Today as a fiancée nervous about a guest list. Different problems. Better problems. Both made me cry on this beach. Link in my bio, hi2b dot com." }, + { "id": "b40-07-wedding-soon", "angle": "Wedding in 4 months", "hook_text": "wedding is in 4 months — one more us weekend", + "tts": "Wedding is in four months. We came back here for one more us only weekend before everyone else gets a say in our schedule. Just us. Just the beach. Link in my bio, hi2b dot com." }, + { "id": "b40-08-walked-by-spot", "angle": "Walked by the proposal spot", "hook_text": "walked by the spot — cried a little", + "tts": "Walked by the exact spot last night. Cried a little. He cried a little. It is a stretch of sand by a coconut tree. Means nothing to anyone else. Means everything to us. Link in my bio, hi2b dot com." }, + { "id": "b40-09-pre-wedding-quiet", "angle": "Last quiet trip before everything","hook_text": "last quiet trip before the wedding chaos", + "tts": "This is the last quiet trip before the wedding chaos hits. The seating chart, the food tasting, the rehearsal dinner. Five days of nothing. We are storing up calm. Link in my bio, hi2b dot com." }, + { "id": "b40-10-cta-engagement-place","angle": "Propose here / come back", "hook_text": "boyfriends — propose here. couples — come back.", + "tts": "Boyfriends. Propose here. The setting is unbeatable for the cost. Couples that got engaged here. Come back. The certificate makes it cheap. The resort makes it sentimental. Link in my bio, hi2b dot com." } + ] +} diff --git a/scripts/batch41-multitalk-render.ts b/scripts/batch41-multitalk-render.ts new file mode 100644 index 0000000..cb4ce41 --- /dev/null +++ b/scripts/batch41-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 41 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch41-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch41/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b41-01-bought-3': 'v10-1', 'b41-02-using-one-now': 'v10-1', + 'b41-03-bulk-makes-sense': 'v10-2', 'b41-04-friends-grateful': 'v10-2', + 'b41-05-did-the-hour-here': 'v10-3', 'b41-06-they-cant-back-out': 'v10-3', + 'b41-07-best-friend-gift': 'v10-4', 'b41-08-vacation-coordinator': 'v10-4', + 'b41-09-cheaper-than-flowers': 'v10-5', 'b41-10-cta-bulk': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b41', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch41/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b41-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch41/${id}.mp4`, buf) + console.log(` ✓ saved batch41/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch41')) mkdirSync('public/videos/ugc/batch41', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch41/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch41/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH41 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch41-scripts.json b/scripts/batch41-scripts.json new file mode 100644 index 0000000..2a365ff --- /dev/null +++ b/scripts/batch41-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 41 — 'Bought 3 Certificates'. Couple bulk-bought, gifted 2 to friends, using 1 themselves. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b41-01-bought-3", "angle": "Bought 3 at once", "hook_text": "we bought 3 of these certificates at once", + "tts": "We bought three of these certificates at once. Yes, three. Two forty nine times three is seven forty seven. Less than a single hotel night for two people in most cities. We were impatient. Link in my bio, hi2b dot com." }, + { "id": "b41-02-using-one-now", "angle": "Using one, gifted two", "hook_text": "using one now, gifted 2 to friend couples", + "tts": "We are using one ourselves right now. Gifted the other two to our closest friend couples. Birthday gift to one, anniversary gift to the other. They both said it was the most thoughtful thing we have ever done. Link in my bio, hi2b dot com." }, + { "id": "b41-03-bulk-makes-sense", "angle": "Bulk = commitment", "hook_text": "when you bulk buy, you actually go", + "tts": "When you bulk buy something this cheap, you actually go. The sunk cost of three certificates means you book the dates. We are here as proof. Link in my bio, hi2b dot com." }, + { "id": "b41-04-friends-grateful", "angle": "Both friend couples cried", "hook_text": "both friend couples cried when we handed them the cert", + "tts": "Both friend couples cried when we handed them the certificate. They are not crying types. We did not expect that reaction. Apparently nobody just gives you a vacation as a gift anymore. Link in my bio, hi2b dot com." }, + { "id": "b41-05-did-the-hour-here","angle": "Did the hour, did the trip", "hook_text": "we're at the resort — did the hour, did the trip", + "tts": "We are at the resort. Did the welcome hour. Said no thanks to the membership. Got the wristbands. Same easy process every time. Three for the price of one nice dinner. Link in my bio, hi2b dot com." }, + { "id": "b41-06-they-cant-back-out","angle": "Friends can't back out", "hook_text": "they have to use them now — we already paid", + "tts": "Our friends have to use them now. We already paid. No backing out possible. We sent the certificates with their booking calendars. Pressure works. Link in my bio, hi2b dot com." }, + { "id": "b41-07-best-friend-gift", "angle": "Best gift in years", "hook_text": "best friend gift idea in years", + "tts": "This is the best friend gift idea we have had in years. Better than a gift card. Better than a candle. Better than a bottle of wine. You are giving them a trip they would not have booked for themselves. Link in my bio, hi2b dot com." }, + { "id": "b41-08-vacation-coordinator","angle": "Now the friend group planner","hook_text": "we're now the vacation coordinator in our friend group", + "tts": "We are now the vacation coordinator in our friend group. Apparently you do one thing right and people put you in charge forever. Already planning a synchronized week where all three couples are at the same resort. Link in my bio, hi2b dot com." }, + { "id": "b41-09-cheaper-than-flowers","angle": "Cheaper than flowers", "hook_text": "$249 per cert — cheaper than wedding flowers", + "tts": "Two forty nine per certificate. Cheaper than the flowers we sent to a friend's wedding last month. Way more memorable. People forget who sent flowers. Nobody forgets who sent them to Mexico. Link in my bio, hi2b dot com." }, + { "id": "b41-10-cta-bulk", "angle": "Buy 3, gift 2, take 1", "hook_text": "buy 3 — gift 2 — take 1 — be the friend", + "tts": "Buy three. Gift two. Take one. Be the friend who actually makes things happen for people. The certificate is on hi2b dot com, link in my bio. Stop sending candles." } + ] +} diff --git a/scripts/batch42-multitalk-render.ts b/scripts/batch42-multitalk-render.ts new file mode 100644 index 0000000..eee0847 --- /dev/null +++ b/scripts/batch42-multitalk-render.ts @@ -0,0 +1,158 @@ +/** + * Batch 42 — "What Happens In That Hour" couple narrating presentation walkthrough. + * Woman speaking, husband listening. MultiTalk on Instance 2. + * + * npx tsx scripts/batch42-multitalk-render.ts + * + * Reuses v10-1..5 couple keyframes (same young honeymoon couple as b16). + * Woman is foreground selfie-taker → -mask-w. audio_1 (Sarah) → woman, audio_2 (silence) → man, mask_3 = bg. + * Output: public/videos/ugc/batch42/.mp4 + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const SSH_PORT = 43312 +const SSH_HOST = 'root@51.83.197.242' +const TUNNEL = 18892 +const HOST = `http://localhost:${TUNNEL}` +const FPS = 25, FRAME_WINDOW = 81 +const SILENCE = 'silence-33s.mp3' + +const KF: Record = { + 'b42-01-i-hate-travel': 'v10-1', 'b42-02-the-stress': 'v10-1', + 'b42-03-this-was-different': 'v10-2', 'b42-04-no-planning': 'v10-2', + 'b42-05-hour-was-fine': 'v10-3', 'b42-06-husband-shocked': 'v10-3', + 'b42-07-introvert-resort': 'v10-4', 'b42-08-room-to-room': 'v10-4', + 'b42-09-might-do-again': 'v10-5', 'b42-10-cta-travel-haters': 'v10-5', +} +const SCRIPTS = Object.keys(KF) + +const POS_PROMPT = + 'A relaxed young couple in their late twenties at a luxury Mexican beach resort just after the welcome presentation, each holding a tropical drink. The woman in the foreground talks warmly and openly to the camera, walking the viewer through what just happened, while her husband stands beside her nodding and smiling. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and resort lobby softly blurred behind them. Warm tropical light.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(stem: string, audio: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: `${stem}-frame.jpg` } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '9': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-w.png` } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-m.png` } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: `${stem}-mask-bg.png` } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: numFrames, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b42', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { sh(`ssh -o StrictHostKeyChecking=no -p ${SSH_PORT} -N -f -L ${TUNNEL}:localhost:18188 ${SSH_HOST}`); sh('sleep 4') } +} + +async function renderOne(id: string): Promise { + const stem = KF[id] + const audio = `${id}.mp3` + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch42/${audio}`).trim()) + const numFrames = Math.round(durSec * FPS) + const wf = buildWorkflow(stem, audio, numFrames) + console.log(`\n=== ${id} (${stem}, ${durSec.toFixed(1)}s, ${numFrames}f) ===`) + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b42-${id}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + for (let i = 0; i < 1200; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 24 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + writeFileSync(`public/videos/ugc/batch42/${id}.mp4`, buf) + console.log(` ✓ saved batch42/${id}.mp4 (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${id}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch42')) mkdirSync('public/videos/ugc/batch42', { recursive: true }) + console.log('uploading inputs to Instance 2...') + const masks = sh('ls public/audio/fish/couple-masks/v10-*-frame.jpg public/audio/fish/couple-masks/v10-*-mask-*.png public/audio/fish/couple-masks/silence-33s.mp3').trim().split('\n').join(' ') + const audios = SCRIPTS.map(id => `public/audio/fish/batch42/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${SSH_PORT} ${masks} ${audios} ${SSH_HOST}:/workspace/ComfyUI/input/`) + console.log('inputs uploaded') + for (const id of SCRIPTS) { + if (existsSync(`public/videos/ugc/batch42/${id}.mp4`)) { console.log(`${id} done, skip`); continue } + await renderOne(id) + } + console.log('\nBATCH42 MULTITALK COMPLETE') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch42-scripts.json b/scripts/batch42-scripts.json new file mode 100644 index 0000000..4c2b361 --- /dev/null +++ b/scripts/batch42-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 42 — 'I Hate Travel But...'. Anti-travel wife angle; this trip uniquely worked because of all-inclusive simplicity. 5 keyframes v10-1..5 (reused). Sarah voice (woman speaking). MultiTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b42-01-i-hate-travel", "angle": "I hate travel, this worked", "hook_text": "I hate traveling — and this trip actually worked for me", + "tts": "I hate traveling. Hate the airports. Hate the planning. Hate making decisions on no sleep in a foreign country. This trip actually worked for me. Sitting at the resort right now with my husband, calm, hi2b certificate paid for it all. Link in my bio, hi2b dot com." }, + { "id": "b42-02-the-stress", "angle": "Normally stressed the whole way", "hook_text": "normally I'm stressed the whole flight, the whole airport", + "tts": "Normally I am stressed the entire flight. The entire airport. The entire taxi to the hotel. Reading reviews on my phone, comparing restaurant menus, worrying about scams. This one I did none of that. Link in my bio, hi2b dot com." }, + { "id": "b42-03-this-was-different","angle": "They handed us everything", "hook_text": "this was different — they handed us everything", + "tts": "This was different because they handed us everything. Shuttle from the airport. Welcome drink. Room ready. Wristband that means yes to every restaurant on the property. No decisions. Link in my bio, hi2b dot com." }, + { "id": "b42-04-no-planning", "angle": "All-inclusive removes decisions", "hook_text": "no planning — all-inclusive removes the decisions", + "tts": "All-inclusive removes the decisions. Where to eat is solved. What it costs is solved. Whether tipping was already included is solved. For somebody like me who hates decision making on vacation, this is unlocking a new life. Link in my bio, hi2b dot com." }, + { "id": "b42-05-hour-was-fine", "angle": "Even the hour was fine", "hook_text": "even the welcome hour was fine", + "tts": "Even the welcome hour was fine. Sixty minutes of coffee and a polite no. I had a story arc planned for how I would handle it badly. Did not need it. Easiest hour of my last month. Link in my bio, hi2b dot com." }, + { "id": "b42-06-husband-shocked", "angle": "Husband can't believe I'm calm", "hook_text": "my husband can't believe I'm not complaining", + "tts": "My husband cannot believe I am not complaining about anything. He keeps testing it. Should we go to the buffet. Sure. Want to try the kayaks. Sure. Want to walk the beach at sunset. Sure. He is now suspicious. Link in my bio, hi2b dot com." }, + { "id": "b42-07-introvert-resort", "angle": "Resorts work for introverts", "hook_text": "resorts work for introverts — no small talk required", + "tts": "Resorts work for introverts. You do not have to ask anyone for directions. You do not have to negotiate prices. The waiter brings food. The bartender brings drinks. The pool stays where you left it. Introvert paradise. Link in my bio, hi2b dot com." }, + { "id": "b42-08-room-to-room", "angle": "Room-to-pool-to-room for 5 days", "hook_text": "I went room-to-pool-to-room for 5 days — bliss", + "tts": "I went from room to pool to restaurant back to room for five days. No excursions. No shopping trips. No instagram tours of the city. Just slow movement between three locations. Bliss. Link in my bio, hi2b dot com." }, + { "id": "b42-09-might-do-again", "angle": "Might do this again", "hook_text": "I might actually do this again — don't tell my husband", + "tts": "I might actually do this again next year. Do not tell my husband. He will think I have become a different person. The certificate model makes it cheap enough that the bar to repeat is very low. Link in my bio, hi2b dot com." }, + { "id": "b42-10-cta-travel-haters", "angle": "Travel haters — this is it", "hook_text": "travel haters — this is the trip for you", + "tts": "Travel haters. This is the trip. All-inclusive removes the parts of travel that make you hate it. The certificate makes it cheap. The hour removes the price guilt. Link in my bio, hi2b dot com. Reluctantly recommend." } + ] +} diff --git a/scripts/batch43-render.ts b/scripts/batch43-render.ts new file mode 100644 index 0000000..500fd22 --- /dev/null +++ b/scripts/batch43-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 43 InfiniteTalk render — "Hour Play-by-Play" Hour-to-Paradise spin. + * + * npx tsx scripts/batch43-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B43 = [ + 'b43-01-play-by-play', 'b43-02-first-ten', 'b43-03-the-tour', 'b43-04-the-offer', 'b43-05-the-no', + 'b43-06-the-wristband', 'b43-07-no-tricks', 'b43-08-what-to-say', 'b43-09-worth-it', 'b43-10-cta-demystified', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18897, scripts: ALL_B43 }, +} + +const KEYFRAME_MAP: Record = { + 'b43-01-play-by-play': 'keyframe-v13-1.jpg', + 'b43-02-first-ten': 'keyframe-v13-1.jpg', + 'b43-03-the-tour': 'keyframe-v13-2.jpg', + 'b43-04-the-offer': 'keyframe-v13-2.jpg', + 'b43-05-the-no': 'keyframe-v13-3.jpg', + 'b43-06-the-wristband': 'keyframe-v13-3.jpg', + 'b43-07-no-tricks': 'keyframe-v13-4.jpg', + 'b43-08-what-to-say': 'keyframe-v13-4.jpg', + 'b43-09-worth-it': 'keyframe-v13-5.jpg', + 'b43-10-cta-demystified': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch43-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b43', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b43-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch43/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch43')) mkdirSync('public/videos/ugc/batch43', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch43/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch43/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch43/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch43-scripts.json b/scripts/batch43-scripts.json new file mode 100644 index 0000000..f7c61d6 --- /dev/null +++ b/scripts/batch43-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 43 — 'Hour Play-by-Play'. Honest minute-by-minute walk-through of the actual 60-90 min resort presentation, demystifying it. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b43-01-play-by-play", "angle": "Minute-by-minute breakdown", "hook_text": "here's the hour, minute by minute, no spin", + "tts": "Let me walk you through the whole hour minute by minute, no spin. People act like it is a mystery. It is not. I wrote down what happened so you know exactly what to expect. Link in my bio, hi2b dot com." }, + { "id": "b43-02-first-ten", "angle": "First 10 minutes", "hook_text": "the first 10 minutes is just coffee and paperwork", + "tts": "First ten minutes. They check you in, hand you a coffee, and you sign a basic form. That is it. Nobody is selling yet. You are just sitting in a nice room near the beach waking up. Link in my bio, hi2b dot com." }, + { "id": "b43-03-the-tour", "angle": "The property tour", "hook_text": "minutes 10 to 30 is a tour of the resort", + "tts": "Minutes ten to thirty are a tour. They walk you around the property. The pools, the restaurants, a model room. Honestly it doubled as a free orientation, so we knew where everything was for the rest of the trip. Link in my bio, hi2b dot com." }, + { "id": "b43-04-the-offer", "angle": "The actual pitch", "hook_text": "minutes 30 to 50 is the membership offer", + "tts": "Minutes thirty to fifty is the actual pitch. They explain a membership for future trips. Numbers on a page. You listen. You can take notes or just nod. There is no trick question, it is a normal sales presentation. Link in my bio, hi2b dot com." }, + { "id": "b43-05-the-no", "angle": "Saying no politely", "hook_text": "minute 50: you say no thanks, politely", + "tts": "Around minute fifty you say no thank you. Politely. Once. They ask if you are sure, you say yes, and that is the whole confrontation everybody is scared of. Two seconds. Link in my bio, hi2b dot com." }, + { "id": "b43-06-the-wristband", "angle": "Getting the wristband", "hook_text": "last 10 minutes: they hand you the wristband", + "tts": "Last ten minutes they hand you the all inclusive wristbands and point you to the pool. Done. You walk out and your five days of food, drinks, and beach officially start. Link in my bio, hi2b dot com." }, + { "id": "b43-07-no-tricks", "angle": "No hidden tricks", "hook_text": "there were no hidden tricks in the hour", + "tts": "There were no hidden tricks. No locked room. No aggressive corner. The internet made me expect a hostage situation and it was a guy with a laptop and a smile. The fear is worse than the hour. Link in my bio, hi2b dot com." }, + { "id": "b43-08-what-to-say", "angle": "Exactly what to say", "hook_text": "exactly what to say so it stays easy", + "tts": "Here is exactly what to say so it stays easy. We are not financing anything this year. We love the resort. Thank you. Repeat those three lines and the hour glides. They have heard it a thousand times. Link in my bio, hi2b dot com." }, + { "id": "b43-09-worth-it", "angle": "Worth it for the price", "hook_text": "one hour for a $249 certificate trip — worth it", + "tts": "One predictable hour for a five day trip on a two hundred forty nine dollar certificate. I would sit through that hour again tomorrow. It is the cheapest hour of work I have ever done for a vacation this nice. Link in my bio, hi2b dot com." }, + { "id": "b43-10-cta-demystified", "angle": "Demystified — go book", "hook_text": "now you know the hour — go book it", + "tts": "Now you know the whole hour. No surprises left. The presentation is easy, the no is easy, and the trip is real. The certificate is on hi2b dot com, link in my bio. Go book it." } + ] +} diff --git a/scripts/batch44-render.ts b/scripts/batch44-render.ts new file mode 100644 index 0000000..8e09468 --- /dev/null +++ b/scripts/batch44-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 44 InfiniteTalk render — "The Real Catches" Hour-to-Paradise spin. + * + * npx tsx scripts/batch44-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B44 = [ + 'b44-01-the-catches', 'b44-02-catch-the-hour', 'b44-03-catch-dates', 'b44-04-catch-upsell', 'b44-05-still-worth', + 'b44-06-not-a-scam', 'b44-07-who-its-for', 'b44-08-who-its-not', 'b44-09-my-verdict', 'b44-10-cta-eyes-open', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18898, scripts: ALL_B44 }, +} + +const KEYFRAME_MAP: Record = { + 'b44-01-the-catches': 'keyframe-v13-1.jpg', + 'b44-02-catch-the-hour': 'keyframe-v13-1.jpg', + 'b44-03-catch-dates': 'keyframe-v13-2.jpg', + 'b44-04-catch-upsell': 'keyframe-v13-2.jpg', + 'b44-05-still-worth': 'keyframe-v13-3.jpg', + 'b44-06-not-a-scam': 'keyframe-v13-3.jpg', + 'b44-07-who-its-for': 'keyframe-v13-4.jpg', + 'b44-08-who-its-not': 'keyframe-v13-4.jpg', + 'b44-09-my-verdict': 'keyframe-v13-5.jpg', + 'b44-10-cta-eyes-open': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch44-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b44', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b44-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch44/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch44')) mkdirSync('public/videos/ugc/batch44', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch44/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch44/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch44/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch44-scripts.json b/scripts/batch44-scripts.json new file mode 100644 index 0000000..0903407 --- /dev/null +++ b/scripts/batch44-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 44 — 'The Real Catches'. Honest objection-first: enumerate the genuine conditions (the hour, date availability, the upsell pitch) then why it's still worth it. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b44-01-the-catches", "angle": "Here are the real catches", "hook_text": "you want the real catches? here they are", + "tts": "You want the real catches before you buy? Here they are, all of them, no sugarcoating. There are exactly three and none of them are dealbreakers. Let me be the honest friend nobody on the internet is being. Link in my bio, hi2b dot com." }, + { "id": "b44-02-catch-the-hour", "angle": "Catch 1: the hour", "hook_text": "catch one — you sit through a 60-90 minute talk", + "tts": "Catch number one. You sit through a sixty to ninety minute presentation about a membership. That is the trade for the cheap certificate. It is calm, you can say no, but it is real and it does take an hour of your trip. Link in my bio, hi2b dot com." }, + { "id": "b44-03-catch-dates", "angle": "Catch 2: date availability", "hook_text": "catch two — your dates are subject to availability", + "tts": "Catch number two. Your travel dates are subject to availability. You request, they confirm. Book early and you are fine. Show up demanding a holiday weekend next week and you might not get it. Plan ahead. Link in my bio, hi2b dot com." }, + { "id": "b44-04-catch-upsell", "angle": "Catch 3: it's a sampler", "hook_text": "catch three — the trip exists to upsell you later", + "tts": "Catch number three. The whole thing exists because they hope you will love it and buy a bigger membership someday. You are the sampler customer. That is the business model. Knowing that, you just enjoy the sample and leave. Link in my bio, hi2b dot com." }, + { "id": "b44-05-still-worth", "angle": "Still worth it", "hook_text": "knowing all 3 catches — still worth it", + "tts": "So knowing all three catches, is it still worth it? For us, absolutely. Five days, four nights, all inclusive, two hundred forty nine dollars. An hour and some advance planning is a tiny price. Link in my bio, hi2b dot com." }, + { "id": "b44-06-not-a-scam", "angle": "Not a scam, just a trade", "hook_text": "it's not a scam — it's a transparent trade", + "tts": "It is not a scam. A scam hides the catch. This one tells you up front. You give an hour, you give some flexibility on dates, and you get a real vacation at a real resort. That is a trade, not a trick. Link in my bio, hi2b dot com." }, + { "id": "b44-07-who-its-for", "angle": "Who it's for", "hook_text": "this is for you if you can plan ahead", + "tts": "This is for you if you can plan a couple months ahead and sit through one polite hour. Couples, small families, anybody who wants a beach week without paying full resort price. That is the ideal customer. Link in my bio, hi2b dot com." }, + { "id": "b44-08-who-its-not", "angle": "Who it's NOT for", "hook_text": "skip it if you need last-minute luxury", + "tts": "Skip it if you need a last minute booking, refuse to hear any sales pitch, or want a five star suite for nothing. The certificate is value, not magic. Honest expectations make happy travelers. Link in my bio, hi2b dot com." }, + { "id": "b44-09-my-verdict", "angle": "My honest verdict", "hook_text": "my honest verdict after using it", + "tts": "My honest verdict after actually using it? I would buy it again today. The catches are exactly what they told me, the resort is exactly what they showed me, and the price is exactly what they promised. Rare these days. Link in my bio, hi2b dot com." }, + { "id": "b44-10-cta-eyes-open", "angle": "Eyes open — go book", "hook_text": "now you know everything — book with eyes open", + "tts": "Now you know every catch there is. No surprises left to ambush you. Book it with your eyes wide open, plan your dates, enjoy your hour, and go to Mexico. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch45-render.ts b/scripts/batch45-render.ts new file mode 100644 index 0000000..2499274 --- /dev/null +++ b/scripts/batch45-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 45 InfiniteTalk render — "Your DMs Answered" Hour-to-Paradise spin. + * + * npx tsx scripts/batch45-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B45 = [ + 'b45-01-your-dms', 'b45-02-is-it-real', 'b45-03-hidden-fees', 'b45-04-the-catch-q', 'b45-05-which-resorts', + 'b45-06-kids-free', 'b45-07-how-book', 'b45-08-flights-q', 'b45-09-timeshare-q', 'b45-10-cta-dm-me', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18899, scripts: ALL_B45 }, +} + +const KEYFRAME_MAP: Record = { + 'b45-01-your-dms': 'keyframe-v13-1.jpg', + 'b45-02-is-it-real': 'keyframe-v13-1.jpg', + 'b45-03-hidden-fees': 'keyframe-v13-2.jpg', + 'b45-04-the-catch-q': 'keyframe-v13-2.jpg', + 'b45-05-which-resorts': 'keyframe-v13-3.jpg', + 'b45-06-kids-free': 'keyframe-v13-3.jpg', + 'b45-07-how-book': 'keyframe-v13-4.jpg', + 'b45-08-flights-q': 'keyframe-v13-4.jpg', + 'b45-09-timeshare-q': 'keyframe-v13-5.jpg', + 'b45-10-cta-dm-me': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch45-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b45', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b45-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch45/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch45')) mkdirSync('public/videos/ugc/batch45', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch45/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch45/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch45/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch45-scripts.json b/scripts/batch45-scripts.json new file mode 100644 index 0000000..3164dd4 --- /dev/null +++ b/scripts/batch45-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 45 — 'Your DMs Answered'. FAQ-style: Sarah answers the most common DM questions/objections about the hi2b trip, one per video. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b45-01-your-dms", "angle": "Answering your DMs", "hook_text": "you flooded my DMs — let's answer all of it", + "tts": "You flooded my DMs after my last video about this trip, so let me answer all of it right here, honestly, from the resort. No filter, no sponsor telling me what to say. Link in my bio, hi2b dot com." }, + { "id": "b45-02-is-it-real", "angle": "Is it actually real?", "hook_text": "question 1: is this actually real? yes", + "tts": "Question one, the big one. Is this actually real? Yes. I am literally standing at the resort it paid for. Five days, four nights, real bed, real beach, real all inclusive wristband. It is real. Link in my bio, hi2b dot com." }, + { "id": "b45-03-hidden-fees", "angle": "Are there hidden fees?", "hook_text": "question 2: hidden fees? here's the honest answer", + "tts": "Question two. Are there hidden fees? The certificate is what you pay. Taxes and resort fees on arrival are normal and disclosed, same as any hotel. Nobody sprung a surprise charge on me. Link in my bio, hi2b dot com." }, + { "id": "b45-04-the-catch-q", "angle": "What's the catch?", "hook_text": "question 3: what's the catch — the hour", + "tts": "Question three. What is the catch? The catch is the one hour presentation about a membership. You sit, you listen, you say no thank you, you get your trip. That is the entire catch. Link in my bio, hi2b dot com." }, + { "id": "b45-05-which-resorts","angle": "Which resorts/destinations?", "hook_text": "question 4: which destinations? four of them", + "tts": "Question four. Which destinations? Cancun, Cabo, Riviera Maya, and Puerto Vallarta. Four of the best spots in Mexico. You pick. We chose this one and it did not disappoint. Link in my bio, hi2b dot com." }, + { "id": "b45-06-kids-free", "angle": "Can I bring my kids?", "hook_text": "question 5: kids? two adults, kids stay free", + "tts": "Question five, asked by every parent. Can I bring my kids? It covers two adults and kids stay free. We brought ours and it genuinely cost us nothing extra for them. Family win. Link in my bio, hi2b dot com." }, + { "id": "b45-07-how-book", "angle": "How do I book it?", "hook_text": "question 6: how to book — it's simple", + "tts": "Question six. How do I actually book it? You buy the certificate on the site, you pick your destination and dates, they confirm availability, you go. It is simpler than booking a regular hotel. Link in my bio, hi2b dot com." }, + { "id": "b45-08-flights-q", "angle": "Are flights included?", "hook_text": "question 7: flights are NOT included — be clear", + "tts": "Question seven, and I want to be totally clear. Flights are not included. The certificate covers the resort stay. You book your own airfare. Knowing that, the value is still incredible. Link in my bio, hi2b dot com." }, + { "id": "b45-09-timeshare-q", "angle": "Is it a timeshare?", "hook_text": "question 8: am I buying a timeshare? no", + "tts": "Question eight. Am I buying a timeshare? No. You are buying a vacation certificate. The hour is them pitching a membership, but you are under zero obligation to buy anything. We did not, and we still got the whole trip. Link in my bio, hi2b dot com." }, + { "id": "b45-10-cta-dm-me", "angle": "Still have questions? Go look","hook_text": "still have questions? everything's on the site", + "tts": "If you still have questions, everything is spelled out on the site, no DM required. But keep them coming, I read them all. Real trip, real answers. The certificate is on hi2b dot com, link in my bio. Go look." } + ] +} diff --git a/scripts/batch46-render.ts b/scripts/batch46-render.ts new file mode 100644 index 0000000..841fafb --- /dev/null +++ b/scripts/batch46-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 46 InfiniteTalk render — "Stop Overthinking It" Hour-to-Paradise spin. + * + * npx tsx scripts/batch46-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B46 = [ + 'b46-01-stop-overthinking', 'b46-02-you-keep-scrolling', 'b46-03-its-249', 'b46-04-worst-case', 'b46-05-we-almost-didnt', + 'b46-06-no-perfect-time', 'b46-07-decision-fatigue', 'b46-08-just-pick-dates', 'b46-09-future-you', 'b46-10-cta-do-it-now', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18900, scripts: ALL_B46 }, +} + +const KEYFRAME_MAP: Record = { + 'b46-01-stop-overthinking': 'keyframe-v13-1.jpg', + 'b46-02-you-keep-scrolling': 'keyframe-v13-1.jpg', + 'b46-03-its-249': 'keyframe-v13-2.jpg', + 'b46-04-worst-case': 'keyframe-v13-2.jpg', + 'b46-05-we-almost-didnt': 'keyframe-v13-3.jpg', + 'b46-06-no-perfect-time': 'keyframe-v13-3.jpg', + 'b46-07-decision-fatigue': 'keyframe-v13-4.jpg', + 'b46-08-just-pick-dates': 'keyframe-v13-4.jpg', + 'b46-09-future-you': 'keyframe-v13-5.jpg', + 'b46-10-cta-do-it-now': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch46-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b46', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b46-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch46/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch46')) mkdirSync('public/videos/ugc/batch46', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch46/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch46/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch46/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch46-scripts.json b/scripts/batch46-scripts.json new file mode 100644 index 0000000..478da79 --- /dev/null +++ b/scripts/batch46-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 46 — 'Stop Overthinking It'. Decisive anti-analysis-paralysis push; addresses the scroller who keeps almost-booking. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b46-01-stop-overthinking", "angle": "Stop overthinking it", "hook_text": "stop overthinking this one — seriously", + "tts": "Stop overthinking this one. I watched it for two weeks before I booked and the only thing that overthinking cost me was two weeks I could have been planning this trip. Just do it. Link in my bio, hi2b dot com." }, + { "id": "b46-02-you-keep-scrolling","angle": "You keep scrolling past", "hook_text": "you've scrolled past this 3 times now", + "tts": "You have scrolled past this three times now. I know because that was me. Same video, same hesitation, same little voice saying maybe later. Later is how trips never happen. Link in my bio, hi2b dot com." }, + { "id": "b46-03-its-249", "angle": "It's $249, not $2,490", "hook_text": "it's $249 — not a mortgage payment", + "tts": "Let me put the risk in perspective. It is two hundred forty nine dollars. Not two thousand. Not a mortgage payment. It is a nice dinner for two, except it is five days in Mexico. The stakes are tiny. Link in my bio, hi2b dot com." }, + { "id": "b46-04-worst-case", "angle": "What's the worst case?", "hook_text": "worst case? you sit through one hour", + "tts": "What is the actual worst case here? You sit through a one hour presentation and say no. That is it. That is the whole downside. Compare that to the upside of a five day vacation. Link in my bio, hi2b dot com." }, + { "id": "b46-05-we-almost-didnt", "angle": "We almost talked ourselves out","hook_text": "we almost talked ourselves out of this", + "tts": "We almost talked ourselves out of this. Too good to be true, we said. Then we booked it, came, and now we are mad we almost let our own doubt cancel this trip. Do not do that to yourselves. Link in my bio, hi2b dot com." }, + { "id": "b46-06-no-perfect-time", "angle": "No perfect time", "hook_text": "there's no perfect time — book the dates", + "tts": "There is no perfect time. Work is always busy, the calendar is always full, something always comes up. The people who travel are not less busy, they just booked the dates. Be that person. Link in my bio, hi2b dot com." }, + { "id": "b46-07-decision-fatigue", "angle": "One easy decision", "hook_text": "for once, make the easy decision", + "tts": "You make a hundred hard decisions a week. For once, here is an easy one. Cheap, all inclusive, four destinations, kids free. The math already works. Stop auditing it and just choose. Link in my bio, hi2b dot com." }, + { "id": "b46-08-just-pick-dates", "angle": "Just pick the dates", "hook_text": "just pick the dates — momentum does the rest", + "tts": "Here is the trick that worked for us. Do not plan the whole trip in your head. Just pick the dates. Once the dates are real, everything else falls into place. Momentum beats overthinking every time. Link in my bio, hi2b dot com." }, + { "id": "b46-09-future-you", "angle": "Future you is grateful", "hook_text": "future you is begging you to book it", + "tts": "Future you, three weeks from now, sitting on this exact beach, is begging present you to stop deliberating and book it. I am future you. I am telling you. Do it. Link in my bio, hi2b dot com." }, + { "id": "b46-10-cta-do-it-now", "angle": "Do it now, not later", "hook_text": "do it now — not later, now", + "tts": "Do it now. Not after you think about it, not after you ask three people, now, while you are already holding your phone. The certificate is two forty nine on hi2b dot com, link in my bio. Stop scrolling and go." } + ] +} diff --git a/scripts/batch47-render.ts b/scripts/batch47-render.ts new file mode 100644 index 0000000..6530550 --- /dev/null +++ b/scripts/batch47-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 47 InfiniteTalk render — "Booked On My Lunch Break" Hour-to-Paradise spin. + * + * npx tsx scripts/batch47-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B47 = [ + 'b47-01-lunch-break', 'b47-02-five-minutes', 'b47-03-no-call', 'b47-04-confirmation', 'b47-05-dates-later', + 'b47-06-on-my-phone', 'b47-07-compared-hotels', 'b47-08-impulse-good', 'b47-09-tell-friend', 'b47-10-cta-five-min', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18901, scripts: ALL_B47 }, +} + +const KEYFRAME_MAP: Record = { + 'b47-01-lunch-break': 'keyframe-v13-1.jpg', + 'b47-02-five-minutes': 'keyframe-v13-1.jpg', + 'b47-03-no-call': 'keyframe-v13-2.jpg', + 'b47-04-confirmation': 'keyframe-v13-2.jpg', + 'b47-05-dates-later': 'keyframe-v13-3.jpg', + 'b47-06-on-my-phone': 'keyframe-v13-3.jpg', + 'b47-07-compared-hotels': 'keyframe-v13-4.jpg', + 'b47-08-impulse-good': 'keyframe-v13-4.jpg', + 'b47-09-tell-friend': 'keyframe-v13-5.jpg', + 'b47-10-cta-five-min': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch47-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b47', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b47-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch47/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch47')) mkdirSync('public/videos/ugc/batch47', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch47/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch47/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch47/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch47-scripts.json b/scripts/batch47-scripts.json new file mode 100644 index 0000000..e91828b --- /dev/null +++ b/scripts/batch47-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 47 — 'Booked On My Lunch Break'. Speed/ease angle: how fast and frictionless the actual booking was. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b47-01-lunch-break", "angle": "Booked on my lunch break", "hook_text": "I booked this whole trip on my lunch break", + "tts": "I booked this entire trip on my lunch break. Sandwich in one hand, phone in the other. Five days in Mexico, done before my coffee got cold. It is genuinely that fast. Link in my bio, hi2b dot com." }, + { "id": "b47-02-five-minutes", "angle": "Took about five minutes", "hook_text": "the whole thing took about five minutes", + "tts": "The whole checkout took about five minutes. Pick the certificate, pay, done. I have spent longer deciding what to watch on Netflix than I spent booking an actual vacation. Link in my bio, hi2b dot com." }, + { "id": "b47-03-no-call", "angle": "No phone call required", "hook_text": "no phone call, no salesperson, no pressure", + "tts": "No phone call. No salesperson breathing down my neck. No being transferred four times. Just a normal online checkout like buying anything else. That alone sold me. Link in my bio, hi2b dot com." }, + { "id": "b47-04-confirmation", "angle": "Instant confirmation", "hook_text": "confirmation hit my inbox immediately", + "tts": "The confirmation hit my inbox immediately. No waiting three business days wondering if it went through. It was real and in my email before I finished my lunch. Link in my bio, hi2b dot com." }, + { "id": "b47-05-dates-later", "angle": "Pick dates whenever", "hook_text": "you buy now and pick your dates later", + "tts": "Here is what made it painless. You buy the certificate now and pick your travel dates later. No pressure to have your whole calendar figured out in the moment. Lock it in, plan when ready. Link in my bio, hi2b dot com." }, + { "id": "b47-06-on-my-phone", "angle": "Did it all on my phone", "hook_text": "did the entire thing on my phone", + "tts": "I did the entire thing on my phone. No laptop, no printing anything, no scanning documents. If you can order food delivery you can book this trip. Link in my bio, hi2b dot com." }, + { "id": "b47-07-compared-hotels","angle": "vs booking a normal hotel", "hook_text": "compared to booking a regular hotel? night and day", + "tts": "Compared to booking a regular resort, where I am comparing twelve tabs and fake urgency timers, this was night and day. One price, one click, four destinations to choose from. Link in my bio, hi2b dot com." }, + { "id": "b47-08-impulse-good", "angle": "Best impulse buy ever", "hook_text": "best impulse purchase I've ever made", + "tts": "I will call it what it was. An impulse buy. And the best one I have ever made. Two hundred forty nine dollars on a whim turned into the trip we are on right now. Link in my bio, hi2b dot com." }, + { "id": "b47-09-tell-friend", "angle": "Texted my friend instantly", "hook_text": "I texted my best friend before I even finished", + "tts": "I texted my best friend before I even finished checking out. Booked Mexico, come with us. She booked hers on her lunch break too. Now it is a whole group trip. Link in my bio, hi2b dot com." }, + { "id": "b47-10-cta-five-min", "angle": "You have five minutes", "hook_text": "you have five minutes right now — use them", + "tts": "You have five minutes right now. You are already on your phone. That is literally all this takes. Pick the destination, pay, get your confirmation, brag to a friend. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch48-render.ts b/scripts/batch48-render.ts new file mode 100644 index 0000000..fcdba54 --- /dev/null +++ b/scripts/batch48-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 48 InfiniteTalk render — "What I'd Tell My Past Self" Hour-to-Paradise spin. + * + * npx tsx scripts/batch48-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B48 = [ + 'b48-01-past-self', 'b48-02-waited-too-long', 'b48-03-thought-expensive', 'b48-04-kids-grow', 'b48-05-no-regrets', + 'b48-06-money-comes-back', 'b48-07-first-of-many', 'b48-08-photos', 'b48-09-permission', 'b48-10-cta-dont-wait', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18902, scripts: ALL_B48 }, +} + +const KEYFRAME_MAP: Record = { + 'b48-01-past-self': 'keyframe-v13-1.jpg', + 'b48-02-waited-too-long': 'keyframe-v13-1.jpg', + 'b48-03-thought-expensive': 'keyframe-v13-2.jpg', + 'b48-04-kids-grow': 'keyframe-v13-2.jpg', + 'b48-05-no-regrets': 'keyframe-v13-3.jpg', + 'b48-06-money-comes-back': 'keyframe-v13-3.jpg', + 'b48-07-first-of-many': 'keyframe-v13-4.jpg', + 'b48-08-photos': 'keyframe-v13-4.jpg', + 'b48-09-permission': 'keyframe-v13-5.jpg', + 'b48-10-cta-dont-wait': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch48-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b48', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b48-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch48/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch48')) mkdirSync('public/videos/ugc/batch48', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch48/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch48/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch48/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch48-scripts.json b/scripts/batch48-scripts.json new file mode 100644 index 0000000..61bbd0a --- /dev/null +++ b/scripts/batch48-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 48 — 'What I'd Tell My Past Self'. Regret-reversal/aspirational angle: looking back, wishing they'd done it sooner. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b48-01-past-self", "angle": "What I'd tell my past self", "hook_text": "what I'd tell myself a year ago about this", + "tts": "If I could tell myself one thing a year ago, it would be this. Stop waiting for the right year to take the trip. The certificate was always this cheap. I just kept not doing it. Link in my bio, hi2b dot com." }, + { "id": "b48-02-waited-too-long", "angle": "We waited too long", "hook_text": "we waited way too long to do this", + "tts": "We waited way too long to do this. Years of saying someday. Someday we will have more money, more time, fewer excuses. Someday was always a lie. We are finally here and furious we waited. Link in my bio, hi2b dot com." }, + { "id": "b48-03-thought-expensive","angle": "Assumed it was out of reach", "hook_text": "I assumed a trip like this was out of reach", + "tts": "For years I assumed a resort vacation like this was just out of reach for us. Not rich enough, I told myself. Turns out it was two hundred forty nine dollars and an hour. I was wrong for years. Link in my bio, hi2b dot com." }, + { "id": "b48-04-kids-grow", "angle": "The kids grow up fast", "hook_text": "the kids are only this age once", + "tts": "The kids are only this age once. The window for a family trip where they still want to be with us is shorter than I admitted. Kids stay free on this. Stop postponing the memories. Link in my bio, hi2b dot com." }, + { "id": "b48-05-no-regrets", "angle": "Nobody regrets the trip", "hook_text": "nobody ever regrets taking the trip", + "tts": "Nobody on their deathbed wishes they had taken fewer vacations. Every single person regrets the trips they did not take. This one costs almost nothing. Take it. Link in my bio, hi2b dot com." }, + { "id": "b48-06-money-comes-back","angle": "Money comes back, time doesn't","hook_text": "the money comes back — the time doesn't", + "tts": "The two hundred forty nine dollars comes back. Your next paycheck replaces it. The summer your family had together does not come back. Spend the money, keep the time. Link in my bio, hi2b dot com." }, + { "id": "b48-07-first-of-many", "angle": "First of many now", "hook_text": "this is the first of many now — finally", + "tts": "This is the first of many now. We broke the seal. Once you realize a real trip can cost this little, you stop making excuses and start making memories. I wish we had started years ago. Link in my bio, hi2b dot com." }, + { "id": "b48-08-photos", "angle": "The photos are everything", "hook_text": "the photos from this week are everything", + "tts": "The photos from this week are everything. Us actually together, actually relaxed, actually somewhere beautiful. Not another year of the same backyard pictures. This is what I will keep. Link in my bio, hi2b dot com." }, + { "id": "b48-09-permission", "angle": "This is your permission", "hook_text": "consider this your permission to go", + "tts": "If you have been waiting for permission, consider this it. You are allowed to take the trip. You are allowed to spend two hundred forty nine dollars on joy. You do not have to earn it first. Link in my bio, hi2b dot com." }, + { "id": "b48-10-cta-dont-wait", "angle": "Don't wait like we did", "hook_text": "don't wait like we did — go now", + "tts": "Do not wait like we did. The version of you a year from now will either be grateful you booked today or annoyed you waited again. Be the grateful one. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch49-render.ts b/scripts/batch49-render.ts new file mode 100644 index 0000000..a3e3d49 --- /dev/null +++ b/scripts/batch49-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 49 InfiniteTalk render — "Girls Trip on a Budget" Hour-to-Paradise spin. + * + * npx tsx scripts/batch49-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B49 = [ + 'b49-01-girls-trip', 'b49-02-why-it-never', 'b49-03-split-easy', 'b49-04-no-planner', 'b49-05-the-hour-girls', + 'b49-06-all-inclusive', 'b49-07-memories', 'b49-08-mom-friends', 'b49-09-already-next', 'b49-10-cta-text-group', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18903, scripts: ALL_B49 }, +} + +const KEYFRAME_MAP: Record = { + 'b49-01-girls-trip': 'keyframe-v13-1.jpg', + 'b49-02-why-it-never': 'keyframe-v13-1.jpg', + 'b49-03-split-easy': 'keyframe-v13-2.jpg', + 'b49-04-no-planner': 'keyframe-v13-2.jpg', + 'b49-05-the-hour-girls': 'keyframe-v13-3.jpg', + 'b49-06-all-inclusive': 'keyframe-v13-3.jpg', + 'b49-07-memories': 'keyframe-v13-4.jpg', + 'b49-08-mom-friends': 'keyframe-v13-4.jpg', + 'b49-09-already-next': 'keyframe-v13-5.jpg', + 'b49-10-cta-text-group': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch49-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b49', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b49-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch49/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch49')) mkdirSync('public/videos/ugc/batch49', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch49/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch49/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch49/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch49-scripts.json b/scripts/batch49-scripts.json new file mode 100644 index 0000000..25a16cb --- /dev/null +++ b/scripts/batch49-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 49 — 'Girls Trip on a Budget'. Friend-group / girlfriends angle: organizing a cheap getaway with the girls. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b49-01-girls-trip", "angle": "Girls trip, finally", "hook_text": "we finally pulled off the girls trip", + "tts": "We finally pulled off the girls trip we have been threatening to take for five years. You know the group chat that plans a trip every January and never goes? We actually went. This is how. Link in my bio, hi2b dot com." }, + { "id": "b49-02-why-it-never", "angle": "Why it never happened", "hook_text": "the reason it never happened before? money", + "tts": "The reason it never happened before was always money. Somebody could not swing it that month. With the certificate at two forty nine each, nobody had an excuse anymore. The price killed the excuses. Link in my bio, hi2b dot com." }, + { "id": "b49-03-split-easy", "angle": "Easy to split", "hook_text": "everyone bought their own — no awkward money talk", + "tts": "Everyone just bought their own certificate. No one fronting money, no awkward who owes who, no chasing people for venmo. Each girl, her own two forty nine, done. Cleanest group trip finances ever. Link in my bio, hi2b dot com." }, + { "id": "b49-04-no-planner", "angle": "No designated planner burnout", "hook_text": "no one had to be the exhausted trip planner", + "tts": "Best part. No one had to be the exhausted trip planner doing forty hours of research. The resort is all inclusive, the destinations are set, you just pick and show up. The planner girl finally got to relax too. Link in my bio, hi2b dot com." }, + { "id": "b49-05-the-hour-girls", "angle": "We did the hour together", "hook_text": "we did the welcome hour together — easy", + "tts": "We did the welcome hour together as a group, which honestly made it fun. Sixty minutes, polite no thank you, wristbands, pool. Then five days of zero responsibilities together. Link in my bio, hi2b dot com." }, + { "id": "b49-06-all-inclusive", "angle": "All-inclusive = no bill math", "hook_text": "all-inclusive means no end-of-trip bill fight", + "tts": "All inclusive means no end of trip bill where someone had three more cocktails and now math is ruining the friendship. Everything is covered. Order freely. Nobody is keeping a tab on anybody. Link in my bio, hi2b dot com." }, + { "id": "b49-07-memories", "angle": "The memories", "hook_text": "the photos and memories — priceless", + "tts": "The photos. The laughing until we cried at dinner. The beach at sunset with my favorite people. You cannot put a price on that, but the entry fee happened to be two forty nine. Link in my bio, hi2b dot com." }, + { "id": "b49-08-mom-friends", "angle": "Even the busy moms came", "hook_text": "even the moms who never get away came", + "tts": "Even the moms in the group who never get a weekend away made this one. Kids stay free so a couple brought the little ones, the rest of us got our break. It worked for everybody. Link in my bio, hi2b dot com." }, + { "id": "b49-09-already-next", "angle": "Already planning the next one", "hook_text": "we're already planning round two", + "tts": "We are already planning round two before this one is even over. Different destination next time. Once you realize a girls trip can cost this little, it stops being a someday and becomes a tradition. Link in my bio, hi2b dot com." }, + { "id": "b49-10-cta-text-group", "angle": "Text your group right now", "hook_text": "text your group chat right now — send this", + "tts": "Text your group chat right now. Send them this video. Tell them two forty nine each, kids free, four destinations, no planner required. Watch how fast someone says I'm in. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch50-render.ts b/scripts/batch50-render.ts new file mode 100644 index 0000000..8c367b0 --- /dev/null +++ b/scripts/batch50-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 50 InfiniteTalk render — "Anniversary Reset" Hour-to-Paradise spin. + * + * npx tsx scripts/batch50-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B50 = [ + 'b50-01-anniversary', 'b50-02-autopilot', 'b50-03-cheaper-counseling', 'b50-04-kids-free-too', 'b50-05-the-hour-us', + 'b50-06-talked-again', 'b50-07-no-money-fight', 'b50-08-recommend-couples', 'b50-09-felt-young', 'b50-10-cta-book-partner', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18904, scripts: ALL_B50 }, +} + +const KEYFRAME_MAP: Record = { + 'b50-01-anniversary': 'keyframe-v13-1.jpg', + 'b50-02-autopilot': 'keyframe-v13-1.jpg', + 'b50-03-cheaper-counseling': 'keyframe-v13-2.jpg', + 'b50-04-kids-free-too': 'keyframe-v13-2.jpg', + 'b50-05-the-hour-us': 'keyframe-v13-3.jpg', + 'b50-06-talked-again': 'keyframe-v13-3.jpg', + 'b50-07-no-money-fight': 'keyframe-v13-4.jpg', + 'b50-08-recommend-couples': 'keyframe-v13-4.jpg', + 'b50-09-felt-young': 'keyframe-v13-5.jpg', + 'b50-10-cta-book-partner': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch50-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b50', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b50-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch50/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch50')) mkdirSync('public/videos/ugc/batch50', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch50/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch50/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch50/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch50-scripts.json b/scripts/batch50-scripts.json new file mode 100644 index 0000000..ee1e055 --- /dev/null +++ b/scripts/batch50-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 50 — 'Anniversary Reset'. Long-married couple using the cheap trip to reconnect / reset the relationship. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b50-01-anniversary", "angle": "Anniversary reset", "hook_text": "we used this to reset our marriage, honestly", + "tts": "We used this trip to reset our marriage, honestly. Fifteen years, two kids, a lot of autopilot. We needed time that was just us and we could not justify spending thousands. Two forty nine made it a yes. Link in my bio, hi2b dot com." }, + { "id": "b50-02-autopilot", "angle": "Stuck on autopilot", "hook_text": "we'd been on autopilot for years", + "tts": "We had been on autopilot for years. Pass each other in the kitchen, divide and conquer the kids, fall asleep mid sentence. This was the first time in forever we actually talked for five days straight. Link in my bio, hi2b dot com." }, + { "id": "b50-03-cheaper-counseling","angle": "Cheaper than therapy", "hook_text": "cheaper than a single therapy session", + "tts": "I will say it. This was cheaper than one therapy session and did more for us than a month of them. Sun, no chores, no schedule, just each other. Sometimes you do not need counseling, you need a beach. Link in my bio, hi2b dot com." }, + { "id": "b50-04-kids-free-too", "angle": "Brought the kids, still us-time","hook_text": "kids came free and we STILL got couple time", + "tts": "The kids came free, but the resort kept them so busy with the pool and activities that we still got real couple time. Best of both. Family trip and a marriage tune up at the same time. Link in my bio, hi2b dot com." }, + { "id": "b50-05-the-hour-us", "angle": "Even the hour was a date", "hook_text": "even the welcome hour felt like a date", + "tts": "Even the welcome hour felt like a date. Sitting together, coffee, saying no thanks as a team, laughing about it after. We have not been a team about anything small in years. It was nice. Link in my bio, hi2b dot com." }, + { "id": "b50-06-talked-again", "angle": "We talked like we used to", "hook_text": "we talked like we did when we first met", + "tts": "By day three we were talking like we did when we first met. Dreams, plans, dumb jokes. The version of us we were worried we had lost was just buried under logistics. The trip dug it back up. Link in my bio, hi2b dot com." }, + { "id": "b50-07-no-money-fight", "angle": "No money stress on the trip", "hook_text": "no money stress means no money fights", + "tts": "Money stress is where a lot of our fights start. All inclusive and prepaid means there was nothing to argue about. No bill, no budgeting at dinner, no tension. Just the two of us, relaxed. Link in my bio, hi2b dot com." }, + { "id": "b50-08-recommend-couples","angle": "Every long couple should go", "hook_text": "every long-married couple needs this reset", + "tts": "Every couple a decade or two in needs this reset and most of them are not doing it because they think it costs a fortune. It does not. That is the whole reason I am posting this. Go reconnect. Link in my bio, hi2b dot com." }, + { "id": "b50-09-felt-young", "angle": "Felt like newlyweds", "hook_text": "for 5 days we felt like newlyweds again", + "tts": "For five days we felt like newlyweds again. Holding hands walking the beach, no phones, no carpool. I forgot how much I actually like my husband when life gets out of the way. Link in my bio, hi2b dot com." }, + { "id": "b50-10-cta-book-partner","angle": "Book it with your person", "hook_text": "book this with your person — you both need it", + "tts": "Book this with your person. You both need it more than you are admitting. Two forty nine, five days, no excuses about money. Surprise them tonight. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch51-render.ts b/scripts/batch51-render.ts new file mode 100644 index 0000000..7b2fe0d --- /dev/null +++ b/scripts/batch51-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 51 InfiniteTalk render — "Solo Reset Trip" Hour-to-Paradise spin. + * + * npx tsx scripts/batch51-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B51 = [ + 'b51-01-solo-trip', 'b51-02-deserve-it', 'b51-03-cheap-recharge', 'b51-04-no-compromise', 'b51-05-the-hour-solo', + 'b51-06-safe-easy', 'b51-07-met-people', 'b51-08-came-back-better', 'b51-09-do-it-yearly', 'b51-10-cta-book-you', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18905, scripts: ALL_B51 }, +} + +const KEYFRAME_MAP: Record = { + 'b51-01-solo-trip': 'keyframe-v13-1.jpg', + 'b51-02-deserve-it': 'keyframe-v13-1.jpg', + 'b51-03-cheap-recharge': 'keyframe-v13-2.jpg', + 'b51-04-no-compromise': 'keyframe-v13-2.jpg', + 'b51-05-the-hour-solo': 'keyframe-v13-3.jpg', + 'b51-06-safe-easy': 'keyframe-v13-3.jpg', + 'b51-07-met-people': 'keyframe-v13-4.jpg', + 'b51-08-came-back-better': 'keyframe-v13-4.jpg', + 'b51-09-do-it-yearly': 'keyframe-v13-5.jpg', + 'b51-10-cta-book-you': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch51-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b51', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b51-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch51/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch51')) mkdirSync('public/videos/ugc/batch51', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch51/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch51/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch51/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch51-scripts.json b/scripts/batch51-scripts.json new file mode 100644 index 0000000..6e2e0bf --- /dev/null +++ b/scripts/batch51-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 51 — 'Solo Reset Trip'. Solo-traveler / self-care angle: going alone to recharge, cheap enough to justify treating yourself. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b51-01-solo-trip", "angle": "I went alone on purpose", "hook_text": "I booked a solo trip and it changed everything", + "tts": "I booked a solo trip to Mexico, on purpose, by myself, and it changed everything. No one to coordinate with, no compromises on the itinerary. Just me, the beach, and a certificate that cost two forty nine. Link in my bio, hi2b dot com." }, + { "id": "b51-02-deserve-it", "angle": "You're allowed to treat yourself","hook_text": "you're allowed to do something just for you", + "tts": "You are allowed to do something just for you. We spend so much on everyone else and feel guilty spending on ourselves. At two forty nine, the guilt has nowhere to hide. Book the trip for you. Link in my bio, hi2b dot com." }, + { "id": "b51-03-cheap-recharge", "angle": "Cheapest recharge there is", "hook_text": "cheapest way to fully recharge I've found", + "tts": "This is the cheapest way to fully recharge that I have ever found. Five days of sun, quiet, and no responsibilities for the price of a nice dinner. Burnout did not stand a chance. Link in my bio, hi2b dot com." }, + { "id": "b51-04-no-compromise", "angle": "No compromises solo", "hook_text": "solo means zero compromises on your trip", + "tts": "Solo means zero compromises. I ate when I wanted, napped when I wanted, read an entire book by the pool with nobody asking what is next. If you have never traveled alone, this is the cheap way to try it. Link in my bio, hi2b dot com." }, + { "id": "b51-05-the-hour-solo", "angle": "Did the hour solo, easy", "hook_text": "did the welcome hour alone — even easier", + "tts": "I did the welcome hour completely alone and it was even easier solo. No spouse to convince, no group to wrangle. Sixty minutes, polite no thank you, done. Then five days that were entirely mine. Link in my bio, hi2b dot com." }, + { "id": "b51-06-safe-easy", "angle": "Felt safe and easy", "hook_text": "as a solo woman it felt safe and easy", + "tts": "As a solo woman traveler I will say it felt safe and easy. All inclusive resort, everything on property, staff everywhere. I did not have to navigate a strange city alone unless I wanted to. Link in my bio, hi2b dot com." }, + { "id": "b51-07-met-people", "angle": "Met people if you want", "hook_text": "solo but not lonely — met people if I wanted", + "tts": "Solo did not mean lonely. I met other travelers at the pool bar, chatted at dinner, then went back to my quiet room whenever I wanted. Best of both, on my terms. Link in my bio, hi2b dot com." }, + { "id": "b51-08-came-back-better","angle": "Came back a better everything", "hook_text": "I came back a better mom, partner, everything", + "tts": "I came back a better mom, a better partner, a better everything. Five days of actually resting refilled a tank that had been empty for a year. Everyone around me benefited from my little selfish trip. Link in my bio, hi2b dot com." }, + { "id": "b51-09-do-it-yearly", "angle": "Making it a yearly ritual", "hook_text": "this is becoming my yearly solo ritual", + "tts": "This is becoming my yearly solo ritual. Once a year, by myself, somewhere warm, to remember who I am outside of all my roles. At this price I can actually keep that promise to myself. Link in my bio, hi2b dot com." }, + { "id": "b51-10-cta-book-you", "angle": "Book one for yourself", "hook_text": "book one just for you — you've earned it", + "tts": "Book one just for you. Not for the family, not for the couple, for you. You have earned a quiet beach and a clear head. Two forty nine, five days, totally yours. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch52-render.ts b/scripts/batch52-render.ts new file mode 100644 index 0000000..1431450 --- /dev/null +++ b/scripts/batch52-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 52 InfiniteTalk render — "Skeptic Converted" Hour-to-Paradise spin. + * + * npx tsx scripts/batch52-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B52 = [ + 'b52-01-was-skeptic', 'b52-02-too-good', 'b52-03-did-research', 'b52-04-looked-for-trap', 'b52-05-hour-confirmed', + 'b52-06-husband-skeptic', 'b52-07-now-i-tell', 'b52-08-proof-photos', 'b52-09-what-changed', 'b52-10-cta-skeptics', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18906, scripts: ALL_B52 }, +} + +const KEYFRAME_MAP: Record = { + 'b52-01-was-skeptic': 'keyframe-v13-1.jpg', + 'b52-02-too-good': 'keyframe-v13-1.jpg', + 'b52-03-did-research': 'keyframe-v13-2.jpg', + 'b52-04-looked-for-trap': 'keyframe-v13-2.jpg', + 'b52-05-hour-confirmed': 'keyframe-v13-3.jpg', + 'b52-06-husband-skeptic': 'keyframe-v13-3.jpg', + 'b52-07-now-i-tell': 'keyframe-v13-4.jpg', + 'b52-08-proof-photos': 'keyframe-v13-4.jpg', + 'b52-09-what-changed': 'keyframe-v13-5.jpg', + 'b52-10-cta-skeptics': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch52-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b52', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b52-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch52/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch52')) mkdirSync('public/videos/ugc/batch52', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch52/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch52/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch52/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch52-scripts.json b/scripts/batch52-scripts.json new file mode 100644 index 0000000..6fd415e --- /dev/null +++ b/scripts/batch52-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 52 — 'Skeptic Converted'. Hard-skeptic-to-believer arc: assumed it was a scam, did the research, proven wrong. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b52-01-was-skeptic", "angle": "I was the biggest skeptic", "hook_text": "I was the biggest skeptic about this — I was wrong", + "tts": "I was the biggest skeptic about this whole thing. Cheap Mexico vacation, sit through an hour, sounds like a scam. I went in ready to expose it. Instead it exposed how wrong I was. Link in my bio, hi2b dot com." }, + { "id": "b52-02-too-good", "angle": "If it sounds too good...", "hook_text": "if it sounds too good to be true — sometimes it isn't", + "tts": "If it sounds too good to be true it usually is, right? That rule has saved me a hundred times. This is the rare exception. The catch is real but small, and the trip is exactly what they said. Link in my bio, hi2b dot com." }, + { "id": "b52-03-did-research", "angle": "I did the research", "hook_text": "I did way too much research before booking", + "tts": "I did way too much research before booking. Read every review, every complaint, every reddit thread. The only real gripe was the presentation, which they tell you about up front. So I booked. Link in my bio, hi2b dot com." }, + { "id": "b52-04-looked-for-trap", "angle": "Looking for the trap", "hook_text": "I spent the first day looking for the trap", + "tts": "I spent the entire first day at the resort looking for the trap. The hidden charge, the bait and switch, the asterisk. There wasn't one. By day two I finally let myself relax. Link in my bio, hi2b dot com." }, + { "id": "b52-05-hour-confirmed", "angle": "The hour confirmed it's legit", "hook_text": "the hour actually proved it's legit, not a scam", + "tts": "The welcome hour actually proved it is legit, not a scam. A scam takes your money and vanishes. They explained a real membership, I said no, and they still honored everything. That is the opposite of a scam. Link in my bio, hi2b dot com." }, + { "id": "b52-06-husband-skeptic", "angle": "Converted my skeptic husband", "hook_text": "even my skeptic husband admitted he was wrong", + "tts": "Even my husband, who is somehow more skeptical than me, admitted he was wrong by day three. He kept saying there has to be something. There wasn't. He hates being wrong, so that is high praise. Link in my bio, hi2b dot com." }, + { "id": "b52-07-now-i-tell", "angle": "Now I'm the one telling people", "hook_text": "now I'm the annoying one telling everyone", + "tts": "Now I am the annoying one telling everyone they have to do this. I went from skeptic to evangelist in five days. If a cynic like me is posting this for free, that should tell you something. Link in my bio, hi2b dot com." }, + { "id": "b52-08-proof-photos", "angle": "Here's my proof", "hook_text": "here's my proof — I'm literally here", + "tts": "Here is my proof. I am literally standing on the beach it paid for, filming this myself. No green screen, no sponsor, no script. Just a former skeptic who got proven wrong and is happy about it. Link in my bio, hi2b dot com." }, + { "id": "b52-09-what-changed", "angle": "What changed my mind", "hook_text": "what actually changed my mind", + "tts": "What actually changed my mind was the transparency. They told me the catch before I paid. Scams hide the catch, businesses disclose it. The honesty is what flipped me. Link in my bio, hi2b dot com." }, + { "id": "b52-10-cta-skeptics", "angle": "Skeptics — verify it yourself", "hook_text": "fellow skeptics — go verify it yourself", + "tts": "Fellow skeptics, do not take my word for it. Go verify it yourself like I did. Read the reviews, check the site, find the catch. When you realize the catch is just one hour, you will book it too. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch53-render.ts b/scripts/batch53-render.ts new file mode 100644 index 0000000..fcd627b --- /dev/null +++ b/scripts/batch53-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 53 InfiniteTalk render — "Bucket List Without the Price" Hour-to-Paradise spin. + * + * npx tsx scripts/batch53-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B53 = [ + 'b53-01-bucket-list', 'b53-02-on-the-list', 'b53-03-postcard', 'b53-04-not-rich', 'b53-05-the-hour-worth', + 'b53-06-photos-prove', 'b53-07-do-it-young', 'b53-08-kids-saw', 'b53-09-what-else', 'b53-10-cta-cross-off', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18907, scripts: ALL_B53 }, +} + +const KEYFRAME_MAP: Record = { + 'b53-01-bucket-list': 'keyframe-v13-1.jpg', + 'b53-02-on-the-list': 'keyframe-v13-1.jpg', + 'b53-03-postcard': 'keyframe-v13-2.jpg', + 'b53-04-not-rich': 'keyframe-v13-2.jpg', + 'b53-05-the-hour-worth': 'keyframe-v13-3.jpg', + 'b53-06-photos-prove': 'keyframe-v13-3.jpg', + 'b53-07-do-it-young': 'keyframe-v13-4.jpg', + 'b53-08-kids-saw': 'keyframe-v13-4.jpg', + 'b53-09-what-else': 'keyframe-v13-5.jpg', + 'b53-10-cta-cross-off': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch53-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b53', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b53-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch53/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch53')) mkdirSync('public/videos/ugc/batch53', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch53/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch53/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch53/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch53-scripts.json b/scripts/batch53-scripts.json new file mode 100644 index 0000000..408e013 --- /dev/null +++ b/scripts/batch53-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 53 — 'Bucket List Without the Price'. Aspirational bucket-list angle: crossing off a dream trip for a fraction of expected cost. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b53-01-bucket-list", "angle": "Bucket list, no bucket budget", "hook_text": "I crossed off a bucket-list trip for almost nothing", + "tts": "I crossed a bucket list trip off my list this week and it cost almost nothing. Everyone thinks the dream beach vacation requires a dream salary. It does not. It required two hundred forty nine dollars and one hour. Link in my bio, hi2b dot com." }, + { "id": "b53-02-on-the-list", "angle": "On my list for years", "hook_text": "this was on my list for literally years", + "tts": "This trip was on my list for literally years. A real Mexican resort, the turquoise water, the swim up bar. I assumed it was a someday when we are richer thing. Turns out someday was two forty nine away the whole time. Link in my bio, hi2b dot com." }, + { "id": "b53-03-postcard", "angle": "It looks like the postcard", "hook_text": "it actually looks like the postcard, I promise", + "tts": "It actually looks like the postcard. I keep waiting for the catch where the beach is brown or the resort is sad. Nope. White sand, blue water, exactly the picture in my head for years. Link in my bio, hi2b dot com." }, + { "id": "b53-04-not-rich", "angle": "You don't have to be rich", "hook_text": "you do not have to be rich to do this", + "tts": "You do not have to be rich to do this. That is the lie that kept me from booking for so long. The certificate makes a luxury resort accessible to a normal budget. The gate I imagined was never really there. Link in my bio, hi2b dot com." }, + { "id": "b53-05-the-hour-worth", "angle": "The hour was a tiny price", "hook_text": "the hour was a tiny price for a dream", + "tts": "Was there a catch? The hour. Sixty to ninety minutes of a polite presentation. For a bucket list trip, that is the smallest price I have ever paid for a dream. I would do it twice. Link in my bio, hi2b dot com." }, + { "id": "b53-06-photos-prove", "angle": "Photos prove it", "hook_text": "my camera roll is proof it's real", + "tts": "My camera roll is the proof. Sunsets, the infinity pool, dinner by the ocean, all of it real, all of it mine now. Memories I genuinely thought were reserved for other, wealthier people. Link in my bio, hi2b dot com." }, + { "id": "b53-07-do-it-young", "angle": "Do it while you can enjoy it", "hook_text": "do the bucket list now, not at 70", + "tts": "Do the bucket list now while you can actually enjoy it, not at seventy when your knees hurt. The whole point of cheap is you do not have to wait for retirement to live a little. Go now. Link in my bio, hi2b dot com." }, + { "id": "b53-08-kids-saw", "angle": "My kids saw the ocean", "hook_text": "my kids saw the ocean for the first time", + "tts": "My kids saw the ocean for the first time on this trip. Their faces. That memory alone was worth a hundred times what we paid, and kids stay free, so it cost us nothing extra to give them that. Link in my bio, hi2b dot com." }, + { "id": "b53-09-what-else", "angle": "What else am I missing?", "hook_text": "now I'm wondering what else I'm wrongly skipping", + "tts": "Now I am wondering what else I have been skipping because I assumed it was too expensive. If a resort vacation was always this affordable, what other dreams have I been talking myself out of? Link in my bio, hi2b dot com." }, + { "id": "b53-10-cta-cross-off", "angle": "Cross it off your list", "hook_text": "go cross it off your list — it's cheaper than you think", + "tts": "Go cross it off your list. The beach vacation you keep scrolling past is cheaper than you think. Two forty nine, five days, four destinations to choose from. Stop saving the dream for later. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch54-render.ts b/scripts/batch54-render.ts new file mode 100644 index 0000000..212cf16 --- /dev/null +++ b/scripts/batch54-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 54 InfiniteTalk render — "Last-Minute Escape" Hour-to-Paradise spin. + * + * npx tsx scripts/batch54-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B54 = [ + 'b54-01-last-minute', 'b54-02-needed-out', 'b54-03-cheap-enough', 'b54-04-no-overthink', 'b54-05-the-hour-fast', + 'b54-06-dates-flexible', 'b54-07-mental-health', 'b54-08-told-no-one', 'b54-09-back-recharged', 'b54-10-cta-need-break', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18908, scripts: ALL_B54 }, +} + +const KEYFRAME_MAP: Record = { + 'b54-01-last-minute': 'keyframe-v13-1.jpg', + 'b54-02-needed-out': 'keyframe-v13-1.jpg', + 'b54-03-cheap-enough': 'keyframe-v13-2.jpg', + 'b54-04-no-overthink': 'keyframe-v13-2.jpg', + 'b54-05-the-hour-fast': 'keyframe-v13-3.jpg', + 'b54-06-dates-flexible': 'keyframe-v13-3.jpg', + 'b54-07-mental-health': 'keyframe-v13-4.jpg', + 'b54-08-told-no-one': 'keyframe-v13-4.jpg', + 'b54-09-back-recharged': 'keyframe-v13-5.jpg', + 'b54-10-cta-need-break': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch54-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b54', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b54-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch54/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch54')) mkdirSync('public/videos/ugc/batch54', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch54/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch54/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch54/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch54-scripts.json b/scripts/batch54-scripts.json new file mode 100644 index 0000000..bf72e1d --- /dev/null +++ b/scripts/batch54-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 54 — 'Last-Minute Escape'. Spontaneity / need-a-break-now angle: booked on impulse when burnt out, no months of planning. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b54-01-last-minute", "angle": "Booked it last minute", "hook_text": "I hit a wall and booked an escape last minute", + "tts": "I hit a wall last week. Completely burnt out, done, fried. So I booked an escape on impulse. No months of planning. The certificate was two forty nine and now I am sitting on a beach in Mexico. Link in my bio, hi2b dot com." }, + { "id": "b54-02-needed-out", "angle": "I just needed OUT", "hook_text": "sometimes you just need to get OUT", + "tts": "Sometimes you do not need a perfectly planned two week itinerary. You just need to get out. Out of the house, out of the routine, out of your own head. This was the fastest, cheapest way out I could find. Link in my bio, hi2b dot com." }, + { "id": "b54-03-cheap-enough", "angle": "Cheap enough to be impulsive", "hook_text": "it's cheap enough to actually be impulsive", + "tts": "Here is why I could be impulsive about it. At two forty nine, a spontaneous trip does not wreck the budget. You cannot impulse book a normal resort vacation. You can impulse book this one. Link in my bio, hi2b dot com." }, + { "id": "b54-04-no-overthink", "angle": "No time to overthink", "hook_text": "I booked before I could talk myself out of it", + "tts": "I booked it before I could talk myself out of it. Usually I deliberate for weeks and the trip never happens. This time I paid in five minutes while I still had the nerve. Best decision of my month. Link in my bio, hi2b dot com." }, + { "id": "b54-05-the-hour-fast", "angle": "Even the hour was quick", "hook_text": "even the welcome hour was a quick yes", + "tts": "Even the welcome hour did not slow me down. Sixty minutes, a polite no thank you, wristband, pool. I went from burnt out at my desk to a drink on the sand in about two days flat. Link in my bio, hi2b dot com." }, + { "id": "b54-06-dates-flexible", "angle": "Dates were flexible enough", "hook_text": "I found dates faster than I expected", + "tts": "I worried last minute would mean no availability. I requested dates and found something within a couple weeks. Book a little ahead and even spontaneous works. It is not as rigid as I feared. Link in my bio, hi2b dot com." }, + { "id": "b54-07-mental-health", "angle": "It was a mental health move", "hook_text": "this was a mental-health decision, honestly", + "tts": "Honestly this was a mental health decision. I was running on empty and a cheap five day reset was smarter and cheaper than crashing completely. Sometimes a beach is the responsible choice. Link in my bio, hi2b dot com." }, + { "id": "b54-08-told-no-one", "angle": "Told almost no one", "hook_text": "I barely told anyone, just left", + "tts": "I barely told anyone. No big announcement, no asking permission, no committee. I just quietly booked it and left. There is something powerful about deciding your own break without a debate. Link in my bio, hi2b dot com." }, + { "id": "b54-09-back-recharged", "angle": "Coming back recharged", "hook_text": "I'm going home actually recharged", + "tts": "I am going home actually recharged for the first time in forever. The spontaneous five day reset did more than a long weekend ever could. I should have done this the last three times I was this tired. Link in my bio, hi2b dot com." }, + { "id": "b54-10-cta-need-break", "angle": "Need a break? Just go", "hook_text": "if you need a break — just go, it's affordable", + "tts": "If you are reading this exhausted, take it as a sign. You can actually afford to just go. Two forty nine, five days, pick a beach. Stop waiting for the perfect time to rest. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch55-render.ts b/scripts/batch55-render.ts new file mode 100644 index 0000000..ed86933 --- /dev/null +++ b/scripts/batch55-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 55 InfiniteTalk render — "Honeymoon on a Budget" Hour-to-Paradise spin. + * + * npx tsx scripts/batch55-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B55 = [ + 'b55-01-honeymoon', 'b55-02-wedding-broke', 'b55-03-still-romantic', 'b55-04-all-included', 'b55-05-the-hour-newly', + 'b55-06-engaged-tip', 'b55-07-more-budget', 'b55-08-photos-newly', 'b55-09-do-anniversary', 'b55-10-cta-newlyweds', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18909, scripts: ALL_B55 }, +} + +const KEYFRAME_MAP: Record = { + 'b55-01-honeymoon': 'keyframe-v13-1.jpg', + 'b55-02-wedding-broke': 'keyframe-v13-1.jpg', + 'b55-03-still-romantic': 'keyframe-v13-2.jpg', + 'b55-04-all-included': 'keyframe-v13-2.jpg', + 'b55-05-the-hour-newly': 'keyframe-v13-3.jpg', + 'b55-06-engaged-tip': 'keyframe-v13-3.jpg', + 'b55-07-more-budget': 'keyframe-v13-4.jpg', + 'b55-08-photos-newly': 'keyframe-v13-4.jpg', + 'b55-09-do-anniversary': 'keyframe-v13-5.jpg', + 'b55-10-cta-newlyweds': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch55-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b55', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b55-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch55/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch55')) mkdirSync('public/videos/ugc/batch55', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch55/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch55/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch55/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch55-scripts.json b/scripts/batch55-scripts.json new file mode 100644 index 0000000..ab23841 --- /dev/null +++ b/scripts/batch55-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 55 — 'Honeymoon on a Budget'. Newlyweds / engaged angle: dream honeymoon without the honeymoon price tag. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b55-01-honeymoon", "angle": "Honeymoon without the price", "hook_text": "we did our honeymoon without the honeymoon price", + "tts": "We did our honeymoon without the honeymoon price tag. Everyone said save for years for the trip. We spent two forty nine on a certificate and we are on a Mexican beach as newlyweds right now. Link in my bio, hi2b dot com." }, + { "id": "b55-02-wedding-broke", "angle": "The wedding drained us", "hook_text": "the wedding drained us — this didn't", + "tts": "The wedding drained our savings like every wedding does. A normal honeymoon felt impossible right after. This made it possible. We did not have to choose between the wedding and the trip. Link in my bio, hi2b dot com." }, + { "id": "b55-03-still-romantic", "angle": "Cheap but still romantic", "hook_text": "cheap doesn't mean it isn't romantic", + "tts": "Cheap does not mean it is not romantic. Sunset dinners, the beach, our own little balcony. It felt every bit like the honeymoon we pictured. The price tag was the only un-fancy part. Link in my bio, hi2b dot com." }, + { "id": "b55-04-all-included", "angle": "All-inclusive = no money talk", "hook_text": "no budgeting arguments on our honeymoon", + "tts": "All inclusive meant no budgeting arguments on our honeymoon, which is the last thing newlyweds need. Food, drinks, the beach, all handled. We just enjoyed each other instead of watching a tab. Link in my bio, hi2b dot com." }, + { "id": "b55-05-the-hour-newly", "angle": "The hour as a newlywed thing", "hook_text": "even the welcome hour became a cute memory", + "tts": "Even the welcome hour became a cute newlywed memory. Sitting together as a married couple for the first time, politely saying no thanks, laughing about being adults now. Sixty minutes, then paradise. Link in my bio, hi2b dot com." }, + { "id": "b55-06-engaged-tip", "angle": "Tip for the engaged", "hook_text": "if you're engaged, this is your honeymoon hack", + "tts": "If you are engaged right now, this is your honeymoon hack. Buy the certificate before the wedding bills pile up. Lock in the trip while you can, do it after the chaos. Future married you will thank you. Link in my bio, hi2b dot com." }, + { "id": "b55-07-more-budget", "angle": "Spend savings on the marriage", "hook_text": "we'd rather save for the marriage, not one trip", + "tts": "We would rather save our money for the actual marriage, the house, the life, not blow it all on one trip. This let us have the dream honeymoon and keep our savings for what comes next. Link in my bio, hi2b dot com." }, + { "id": "b55-08-photos-newly", "angle": "The newlywed photos", "hook_text": "the just-married photos here are unreal", + "tts": "The just married photos here are unreal. Us on the beach, still in honeymoon glow, the water behind us. Nobody scrolling these will guess the whole trip was two forty nine. Link in my bio, hi2b dot com." }, + { "id": "b55-09-do-anniversary", "angle": "Already planning anniversary", "hook_text": "we're already planning our anniversary trip", + "tts": "We are already planning our first anniversary trip here too. When the honeymoon is this affordable, you do not stop at one. We found a tradition on day one of our marriage. Link in my bio, hi2b dot com." }, + { "id": "b55-10-cta-newlyweds", "angle": "Newlyweds — this is your trip", "hook_text": "newlyweds — book the honeymoon you can afford", + "tts": "Newlyweds and soon to be, this is your trip. Book the honeymoon you can actually afford and keep your savings for the marriage. Two forty nine, five days, four beaches to pick from. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch56-render.ts b/scripts/batch56-render.ts new file mode 100644 index 0000000..b823add --- /dev/null +++ b/scripts/batch56-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 56 InfiniteTalk render — "Retirement Travel Hack" Hour-to-Paradise spin. + * + * npx tsx scripts/batch56-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B56 = [ + 'b56-01-retire-hack', 'b56-02-fixed-income', 'b56-03-time-now', 'b56-04-the-hour-us', 'b56-05-no-membership', + 'b56-06-grandkids', 'b56-07-bucket-now', 'b56-08-easy-trip', 'b56-09-tell-friends', 'b56-10-cta-retirees', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18910, scripts: ALL_B56 }, +} + +const KEYFRAME_MAP: Record = { + 'b56-01-retire-hack': 'keyframe-v13-1.jpg', + 'b56-02-fixed-income': 'keyframe-v13-1.jpg', + 'b56-03-time-now': 'keyframe-v13-2.jpg', + 'b56-04-the-hour-us': 'keyframe-v13-2.jpg', + 'b56-05-no-membership': 'keyframe-v13-3.jpg', + 'b56-06-grandkids': 'keyframe-v13-3.jpg', + 'b56-07-bucket-now': 'keyframe-v13-4.jpg', + 'b56-08-easy-trip': 'keyframe-v13-4.jpg', + 'b56-09-tell-friends': 'keyframe-v13-5.jpg', + 'b56-10-cta-retirees': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch56-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b56', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b56-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch56/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch56')) mkdirSync('public/videos/ugc/batch56', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch56/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch56/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch56/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch56-scripts.json b/scripts/batch56-scripts.json new file mode 100644 index 0000000..457eb92 --- /dev/null +++ b/scripts/batch56-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 56 — 'Retirement Travel Hack'. Older / retired audience: stretch a fixed income into more trips. Single speaker (Sarah at resort), 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b56-01-retire-hack", "angle": "Retirement travel hack", "hook_text": "the travel hack every retiree should know", + "tts": "Here is the travel hack every retiree should know. You do not have to spend your fixed income on one big trip a year. At two hundred forty nine dollars a certificate, you can take several. We are on our second this year already. Link in my bio, hi2b dot com." }, + { "id": "b56-02-fixed-income", "angle": "Stretch a fixed income", "hook_text": "this stretches a fixed income further than anything", + "tts": "This stretches a fixed income further than anything we have found. The pension only goes so far, but a five day all inclusive trip for two forty nine? That fits the budget with room to spare. Link in my bio, hi2b dot com." }, + { "id": "b56-03-time-now", "angle": "We finally have the time", "hook_text": "we finally have the time — now it's affordable too", + "tts": "We worked our whole lives waiting for the time to travel. Now we have the time and this finally made it affordable too. Time and money lining up at last, thanks to a cheap little certificate. Link in my bio, hi2b dot com." }, + { "id": "b56-04-the-hour-us", "angle": "The hour is easy for us", "hook_text": "the hour? we've got nothing but time", + "tts": "Worried about the hour presentation? We are retired. We have got nothing but time. Sixty relaxed minutes, a polite no thank you, and then the beach. Easiest hour of our week. Link in my bio, hi2b dot com." }, + { "id": "b56-05-no-membership", "angle": "No, we don't want the membership","hook_text": "we said no to the membership — easy at our age", + "tts": "We said no to the membership without a second thought. At our age we are not signing up for decades of anything. We took the cheap trip, said no thanks, and enjoyed the sun. Simple. Link in my bio, hi2b dot com." }, + { "id": "b56-06-grandkids", "angle": "Bring the grandkids free", "hook_text": "we brought the grandkids — kids stay free", + "tts": "We brought the grandkids on this one because kids stay free. Watching them at the ocean, spoiling them rotten, all without it costing us extra. That is the kind of memory we retired for. Link in my bio, hi2b dot com." }, + { "id": "b56-07-bucket-now", "angle": "Do the list while healthy", "hook_text": "do the travel list while you're still healthy", + "tts": "Do your travel list now while you are still healthy enough to enjoy it. We waited long enough. At this price there is no reason to keep putting the beach off another year. Link in my bio, hi2b dot com." }, + { "id": "b56-08-easy-trip", "angle": "All-inclusive = easy on us", "hook_text": "all-inclusive is easy on these old knees", + "tts": "All inclusive is easy on these old knees. Everything is on the property. No hauling luggage across a city, no hunting for restaurants. We walk from the room to the pool to dinner and back. Perfect pace. Link in my bio, hi2b dot com." }, + { "id": "b56-09-tell-friends", "angle": "Telling all our friends", "hook_text": "we're telling everyone at the senior center", + "tts": "We are telling everyone at the senior center about this. The whole group is talking about doing a trip together now. Cheap enough that nobody on a pension has to sit it out. Link in my bio, hi2b dot com." }, + { "id": "b56-10-cta-retirees", "angle": "Retirees — travel more for less","hook_text": "retirees — you can travel more than you think", + "tts": "Retirees, you can travel more than you think on what you have. Stop saving the trips for a someday that keeps slipping. Two forty nine, five days, four beaches. Do it while you can. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch57-render.ts b/scripts/batch57-render.ts new file mode 100644 index 0000000..9503ae6 --- /dev/null +++ b/scripts/batch57-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 57 InfiniteTalk render — "Dad Who Hates Spending" Hour-to-Paradise spin. + * + * npx tsx scripts/batch57-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B57 = [ + 'b57-01-cheap-husband', 'b57-02-the-number', 'b57-03-he-checked', 'b57-04-the-hour-him', 'b57-05-no-upsell', + 'b57-06-kids-free-dad', 'b57-07-now-happy', 'b57-08-bragging', 'b57-09-wants-again', 'b57-10-cta-frugal', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18911, scripts: ALL_B57 }, +} + +const KEYFRAME_MAP: Record = { + 'b57-01-cheap-husband': 'keyframe-v13-1.jpg', + 'b57-02-the-number': 'keyframe-v13-1.jpg', + 'b57-03-he-checked': 'keyframe-v13-2.jpg', + 'b57-04-the-hour-him': 'keyframe-v13-2.jpg', + 'b57-05-no-upsell': 'keyframe-v13-3.jpg', + 'b57-06-kids-free-dad': 'keyframe-v13-3.jpg', + 'b57-07-now-happy': 'keyframe-v13-4.jpg', + 'b57-08-bragging': 'keyframe-v13-4.jpg', + 'b57-09-wants-again': 'keyframe-v13-5.jpg', + 'b57-10-cta-frugal': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch57-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b57', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b57-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch57/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch57')) mkdirSync('public/videos/ugc/batch57', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch57/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch57/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch57/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch57-scripts.json b/scripts/batch57-scripts.json new file mode 100644 index 0000000..b966827 --- /dev/null +++ b/scripts/batch57-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 57 — 'Dad Who Hates Spending'. Frugal-dad / budget-skeptic-husband POV: the cheapskate finally approved a vacation. Single speaker (Sarah at resort, narrating about her husband). 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b57-01-cheap-husband", "angle": "My frugal husband approved it", "hook_text": "my cheapskate husband actually approved a vacation", + "tts": "My husband is the cheapest man alive and he actually approved a vacation. If you knew this man, you would understand that is a miracle. The number that did it was two hundred forty nine dollars. Link in my bio, hi2b dot com." }, + { "id": "b57-02-the-number", "angle": "The number that won him over", "hook_text": "the price was the only thing that won him over", + "tts": "Nothing wins this man over except a low number, and this was a low number. Five days all inclusive for the price he would spend on one tank of gas plus lunch. He could not argue with the math. Link in my bio, hi2b dot com." }, + { "id": "b57-03-he-checked", "angle": "He triple-checked the catch", "hook_text": "he triple-checked it for the catch", + "tts": "He triple checked it looking for the catch because that is who he is. The only catch is the one hour presentation. He decided one hour of his life was worth a five day trip. For him, that is reckless spending. Link in my bio, hi2b dot com." }, + { "id": "b57-04-the-hour-him", "angle": "He sat through the hour gladly", "hook_text": "he happily sat through the hour to save money", + "tts": "He happily sat through the entire hour because sitting through an hour to save thousands is exactly his love language. He took notes. He asked about the math. Then he said no thanks and grinned. Link in my bio, hi2b dot com." }, + { "id": "b57-05-no-upsell", "angle": "He said no to the upsell easily", "hook_text": "saying no to the membership? his specialty", + "tts": "Saying no to the membership upsell? That is his specialty. This is a man who has talked down a car salesman to tears. Sixty seconds and a firm no thank you. Then we went to the pool. Link in my bio, hi2b dot com." }, + { "id": "b57-06-kids-free-dad", "angle": "Kids free sealed it", "hook_text": "kids stay free is what truly sealed it for him", + "tts": "Kids stay free is what truly sealed it for him. The man would normally calculate the cost per child of a vacation. Free meant his spreadsheet had nothing to complain about. Approved. Link in my bio, hi2b dot com." }, + { "id": "b57-07-now-happy", "angle": "Now he's the happiest one here", "hook_text": "now he's somehow the happiest one at the resort", + "tts": "And now? He is somehow the happiest person at this resort. The cheapest man alive is on his third buffet plate grinning because it is all included. Watching him relax about money is worth the trip alone. Link in my bio, hi2b dot com." }, + { "id": "b57-08-bragging", "angle": "He's bragging about the deal", "hook_text": "he won't stop bragging about the deal he got", + "tts": "He will not stop bragging about the deal. He has told the bartender, the family at the next table, and a stranger by the pool that this trip cost two forty nine. He is more proud of the price than the vacation. Link in my bio, hi2b dot com." }, + { "id": "b57-09-wants-again", "angle": "He already wants to rebook", "hook_text": "the frugal man already wants to do it again", + "tts": "The frugal man already wants to do it again. He never wants to do anything again because everything costs money. But this? He is already eyeing a second certificate for the fall. I am stunned. Link in my bio, hi2b dot com." }, + { "id": "b57-10-cta-frugal", "angle": "For the budget skeptics", "hook_text": "if your partner is a cheapskate — send them this", + "tts": "If your partner is the budget police, send them this. The math is on your side this time. Two forty nine, five days, kids free, one hour of saying no. Even a cheapskate cannot argue. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch58-render.ts b/scripts/batch58-render.ts new file mode 100644 index 0000000..f344fdd --- /dev/null +++ b/scripts/batch58-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 58 InfiniteTalk render — "I Took My Mom" Hour-to-Paradise spin. + * + * npx tsx scripts/batch58-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B58 = [ + 'b58-01-took-my-mom', 'b58-02-she-never-went', 'b58-03-while-she-can', 'b58-04-cheap-enough', 'b58-05-her-face', + 'b58-06-the-hour-mom', 'b58-07-best-money', 'b58-08-grandkids-too', 'b58-09-no-regret', 'b58-10-cta-take-parents', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18912, scripts: ALL_B58 }, +} + +const KEYFRAME_MAP: Record = { + 'b58-01-took-my-mom': 'keyframe-v13-1.jpg', + 'b58-02-she-never-went': 'keyframe-v13-1.jpg', + 'b58-03-while-she-can': 'keyframe-v13-2.jpg', + 'b58-04-cheap-enough': 'keyframe-v13-2.jpg', + 'b58-05-her-face': 'keyframe-v13-3.jpg', + 'b58-06-the-hour-mom': 'keyframe-v13-3.jpg', + 'b58-07-best-money': 'keyframe-v13-4.jpg', + 'b58-08-grandkids-too': 'keyframe-v13-4.jpg', + 'b58-09-no-regret': 'keyframe-v13-5.jpg', + 'b58-10-cta-take-parents': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch58-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b58', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b58-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch58/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch58')) mkdirSync('public/videos/ugc/batch58', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch58/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch58/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch58/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch58-scripts.json b/scripts/batch58-scripts.json new file mode 100644 index 0000000..cfcd1f3 --- /dev/null +++ b/scripts/batch58-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 58 — 'I Took My Mom'. Adult-child takes their aging mother on the trip; give-while-they-can-enjoy-it / memory-before-it's-too-late angle. Single speaker (Sarah at resort). 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b58-01-took-my-mom", "angle": "I took my mom", "hook_text": "I finally took my mom on a real vacation", + "tts": "I finally took my mom on a real vacation. She spent her whole life giving and never going anywhere for herself. This cost me two hundred forty nine dollars and it is the best thing I have ever done with money. Link in my bio, hi2b dot com." }, + { "id": "b58-02-she-never-went", "angle": "She never spent on herself", "hook_text": "she never once spent money on herself", + "tts": "My mom never once spent money on herself. Always the kids, always the bills, always later. There was never a 'her' trip. So I bought it for her. Watching her at the ocean fixed something in me. Link in my bio, hi2b dot com." }, + { "id": "b58-03-while-she-can", "angle": "While she still can", "hook_text": "do it while they can still enjoy it", + "tts": "Take your parents while they can still enjoy it. The window is shorter than we admit. I did not want to be the kid who waited until it was too late to make this memory. Link in my bio, hi2b dot com." }, + { "id": "b58-04-cheap-enough", "angle": "Cheap enough to just do it", "hook_text": "it's cheap enough to just make it happen", + "tts": "The reason I could finally do it is the price. Two forty nine. I did not have to save for a year or feel guilty. I just made it happen. Do not let money be the reason you never take your mom. Link in my bio, hi2b dot com." }, + { "id": "b58-05-her-face", "angle": "Her face at the beach", "hook_text": "her face when she saw the ocean", + "tts": "Her face when she saw that water. She got quiet and her eyes filled up. She said she never thought she would see something like this. I will replay that moment for the rest of my life. Link in my bio, hi2b dot com." }, + { "id": "b58-06-the-hour-mom", "angle": "We did the hour together", "hook_text": "we did the welcome hour together, easy", + "tts": "We did the welcome hour together, mom and me. Sixty minutes, a polite no thank you, and we were laughing about it after. She has more patience than anyone, so the hour was nothing. Then five days that were just ours. Link in my bio, hi2b dot com." }, + { "id": "b58-07-best-money", "angle": "Best money I've spent", "hook_text": "best money I've ever spent, period", + "tts": "This is the best money I have ever spent, period. Not the car, not the gadgets. Five days giving my mother the trip she earned forty years ago. The memory dividend on this will pay me back forever. Link in my bio, hi2b dot com." }, + { "id": "b58-08-grandkids-too", "angle": "Three generations, kids free", "hook_text": "three generations together — kids stay free", + "tts": "We brought my kids too, so it was three generations on one beach. Kids stay free, so adding them cost nothing. My mom, me, and the grandkids in one photo. You cannot buy that twice. Link in my bio, hi2b dot com." }, + { "id": "b58-09-no-regret", "angle": "No regret either way", "hook_text": "I'll never regret taking her — only NOT", + "tts": "I will never regret taking my mom on this trip. The only thing I could have regretted was not doing it while I still could. That is the easiest decision there is. Link in my bio, hi2b dot com." }, + { "id": "b58-10-cta-take-parents","angle": "Take your parents", "hook_text": "take your parents — they earned it, it's cheap", + "tts": "Take your parents. They earned it and the clock is real. Two forty nine, five days, kids free so the grandkids come too. Stop waiting for the perfect time to give back. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/batch59-render.ts b/scripts/batch59-render.ts new file mode 100644 index 0000000..8b4f661 --- /dev/null +++ b/scripts/batch59-render.ts @@ -0,0 +1,179 @@ +/** + * Batch 59 InfiniteTalk render — "Winter Escape" Hour-to-Paradise spin. + * + * npx tsx scripts/batch59-render.ts inst1 + * + * Reuses keyframe-v13-1..5.jpg (single Sarah at resort). 5 keyframes × 2 scripts. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +interface InstanceCfg { + sshPort: number + sshHost: string + tunnelPort: number + scripts: string[] +} + +const ALL_B59 = [ + 'b59-01-escape-the-cold', 'b59-02-vitamin-sea', 'b59-03-winter-blues', 'b59-04-cheap-warm', 'b59-05-the-hour-winter', + 'b59-06-kids-snow-day', 'b59-07-recharge-q1', 'b59-08-no-guilt-winter', 'b59-09-while-its-cold', 'b59-10-cta-get-warm', +] +const INSTANCES: Record = { + inst1: { sshPort: 43312, sshHost: 'root@51.83.197.242', tunnelPort: 18913, scripts: ALL_B59 }, +} + +const KEYFRAME_MAP: Record = { + 'b59-01-escape-the-cold': 'keyframe-v13-1.jpg', + 'b59-02-vitamin-sea': 'keyframe-v13-1.jpg', + 'b59-03-winter-blues': 'keyframe-v13-2.jpg', + 'b59-04-cheap-warm': 'keyframe-v13-2.jpg', + 'b59-05-the-hour-winter': 'keyframe-v13-3.jpg', + 'b59-06-kids-snow-day': 'keyframe-v13-3.jpg', + 'b59-07-recharge-q1': 'keyframe-v13-4.jpg', + 'b59-08-no-guilt-winter': 'keyframe-v13-4.jpg', + 'b59-09-while-its-cold': 'keyframe-v13-5.jpg', + 'b59-10-cta-get-warm': 'keyframe-v13-5.jpg', +} +const ALL_KEYFRAMES = ['keyframe-v13-1.jpg', 'keyframe-v13-2.jpg', 'keyframe-v13-3.jpg', 'keyframe-v13-4.jpg', 'keyframe-v13-5.jpg'] + +const which = process.argv[2] +const cfg = INSTANCES[which] +if (!cfg) { console.error('usage: batch59-render.ts '); process.exit(1) } + +const HOST = `http://localhost:${cfg.tunnelPort}` +const WIDTH = 480, HEIGHT = 832, FPS = 25, FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A friendly woman in her early thirties at a luxury Mexican beach resort, taking a casual selfie video and talking warmly to the camera in an honest, direct, slightly conspiratorial tone — as if telling a friend the real catch of the deal. Natural head movements, relaxed candid expression. Resort beach, pool, or lobby backdrop softly blurred behind her. Bright tropical daylight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +function buildWorkflow(refImage: string, audioFile: string, numFrames: number): Record { + return { + '1': { class_type: 'LoadImage', inputs: { image: refImage } }, + '2': { class_type: 'ImageResizeKJv2', inputs: { + image: ['1', 0], width: WIDTH, height: HEIGHT, upscale_method: 'bicubic', + keep_proportion: 'resize', pad_color: '0, 0, 0', crop_position: 'center', + divisible_by: 16, device: 'cpu' } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['3', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + '6': { class_type: 'LoadAudio', inputs: { audio: audioFile } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + '8': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], normalize_loudness: true, + num_frames: numFrames, fps: FPS, audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + '9': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '10': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '11': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' } }, + '12': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['9', 0], lora: ['10', 0], multitalk_model: ['11', 0] } }, + '13': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '14': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + '15': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['13', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['3', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '16': { class_type: 'WanVideoSampler', inputs: { + model: ['12', 0], image_embeds: ['15', 0], text_embeds: ['14', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['8', 0] } }, + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { class_type: 'VHS_VideoCombine', inputs: { + images: ['17', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_b58', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, + } +} + +function sh(cmd: string): string { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function ensureTunnel() { + try { sh(`curl -s -m 5 ${HOST}/system_stats`) } + catch { + sh(`ssh -o StrictHostKeyChecking=no -p ${cfg.sshPort} -N -f -L ${cfg.tunnelPort}:localhost:18188 ${cfg.sshHost}`) + sh('sleep 4') + } +} + +async function renderOne(scriptId: string, audioSeconds: number): Promise { + const refImage = KEYFRAME_MAP[scriptId] + const audioFile = `${scriptId}.mp3` + const numFrames = Math.round(audioSeconds * FPS) + const wf = buildWorkflow(refImage, audioFile, numFrames) + console.log(`\n[${which}] === ${scriptId} (${refImage}, ${audioSeconds.toFixed(1)}s, ${numFrames}f) ===`) + + ensureTunnel() + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: wf, client_id: `b58-${scriptId}-${Date.now()}` }), + }) + if (!submit.ok) { console.error(` submit failed HTTP ${submit.status}: ${await submit.text()}`); return } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error(` NODE ERRORS: ${JSON.stringify(sj.node_errors).slice(0, 800)}`); return + } + const promptId = sj.prompt_id + console.log(` queued ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + ensureTunnel() + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error(` ERROR: ${JSON.stringify(st).slice(0, 800)}`); return } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n18 = entry.outputs?.['18'] + const files = (n18?.gifs || n18?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error(' no output'); return } + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const dest = `public/videos/ugc/batch59/${scriptId}.mp4` + writeFileSync(dest, buf) + console.log(` ✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB, ~${i * 5}s)`) + return + } + } + console.error(` TIMED OUT: ${scriptId}`) +} + +async function main() { + if (!existsSync('public/videos/ugc/batch59')) mkdirSync('public/videos/ugc/batch59', { recursive: true }) + + console.log(`[${which}] uploading inputs...`) + const keyframePaths = ALL_KEYFRAMES.map(k => `public/audio/fish/${k}`).join(' ') + const audioPaths = cfg.scripts.map(id => `public/audio/fish/batch59/${id}.mp3`).join(' ') + sh(`scp -o StrictHostKeyChecking=no -P ${cfg.sshPort} ${keyframePaths} ${audioPaths} ${cfg.sshHost}:/workspace/ComfyUI/input/`) + console.log(`[${which}] inputs uploaded`) + + for (const id of cfg.scripts) { + if (existsSync(`public/videos/ugc/batch59/${id}.mp4`)) { console.log(`[${which}] ${id} done, skip`); continue } + const durSec = parseFloat(sh(`ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 public/audio/fish/batch59/${id}.mp3`).trim()) + await renderOne(id, durSec) + } + console.log(`\n[${which}] BATCH COMPLETE`) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/batch59-scripts.json b/scripts/batch59-scripts.json new file mode 100644 index 0000000..78f2950 --- /dev/null +++ b/scripts/batch59-scripts.json @@ -0,0 +1,27 @@ +{ + "_note": "Batch 59 — 'Winter Escape'. Beat the cold/gray winter, vitamin D, seasonal blues, cheap warm getaway. Single speaker (Sarah at resort). 5 keyframes v13-1..5 (reused). Sarah voice. Single-speaker InfiniteTalk on Inst 2.", + "voice_ref": "933563129e564b19a115bedd57b7406a", + "voice_name": "Sarah", + "scripts": [ + { "id": "b59-01-escape-the-cold", "angle": "Escape the cold", "hook_text": "book your escape from the gray now", + "tts": "If winter is already wearing you down, book your escape now. Five days somewhere warm in the middle of the gray is not a luxury, it is maintenance. Two hundred forty nine dollars. Link in my bio, hi2b dot com." }, + { "id": "b59-02-vitamin-sea", "angle": "Vitamin sea", "hook_text": "your body needs the sun, not another sweater", + "tts": "Your body does not need another sweater, it needs sunlight. A few days on a warm beach in the dead of winter reset my whole mood. Sun, salt water, real rest. Link in my bio, hi2b dot com." }, + { "id": "b59-03-winter-blues", "angle": "Beat the winter blues", "hook_text": "the cure for February is a plane ticket", + "tts": "The cure for February is a plane ticket. When the days get short and dark and everyone is dragging, that is exactly when a warm escape pays for itself. And this one is cheap. Link in my bio, hi2b dot com." }, + { "id": "b59-04-cheap-warm", "angle": "Cheap and warm", "hook_text": "warm getaways don't have to cost thousands", + "tts": "A warm winter getaway does not have to cost thousands. Mine was two forty nine for five days, all inclusive, kids free. The only catch is one short welcome talk. That is it. Link in my bio, hi2b dot com." }, + { "id": "b59-05-the-hour-winter", "angle": "The hour in winter", "hook_text": "one hour inside, then five days in the sun", + "tts": "Here is the honest catch. One hour at a welcome presentation, polite no thank you, done. In exchange you get five days of sun while everyone back home is scraping ice off the windshield. Worth every minute. Link in my bio, hi2b dot com." }, + { "id": "b59-06-kids-snow-day", "angle": "Kids out of the snow", "hook_text": "trade one snow day for a beach day", + "tts": "Trade one of those endless snow days for a real beach day. The kids stay free, so getting them out of the cold and into the ocean costs nothing extra. They will remember this winter forever. Link in my bio, hi2b dot com." }, + { "id": "b59-07-recharge-q1", "angle": "Recharge for the year", "hook_text": "start the year recharged, not run down", + "tts": "Do not start your year already run down. A few warm days in winter is how you come back actually recharged instead of just surviving until spring. Cheapest reset I have ever bought. Link in my bio, hi2b dot com." }, + { "id": "b59-08-no-guilt-winter", "angle": "No guilt", "hook_text": "you're allowed a warm break in winter", + "tts": "You are allowed to take a warm break in the middle of winter. You do not have to earn it or wait for the perfect time. At this price there is no guilt. Just go get some sun. Link in my bio, hi2b dot com." }, + { "id": "b59-09-while-its-cold", "angle": "Book while it's cold", "hook_text": "book it now while you still hate the weather", + "tts": "Book it right now while you still hate the weather, because by summer you will forget how miserable this felt. Lock in the warm escape today. Two forty nine, five days, kids free. Link in my bio, hi2b dot com." }, + { "id": "b59-10-cta-get-warm", "angle": "Go get warm", "hook_text": "stop suffering the winter — go get warm", + "tts": "Stop white knuckling your way through another winter. Two hundred forty nine dollars, five days in the sun, kids stay free, one honest hour is the only catch. Go get warm. The certificate is on hi2b dot com, link in my bio." } + ] +} diff --git a/scripts/build-fuckit-set.ts b/scripts/build-fuckit-set.ts new file mode 100644 index 0000000..0bf7f5a --- /dev/null +++ b/scripts/build-fuckit-set.ts @@ -0,0 +1,23 @@ +/** + * Build all fuck_it_closers as kinetic quote cards across the 8 mamas, deploy to gw. + * npx tsx scripts/build-fuckit-set.ts + */ +import { execSync } from 'child_process' +import { readFileSync, existsSync, mkdirSync } from 'fs' + +const lib = JSON.parse(readFileSync('scripts/travel-quotes.json', 'utf8')) +const quotes: string[] = lib.fuck_it_closers +const OUTDIR = 'public/videos/quote-cards' +if (!existsSync(OUTDIR)) mkdirSync(OUTDIR, { recursive: true }) + +quotes.forEach((q, i) => { + const n = String(i + 1).padStart(2, '0') + const mama = `public/images/quote-cards/mama-${String((i % 8) + 1).padStart(2, '0')}.png` + const out = `${OUTDIR}/qc-fuckit-${n}.mp4` + if (existsSync(out)) { console.log(`qc-fuckit-${n} exists, skip`); return } + try { + execSync(`npx tsx scripts/build-quote-card.ts "${mama}" "${out}" ${JSON.stringify(q)}`, { stdio: ['ignore', 'pipe', 'pipe'] }) + console.log(`✓ qc-fuckit-${n} ${mama.split('/').pop()} "${q}"`) + } catch (e: any) { console.error(`✗ qc-fuckit-${n}: ${e.message?.slice(0, 200)}`) } +}) +console.log('built. count:', execSync(`ls ${OUTDIR}/qc-fuckit-*.mp4 2>/dev/null | wc -l`).toString().trim()) diff --git a/scripts/build-quote-card.ts b/scripts/build-quote-card.ts new file mode 100644 index 0000000..4effd06 --- /dev/null +++ b/scripts/build-quote-card.ts @@ -0,0 +1,81 @@ +/** + * Build a 9:16 kinetic-caption quote card video from a mama still + a quote. + * npx tsx scripts/build-quote-card.ts "" + * Words reveal 1-3 at a time (punchy ~0.55s), centered in the safe zone, + * subtle slow zoom on the ocean, CTA flash at the end. Adaptive 7-12s. + */ +import { execSync } from 'child_process' +import { writeFileSync, mkdirSync, existsSync } from 'fs' + +const [, , imagePath, outPath, quoteRaw] = process.argv +if (!imagePath || !outPath || !quoteRaw) { console.error('usage: build-quote-card.ts ""'); process.exit(1) } + +const FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' +const W = 1080, H = 1920, FPS = 25 +const CTA = 'hi2b.com · link in bio\n$29/mo · kids free' + +// --- chunk the quote into 1-3 word beats (emphatic short words go solo) --- +function chunk(q: string): string[] { + const words = q.replace(/\s+/g, ' ').trim().split(' ') + const out: string[] = [] + let i = 0 + const solo = /^(fuck|now|go|stop|just|no|yes|today|wait|she|he|you|book|it\.?|it!?)$/i + while (i < words.length) { + const w = words[i] + if (solo.test(w.replace(/[.,!?'"]/g, '')) || w.length >= 9) { out.push(w); i += 1; continue } + // group 2 words by default, 1 if next is emphatic/long + const next = words[i + 1] + if (next && !solo.test(next.replace(/[.,!?'"]/g, '')) && next.length < 9) { out.push(`${w} ${next}`); i += 2 } + else { out.push(w); i += 1 } + } + return out +} + +const chunks = chunk(quoteRaw).map(c => c.toUpperCase()) +const ESTAB = 1.2 // ocean breathes before text +const HOLD = 2.0 // last line holds +const CTA_T = 1.6 // CTA flash +const CAP = 12 // hard max clip length +// adaptive seconds-per-chunk: prefer 0.55s, but speed up so ALL chunks fit +// before the CTA on long quotes (never truncate). Floor at 0.34s (still readable). +const avail = CAP - ESTAB - HOLD - CTA_T +const PER = Math.max(0.34, Math.min(0.55, avail / Math.max(chunks.length, 1))) +const quoteDur = chunks.length * PER +let DUR = ESTAB + quoteDur + HOLD + CTA_T +DUR = Math.max(7, Math.min(CAP, DUR)) +const frames = Math.round(DUR * FPS) + +// temp text files (avoids ffmpeg escaping) +const tmp = `/tmp/qc-${Date.now()}` +mkdirSync(tmp, { recursive: true }) +chunks.forEach((c, i) => writeFileSync(`${tmp}/c${i}.txt`, c)) +writeFileSync(`${tmp}/cta.txt`, CTA) + +// build drawtext filters: each chunk visible between [start,end]; last chunk holds through HOLD +const ctaStart = DUR - CTA_T +let dt = '' +chunks.forEach((_, i) => { + const start = ESTAB + i * PER + const end = i === chunks.length - 1 ? ctaStart : start + PER + dt += `,drawtext=fontfile=${FONT}:textfile=${tmp}/c${i}.txt:fontcolor=white:fontsize=118:line_spacing=12:borderw=7:bordercolor=black@0.55:` + + `box=1:boxcolor=black@0.18:boxborderw=28:x=(w-text_w)/2:y=(h-text_h)/2:enable='between(t,${start.toFixed(2)},${end.toFixed(2)})'` +}) +// CTA flash near bottom-safe (but above TikTok UI band) +dt += `,drawtext=fontfile=${FONT}:textfile=${tmp}/cta.txt:fontcolor=white:fontsize=64:line_spacing=14:borderw=6:bordercolor=black@0.6:` + + `box=1:boxcolor=black@0.30:boxborderw=26:x=(w-text_w)/2:y=h*0.40:enable='between(t,${ctaStart.toFixed(2)},${DUR.toFixed(2)})'` + +// subtle slow zoom (Ken-Burns) on the still, filled to 9:16 +const filter = + `[0:v]scale=1350:2400:force_original_aspect_ratio=increase,crop=1350:2400,` + + `zoompan=z='min(zoom+0.00035,1.10)':d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${W}x${H}:fps=${FPS}` + + dt + +const cmd = + `ffmpeg -y -loop 1 -t ${DUR.toFixed(2)} -i "${imagePath}" ` + + `-filter_complex "${filter}" -c:v libx264 -pix_fmt yuv420p -r ${FPS} -t ${DUR.toFixed(2)} "${outPath}"` + +if (!existsSync(outPath.replace(/\/[^/]+$/, ''))) mkdirSync(outPath.replace(/\/[^/]+$/, ''), { recursive: true }) +console.log(`chunks (${chunks.length}): ${chunks.join(' | ')}`) +console.log(`duration ${DUR.toFixed(1)}s`) +execSync(cmd, { stdio: ['ignore', 'ignore', 'pipe'] }) +console.log(`✓ ${outPath}`) diff --git a/scripts/build-wife-sets.ts b/scripts/build-wife-sets.ts new file mode 100644 index 0000000..e788bce --- /dev/null +++ b/scripts/build-wife-sets.ts @@ -0,0 +1,31 @@ +/** + * Build wife-to-wife kinetic quote cards (husband + kids themes), deploy-ready. + * npx tsx scripts/build-wife-sets.ts + * Cycles the 8 mama stills. Outputs to public/videos/quote-cards/. + */ +import { execSync } from 'child_process' +import { readFileSync, existsSync, mkdirSync } from 'fs' + +const lib = JSON.parse(readFileSync('scripts/travel-quotes.json', 'utf8')) +const OUTDIR = 'public/videos/quote-cards' +if (!existsSync(OUTDIR)) mkdirSync(OUTDIR, { recursive: true }) + +const SETS: { key: string; prefix: string }[] = [ + { key: 'wife_to_wife_husband', prefix: 'qc-wifehubby' }, + { key: 'wife_to_wife_kids', prefix: 'qc-wifekids' }, +] + +for (const { key, prefix } of SETS) { + const quotes: string[] = lib[key] || [] + quotes.forEach((q, i) => { + const n = String(i + 1).padStart(2, '0') + const mama = `public/images/quote-cards/mama-${String((i % 8) + 1).padStart(2, '0')}.png` + const out = `${OUTDIR}/${prefix}-${n}.mp4` + if (existsSync(out)) { console.log(`${prefix}-${n} exists, skip`); return } + try { + execSync(`npx tsx scripts/build-quote-card.ts "${mama}" "${out}" ${JSON.stringify(q)}`, { stdio: ['ignore', 'pipe', 'pipe'] }) + console.log(`✓ ${prefix}-${n} ${mama.split('/').pop()} "${q.slice(0, 48)}…"`) + } catch (e: any) { console.error(`✗ ${prefix}-${n}: ${e.message?.slice(0, 200)}`) } + }) +} +console.log('built. count:', execSync(`ls ${OUTDIR}/qc-wife*.mp4 2>/dev/null | wc -l`).toString().trim()) diff --git a/scripts/burn_captions.sh b/scripts/burn_captions.sh new file mode 100755 index 0000000..1411e92 --- /dev/null +++ b/scripts/burn_captions.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Burn UGC caption .ass files into rendered batch videos. +# scripts/burn_captions.sh [dir] +# For each .mp4 with a matching .ass, writes -captioned.mp4. +set -u +DIR="${1:-public/videos/ugc/batch11}" +made=0 +for ass in "$DIR"/*.ass; do + [ -e "$ass" ] || continue + id=$(basename "$ass" .ass) + in="$DIR/$id.mp4" + out="$DIR/$id-captioned.mp4" + [ -f "$in" ] || { echo "skip $id — no video yet"; continue; } + [ -f "$out" ] && { echo "skip $id — already captioned"; continue; } + if ffmpeg -y -i "$in" -vf "ass=$ass" -c:v libx264 -crf 18 -preset medium -c:a copy "$out" 2>/dev/null; then + echo "captioned $id ($(du -h "$out" | cut -f1))" + made=$((made+1)) + else + echo "FAILED $id" + fi +done +echo "burned $made file(s)" diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts new file mode 100644 index 0000000..f512789 --- /dev/null +++ b/scripts/create-admin.ts @@ -0,0 +1,61 @@ +/** + * Create or reset an admin user. + * + * npx tsx scripts/create-admin.ts [full_name] + * + * Idempotent — if the email already exists the password_hash is rotated. + * Requires MYSQL_HOST/USER/PASSWORD/DATABASE in env (read /opt/hi2b/.env on prod). + */ +import 'dotenv/config' +import bcrypt from 'bcryptjs' +import mysql from 'mysql2/promise' + +async function main() { + const [email, password, fullName = 'Admin'] = process.argv.slice(2) + if (!email || !password) { + console.error('Usage: npx tsx scripts/create-admin.ts [full_name]') + process.exit(1) + } + + const { MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE } = process.env + if (!MYSQL_HOST || !MYSQL_USER || !MYSQL_PASSWORD || !MYSQL_DATABASE) { + console.error('MYSQL_* env vars missing') + process.exit(1) + } + + const conn = await mysql.createConnection({ + host: MYSQL_HOST, user: MYSQL_USER, password: MYSQL_PASSWORD, database: MYSQL_DATABASE, + }) + + await conn.execute(` + CREATE TABLE IF NOT EXISTS admin_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255), + role VARCHAR(50) DEFAULT 'admin', + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + `) + + const hash = await bcrypt.hash(password, 12) + const [existing] = await conn.execute('SELECT id FROM admin_users WHERE email = ?', [email]) as any[] + if (existing.length > 0) { + await conn.execute( + 'UPDATE admin_users SET password_hash = ?, full_name = ?, status = ?, role = ? WHERE email = ?', + [hash, fullName, 'active', 'admin', email] + ) + console.log(`Rotated password for existing admin: ${email}`) + } else { + await conn.execute( + 'INSERT INTO admin_users (email, password_hash, full_name, role, status) VALUES (?, ?, ?, ?, ?)', + [email, hash, fullName, 'admin', 'active'] + ) + console.log(`Created admin: ${email}`) + } + await conn.end() +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/scripts/fetch-tiktok-thumbnails.ts b/scripts/fetch-tiktok-thumbnails.ts new file mode 100644 index 0000000..6e8df64 --- /dev/null +++ b/scripts/fetch-tiktok-thumbnails.ts @@ -0,0 +1,118 @@ +/** + * Fetch oEmbed metadata + local thumbnails for every TikTok video referenced + * in TikTokCarousel.tsx. Produces: + * - public/tiktok/.jpg (cached thumbnail, so we no longer rely on + * TikTok's signed, short-lived CDN URLs) + * - src/data/tiktok-videos.json (array of {id, username, title}) + * + * Run once (or whenever new videos are added): + * npx tsx scripts/fetch-tiktok-thumbnails.ts + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs' +import { join } from 'path' + +const CAROUSEL_PATH = join(__dirname, '..', 'src', 'components', 'lp', 'shared', 'TikTokCarousel.tsx') +const THUMB_DIR = join(__dirname, '..', 'public', 'tiktok') +const JSON_OUT = join(__dirname, '..', 'src', 'data', 'tiktok-videos.json') + +interface VideoMeta { + id: string + username: string + title: string +} + +function extractVideoIds(): VideoMeta[] { + const src = readFileSync(CAROUSEL_PATH, 'utf8') + const result: VideoMeta[] = [] + const blockRe = /const (T8|CS)_VIDEOS\s*=\s*\[([\s\S]*?)\]/g + let m + while ((m = blockRe.exec(src)) !== null) { + const key = m[1] + const username = key === 'T8' ? 'travel.to.mexico8' : 'chris724santos' + const idRe = /'(\d+)'/g + let idm + while ((idm = idRe.exec(m[2])) !== null) { + result.push({ id: idm[1], username, title: '' }) + } + } + return result +} + +async function fetchOembed(video: VideoMeta): Promise<{ thumbnail: string; title: string } | null> { + const url = `https://www.tiktok.com/@${video.username}/video/${video.id}` + try { + const res = await fetch(`https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`, { + signal: AbortSignal.timeout(15000), + }) + if (!res.ok) return null + const data = await res.json() as { thumbnail_url?: string; title?: string; author_unique_id?: string } + if (!data.thumbnail_url) return null + if (data.author_unique_id && data.author_unique_id !== video.username) return null + return { thumbnail: data.thumbnail_url, title: (data.title || '').slice(0, 140) } + } catch { return null } +} + +async function downloadThumb(url: string, dest: string): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(20000) }) + if (!res.ok) return false + const buf = Buffer.from(await res.arrayBuffer()) + if (buf.length < 1000) return false + writeFileSync(dest, buf) + return true + } catch { return false } +} + +async function worker(queue: VideoMeta[], results: VideoMeta[]) { + while (queue.length) { + const video = queue.shift()! + const thumbPath = join(THUMB_DIR, `${video.id}.jpg`) + + // Skip if already downloaded and non-tiny + if (existsSync(thumbPath) && statSync(thumbPath).size > 2000) { + results.push(video) + continue + } + + const meta = await fetchOembed(video) + if (!meta) { + console.warn(` SKIP ${video.username}/${video.id} — oEmbed failed`) + continue + } + const ok = await downloadThumb(meta.thumbnail, thumbPath) + if (!ok) { + console.warn(` SKIP ${video.username}/${video.id} — thumb download failed`) + continue + } + video.title = meta.title + results.push(video) + process.stdout.write('.') + } +} + +async function main() { + if (!existsSync(THUMB_DIR)) mkdirSync(THUMB_DIR, { recursive: true }) + if (!existsSync(join(__dirname, '..', 'src', 'data'))) { + mkdirSync(join(__dirname, '..', 'src', 'data'), { recursive: true }) + } + + const all = extractVideoIds() + console.log(`Fetching ${all.length} TikTok thumbnails...`) + + const queue = [...all] + const results: VideoMeta[] = [] + const CONCURRENCY = 8 + const workers = Array.from({ length: CONCURRENCY }, () => worker(queue, results)) + await Promise.all(workers) + + console.log(`\nDone. ${results.length}/${all.length} thumbnails available.`) + // Keep the original ordering from the source file + const byId: Record = {} + for (const r of results) byId[r.id] = r + const ordered = all.filter(v => byId[v.id]).map(v => byId[v.id]) + + writeFileSync(JSON_OUT, JSON.stringify(ordered, null, 2)) + console.log(`Wrote ${JSON_OUT} (${ordered.length} entries)`) +} + +main() diff --git a/scripts/finalize-b13.sh b/scripts/finalize-b13.sh new file mode 100755 index 0000000..1793e0f --- /dev/null +++ b/scripts/finalize-b13.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Autonomous batch-13 finisher: wait for all 10 renders, burn captions, deploy. +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch13 +LOG=/tmp/finalize-b13.log + +echo "waiting for 10 rendered videos... $(date)" > "$LOG" +until [ "$(ls "$DIR"/b13-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered, burning captions..." >> "$LOG" + +bash scripts/burn_captions.sh "$DIR" >> "$LOG" 2>&1 + +echo "deploying to gw..." >> "$LOG" +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch13' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/*-captioned.mp4 root@gw.724care.com:/var/www/html/batch13/ 2>/dev/null +echo "BATCH13 FINALIZED $(date)" >> "$LOG" +ls -la "$DIR"/*-captioned.mp4 >> "$LOG" 2>&1 diff --git a/scripts/finalize-batch11.sh b/scripts/finalize-batch11.sh new file mode 100755 index 0000000..836afb1 --- /dev/null +++ b/scripts/finalize-batch11.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Autonomous batch-11 finisher: wait for all 10 renders, burn captions, deploy. +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch11 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b11-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. burning captions..." + +bash scripts/burn_captions.sh "$DIR" + +echo "deploying to gw..." +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch11' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/*-captioned.mp4 root@gw.724care.com:/var/www/html/batch11/ 2>/dev/null +echo "BATCH11 FINALIZED $(date)" +ls -la "$DIR"/*-captioned.mp4 diff --git a/scripts/finalize-batch12.sh b/scripts/finalize-batch12.sh new file mode 100755 index 0000000..34a622a --- /dev/null +++ b/scripts/finalize-batch12.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-12 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch12 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b12-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch12' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b12-*.mp4 root@gw.724care.com:/var/www/html/batch12/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch12.html root@gw.724care.com:/var/www/html/batch12.html 2>/dev/null +echo "BATCH12 FINALIZED $(date)" +ls -la "$DIR"/b12-*.mp4 diff --git a/scripts/finalize-batch13.sh b/scripts/finalize-batch13.sh new file mode 100755 index 0000000..d154f39 --- /dev/null +++ b/scripts/finalize-batch13.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-12 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch13 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b13-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch13' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b13-*.mp4 root@gw.724care.com:/var/www/html/batch13/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch13.html root@gw.724care.com:/var/www/html/batch13.html 2>/dev/null +echo "BATCH13 FINALIZED $(date)" +ls -la "$DIR"/b13-*.mp4 diff --git a/scripts/finalize-batch14.sh b/scripts/finalize-batch14.sh new file mode 100755 index 0000000..69e7372 --- /dev/null +++ b/scripts/finalize-batch14.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-12 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch14 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b14-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch14' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b14-*.mp4 root@gw.724care.com:/var/www/html/batch14/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch14.html root@gw.724care.com:/var/www/html/batch14.html 2>/dev/null +echo "BATCH14 FINALIZED $(date)" +ls -la "$DIR"/b14-*.mp4 diff --git a/scripts/finalize-batch15.sh b/scripts/finalize-batch15.sh new file mode 100755 index 0000000..6774724 --- /dev/null +++ b/scripts/finalize-batch15.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch15 +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b15-*.mp4 2>/dev/null | grep -vc -- -amb)" -ge 10 ]; do sleep 60; done +echo "all 10 rendered." +bash scripts/mix-ambiance.sh +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'rm -f /var/www/html/batch15/*; mkdir -p /var/www/html/batch15' 2>/dev/null +# deploy ambiance-mixed if present, else the plain renders +if ls "$DIR"/b15-*-amb.mp4 >/dev/null 2>&1; then + for f in "$DIR"/b15-*-amb.mp4; do + base=$(basename "$f" -amb.mp4) + scp -o StrictHostKeyChecking=no "$f" root@gw.724care.com:/var/www/html/batch15/$base.mp4 2>/dev/null + done +else + scp -o StrictHostKeyChecking=no "$DIR"/b15-*.mp4 root@gw.724care.com:/var/www/html/batch15/ 2>/dev/null +fi +scp -o StrictHostKeyChecking=no public/research/batch15.html root@gw.724care.com:/var/www/html/batch15.html 2>/dev/null +echo "BATCH15 FINALIZED $(date)" diff --git a/scripts/finalize-batch16.sh b/scripts/finalize-batch16.sh new file mode 100755 index 0000000..f176e70 --- /dev/null +++ b/scripts/finalize-batch16.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch16 +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b16-*.mp4 2>/dev/null | grep -vc -- -amb)" -ge 10 ]; do sleep 60; done +echo "all 10 rendered." +bash scripts/mix-ambiance.sh +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'rm -f /var/www/html/batch16/*; mkdir -p /var/www/html/batch16' 2>/dev/null +# deploy ambiance-mixed if present, else the plain renders +if ls "$DIR"/b16-*-amb.mp4 >/dev/null 2>&1; then + for f in "$DIR"/b16-*-amb.mp4; do + base=$(basename "$f" -amb.mp4) + scp -o StrictHostKeyChecking=no "$f" root@gw.724care.com:/var/www/html/batch16/$base.mp4 2>/dev/null + done +else + scp -o StrictHostKeyChecking=no "$DIR"/b16-*.mp4 root@gw.724care.com:/var/www/html/batch16/ 2>/dev/null +fi +scp -o StrictHostKeyChecking=no public/research/batch16.html root@gw.724care.com:/var/www/html/batch16.html 2>/dev/null +echo "BATCH16 FINALIZED $(date)" diff --git a/scripts/finalize-batch17.sh b/scripts/finalize-batch17.sh new file mode 100755 index 0000000..b755193 --- /dev/null +++ b/scripts/finalize-batch17.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-12 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch17 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b17-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch17' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b17-*.mp4 root@gw.724care.com:/var/www/html/batch17/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch17.html root@gw.724care.com:/var/www/html/batch17.html 2>/dev/null +echo "BATCH17 FINALIZED $(date)" +ls -la "$DIR"/b17-*.mp4 diff --git a/scripts/finalize-batch18.sh b/scripts/finalize-batch18.sh new file mode 100755 index 0000000..63403fe --- /dev/null +++ b/scripts/finalize-batch18.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch18 +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b18-*.mp4 2>/dev/null | grep -vc -- -amb)" -ge 10 ]; do sleep 60; done +echo "all 10 rendered." +bash scripts/mix-ambiance.sh +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'rm -f /var/www/html/batch18/*; mkdir -p /var/www/html/batch18' 2>/dev/null +# deploy ambiance-mixed if present, else the plain renders +if ls "$DIR"/b18-*-amb.mp4 >/dev/null 2>&1; then + for f in "$DIR"/b18-*-amb.mp4; do + base=$(basename "$f" -amb.mp4) + scp -o StrictHostKeyChecking=no "$f" root@gw.724care.com:/var/www/html/batch18/$base.mp4 2>/dev/null + done +else + scp -o StrictHostKeyChecking=no "$DIR"/b18-*.mp4 root@gw.724care.com:/var/www/html/batch18/ 2>/dev/null +fi +scp -o StrictHostKeyChecking=no public/research/batch18.html root@gw.724care.com:/var/www/html/batch18.html 2>/dev/null +echo "BATCH18 FINALIZED $(date)" diff --git a/scripts/finalize-batch19.sh b/scripts/finalize-batch19.sh new file mode 100755 index 0000000..b475f3a --- /dev/null +++ b/scripts/finalize-batch19.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-12 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch19 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b19-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch19' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b19-*.mp4 root@gw.724care.com:/var/www/html/batch19/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch19.html root@gw.724care.com:/var/www/html/batch19.html 2>/dev/null +echo "BATCH19 FINALIZED $(date)" +ls -la "$DIR"/b19-*.mp4 diff --git a/scripts/finalize-batch20.sh b/scripts/finalize-batch20.sh new file mode 100755 index 0000000..84c6484 --- /dev/null +++ b/scripts/finalize-batch20.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-20 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch20 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b20-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch20' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b20-*.mp4 root@gw.724care.com:/var/www/html/batch20/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch20.html root@gw.724care.com:/var/www/html/batch20.html 2>/dev/null +echo "BATCH20 FINALIZED $(date)" +ls -la "$DIR"/b20-*.mp4 diff --git a/scripts/finalize-batch21.sh b/scripts/finalize-batch21.sh new file mode 100755 index 0000000..4a26072 --- /dev/null +++ b/scripts/finalize-batch21.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch21 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b21-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch21' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b21-*.mp4 root@gw.724care.com:/var/www/html/batch21/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch21.html root@gw.724care.com:/var/www/html/batch21.html 2>/dev/null +echo "BATCH21 FINALIZED $(date)" +ls -la "$DIR"/b21-*.mp4 diff --git a/scripts/finalize-batch22.sh b/scripts/finalize-batch22.sh new file mode 100755 index 0000000..9f6673f --- /dev/null +++ b/scripts/finalize-batch22.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-22 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch22 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b22-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch22' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b22-*.mp4 root@gw.724care.com:/var/www/html/batch22/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch22.html root@gw.724care.com:/var/www/html/batch22.html 2>/dev/null +echo "BATCH22 FINALIZED $(date)" +ls -la "$DIR"/b22-*.mp4 diff --git a/scripts/finalize-batch23.sh b/scripts/finalize-batch23.sh new file mode 100755 index 0000000..5f8242d --- /dev/null +++ b/scripts/finalize-batch23.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-23 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch23 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b23-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch23' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b23-*.mp4 root@gw.724care.com:/var/www/html/batch23/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch23.html root@gw.724care.com:/var/www/html/batch23.html 2>/dev/null +echo "BATCH23 FINALIZED $(date)" +ls -la "$DIR"/b23-*.mp4 diff --git a/scripts/finalize-batch24.sh b/scripts/finalize-batch24.sh new file mode 100755 index 0000000..59e464f --- /dev/null +++ b/scripts/finalize-batch24.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch24 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b24-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch24' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b24-*.mp4 root@gw.724care.com:/var/www/html/batch24/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch24.html root@gw.724care.com:/var/www/html/batch24.html 2>/dev/null +echo "BATCH24 FINALIZED $(date)" +ls -la "$DIR"/b24-*.mp4 diff --git a/scripts/finalize-batch25.sh b/scripts/finalize-batch25.sh new file mode 100755 index 0000000..5c918a5 --- /dev/null +++ b/scripts/finalize-batch25.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-20 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch25 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b25-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch25' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b25-*.mp4 root@gw.724care.com:/var/www/html/batch25/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch25.html root@gw.724care.com:/var/www/html/batch25.html 2>/dev/null +echo "BATCH25 FINALIZED $(date)" +ls -la "$DIR"/b25-*.mp4 diff --git a/scripts/finalize-batch26.sh b/scripts/finalize-batch26.sh new file mode 100755 index 0000000..a0184f8 --- /dev/null +++ b/scripts/finalize-batch26.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch26 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b26-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch26' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b26-*.mp4 root@gw.724care.com:/var/www/html/batch26/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch26.html root@gw.724care.com:/var/www/html/batch26.html 2>/dev/null +echo "BATCH26 FINALIZED $(date)" +ls -la "$DIR"/b26-*.mp4 diff --git a/scripts/finalize-batch27.sh b/scripts/finalize-batch27.sh new file mode 100755 index 0000000..adf4ead --- /dev/null +++ b/scripts/finalize-batch27.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-20 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch27 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b27-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch27' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b27-*.mp4 root@gw.724care.com:/var/www/html/batch27/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch27.html root@gw.724care.com:/var/www/html/batch27.html 2>/dev/null +echo "BATCH27 FINALIZED $(date)" +ls -la "$DIR"/b27-*.mp4 diff --git a/scripts/finalize-batch28.sh b/scripts/finalize-batch28.sh new file mode 100755 index 0000000..fb79167 --- /dev/null +++ b/scripts/finalize-batch28.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch28 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b28-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch28' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b28-*.mp4 root@gw.724care.com:/var/www/html/batch28/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch28.html root@gw.724care.com:/var/www/html/batch28.html 2>/dev/null +echo "BATCH28 FINALIZED $(date)" +ls -la "$DIR"/b28-*.mp4 diff --git a/scripts/finalize-batch29.sh b/scripts/finalize-batch29.sh new file mode 100755 index 0000000..055dfa2 --- /dev/null +++ b/scripts/finalize-batch29.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch29 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b29-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch29' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b29-*.mp4 root@gw.724care.com:/var/www/html/batch29/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch29.html root@gw.724care.com:/var/www/html/batch29.html 2>/dev/null +echo "BATCH29 FINALIZED $(date)" +ls -la "$DIR"/b29-*.mp4 diff --git a/scripts/finalize-batch30.sh b/scripts/finalize-batch30.sh new file mode 100755 index 0000000..25d9cd1 --- /dev/null +++ b/scripts/finalize-batch30.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch30 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b30-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch30' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b30-*.mp4 root@gw.724care.com:/var/www/html/batch30/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch30.html root@gw.724care.com:/var/www/html/batch30.html 2>/dev/null +echo "BATCH30 FINALIZED $(date)" +ls -la "$DIR"/b30-*.mp4 diff --git a/scripts/finalize-batch31.sh b/scripts/finalize-batch31.sh new file mode 100755 index 0000000..73a17d0 --- /dev/null +++ b/scripts/finalize-batch31.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch31 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b31-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch31' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b31-*.mp4 root@gw.724care.com:/var/www/html/batch31/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch31.html root@gw.724care.com:/var/www/html/batch31.html 2>/dev/null +echo "BATCH31 FINALIZED $(date)" +ls -la "$DIR"/b31-*.mp4 diff --git a/scripts/finalize-batch32.sh b/scripts/finalize-batch32.sh new file mode 100755 index 0000000..18c272d --- /dev/null +++ b/scripts/finalize-batch32.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch32 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b32-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch32' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b32-*.mp4 root@gw.724care.com:/var/www/html/batch32/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch32.html root@gw.724care.com:/var/www/html/batch32.html 2>/dev/null +echo "BATCH32 FINALIZED $(date)" +ls -la "$DIR"/b32-*.mp4 diff --git a/scripts/finalize-batch33.sh b/scripts/finalize-batch33.sh new file mode 100755 index 0000000..611f25f --- /dev/null +++ b/scripts/finalize-batch33.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch33 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b33-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch33' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b33-*.mp4 root@gw.724care.com:/var/www/html/batch33/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch33.html root@gw.724care.com:/var/www/html/batch33.html 2>/dev/null +echo "BATCH33 FINALIZED $(date)" +ls -la "$DIR"/b33-*.mp4 diff --git a/scripts/finalize-batch34.sh b/scripts/finalize-batch34.sh new file mode 100755 index 0000000..8539fff --- /dev/null +++ b/scripts/finalize-batch34.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch34 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b34-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch34' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b34-*.mp4 root@gw.724care.com:/var/www/html/batch34/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch34.html root@gw.724care.com:/var/www/html/batch34.html 2>/dev/null +echo "BATCH34 FINALIZED $(date)" +ls -la "$DIR"/b34-*.mp4 diff --git a/scripts/finalize-batch35.sh b/scripts/finalize-batch35.sh new file mode 100755 index 0000000..97e5c70 --- /dev/null +++ b/scripts/finalize-batch35.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch35 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b35-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch35' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b35-*.mp4 root@gw.724care.com:/var/www/html/batch35/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch35.html root@gw.724care.com:/var/www/html/batch35.html 2>/dev/null +echo "BATCH35 FINALIZED $(date)" +ls -la "$DIR"/b35-*.mp4 diff --git a/scripts/finalize-batch36.sh b/scripts/finalize-batch36.sh new file mode 100755 index 0000000..771e1d2 --- /dev/null +++ b/scripts/finalize-batch36.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch36 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b36-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch36' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b36-*.mp4 root@gw.724care.com:/var/www/html/batch36/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch36.html root@gw.724care.com:/var/www/html/batch36.html 2>/dev/null +echo "BATCH36 FINALIZED $(date)" +ls -la "$DIR"/b36-*.mp4 diff --git a/scripts/finalize-batch37.sh b/scripts/finalize-batch37.sh new file mode 100755 index 0000000..60d47ce --- /dev/null +++ b/scripts/finalize-batch37.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch37 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b37-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch37' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b37-*.mp4 root@gw.724care.com:/var/www/html/batch37/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch37.html root@gw.724care.com:/var/www/html/batch37.html 2>/dev/null +echo "BATCH37 FINALIZED $(date)" +ls -la "$DIR"/b37-*.mp4 diff --git a/scripts/finalize-batch38.sh b/scripts/finalize-batch38.sh new file mode 100755 index 0000000..d9ad962 --- /dev/null +++ b/scripts/finalize-batch38.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch38 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b38-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch38' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b38-*.mp4 root@gw.724care.com:/var/www/html/batch38/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch38.html root@gw.724care.com:/var/www/html/batch38.html 2>/dev/null +echo "BATCH38 FINALIZED $(date)" +ls -la "$DIR"/b38-*.mp4 diff --git a/scripts/finalize-batch39.sh b/scripts/finalize-batch39.sh new file mode 100755 index 0000000..9b60247 --- /dev/null +++ b/scripts/finalize-batch39.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch39 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b39-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch39' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b39-*.mp4 root@gw.724care.com:/var/www/html/batch39/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch39.html root@gw.724care.com:/var/www/html/batch39.html 2>/dev/null +echo "BATCH39 FINALIZED $(date)" +ls -la "$DIR"/b39-*.mp4 diff --git a/scripts/finalize-batch40.sh b/scripts/finalize-batch40.sh new file mode 100755 index 0000000..15867b8 --- /dev/null +++ b/scripts/finalize-batch40.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch40 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b40-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch40' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b40-*.mp4 root@gw.724care.com:/var/www/html/batch40/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch40.html root@gw.724care.com:/var/www/html/batch40.html 2>/dev/null +echo "BATCH40 FINALIZED $(date)" +ls -la "$DIR"/b40-*.mp4 diff --git a/scripts/finalize-batch41.sh b/scripts/finalize-batch41.sh new file mode 100755 index 0000000..6158609 --- /dev/null +++ b/scripts/finalize-batch41.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch41 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b41-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch41' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b41-*.mp4 root@gw.724care.com:/var/www/html/batch41/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch41.html root@gw.724care.com:/var/www/html/batch41.html 2>/dev/null +echo "BATCH41 FINALIZED $(date)" +ls -la "$DIR"/b41-*.mp4 diff --git a/scripts/finalize-batch42.sh b/scripts/finalize-batch42.sh new file mode 100755 index 0000000..ef0a532 --- /dev/null +++ b/scripts/finalize-batch42.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Batch-21 finalize: wait for 10 raw renders, deploy raw (no ambiance mix). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch42 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b42-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying raw..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch42' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b42-*.mp4 root@gw.724care.com:/var/www/html/batch42/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch42.html root@gw.724care.com:/var/www/html/batch42.html 2>/dev/null +echo "BATCH42 FINALIZED $(date)" +ls -la "$DIR"/b42-*.mp4 diff --git a/scripts/finalize-batch43.sh b/scripts/finalize-batch43.sh new file mode 100755 index 0000000..cefdf1b --- /dev/null +++ b/scripts/finalize-batch43.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch43 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b43-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch43' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b43-*.mp4 root@gw.724care.com:/var/www/html/batch43/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch43.html root@gw.724care.com:/var/www/html/batch43.html 2>/dev/null +echo "BATCH43 FINALIZED $(date)" +ls -la "$DIR"/b43-*.mp4 diff --git a/scripts/finalize-batch44.sh b/scripts/finalize-batch44.sh new file mode 100755 index 0000000..8c769be --- /dev/null +++ b/scripts/finalize-batch44.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch44 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b44-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch44' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b44-*.mp4 root@gw.724care.com:/var/www/html/batch44/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch44.html root@gw.724care.com:/var/www/html/batch44.html 2>/dev/null +echo "BATCH44 FINALIZED $(date)" +ls -la "$DIR"/b44-*.mp4 diff --git a/scripts/finalize-batch45.sh b/scripts/finalize-batch45.sh new file mode 100755 index 0000000..3a39486 --- /dev/null +++ b/scripts/finalize-batch45.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch45 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b45-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch45' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b45-*.mp4 root@gw.724care.com:/var/www/html/batch45/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch45.html root@gw.724care.com:/var/www/html/batch45.html 2>/dev/null +echo "BATCH45 FINALIZED $(date)" +ls -la "$DIR"/b45-*.mp4 diff --git a/scripts/finalize-batch46.sh b/scripts/finalize-batch46.sh new file mode 100755 index 0000000..04dc5dc --- /dev/null +++ b/scripts/finalize-batch46.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch46 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b46-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch46' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b46-*.mp4 root@gw.724care.com:/var/www/html/batch46/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch46.html root@gw.724care.com:/var/www/html/batch46.html 2>/dev/null +echo "BATCH46 FINALIZED $(date)" +ls -la "$DIR"/b46-*.mp4 diff --git a/scripts/finalize-batch47.sh b/scripts/finalize-batch47.sh new file mode 100755 index 0000000..aeb11a9 --- /dev/null +++ b/scripts/finalize-batch47.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch47 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b47-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch47' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b47-*.mp4 root@gw.724care.com:/var/www/html/batch47/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch47.html root@gw.724care.com:/var/www/html/batch47.html 2>/dev/null +echo "BATCH47 FINALIZED $(date)" +ls -la "$DIR"/b47-*.mp4 diff --git a/scripts/finalize-batch48.sh b/scripts/finalize-batch48.sh new file mode 100755 index 0000000..f2982bc --- /dev/null +++ b/scripts/finalize-batch48.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch48 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b48-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch48' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b48-*.mp4 root@gw.724care.com:/var/www/html/batch48/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch48.html root@gw.724care.com:/var/www/html/batch48.html 2>/dev/null +echo "BATCH48 FINALIZED $(date)" +ls -la "$DIR"/b48-*.mp4 diff --git a/scripts/finalize-batch49.sh b/scripts/finalize-batch49.sh new file mode 100755 index 0000000..69230d2 --- /dev/null +++ b/scripts/finalize-batch49.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch49 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b49-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch49' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b49-*.mp4 root@gw.724care.com:/var/www/html/batch49/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch49.html root@gw.724care.com:/var/www/html/batch49.html 2>/dev/null +echo "BATCH49 FINALIZED $(date)" +ls -la "$DIR"/b49-*.mp4 diff --git a/scripts/finalize-batch50.sh b/scripts/finalize-batch50.sh new file mode 100755 index 0000000..1d5e17c --- /dev/null +++ b/scripts/finalize-batch50.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch50 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b50-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch50' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b50-*.mp4 root@gw.724care.com:/var/www/html/batch50/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch50.html root@gw.724care.com:/var/www/html/batch50.html 2>/dev/null +echo "BATCH50 FINALIZED $(date)" +ls -la "$DIR"/b50-*.mp4 diff --git a/scripts/finalize-batch51.sh b/scripts/finalize-batch51.sh new file mode 100755 index 0000000..be112ad --- /dev/null +++ b/scripts/finalize-batch51.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch51 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b51-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch51' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b51-*.mp4 root@gw.724care.com:/var/www/html/batch51/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch51.html root@gw.724care.com:/var/www/html/batch51.html 2>/dev/null +echo "BATCH51 FINALIZED $(date)" +ls -la "$DIR"/b51-*.mp4 diff --git a/scripts/finalize-batch52.sh b/scripts/finalize-batch52.sh new file mode 100755 index 0000000..997ae47 --- /dev/null +++ b/scripts/finalize-batch52.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch52 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b52-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch52' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b52-*.mp4 root@gw.724care.com:/var/www/html/batch52/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch52.html root@gw.724care.com:/var/www/html/batch52.html 2>/dev/null +echo "BATCH52 FINALIZED $(date)" +ls -la "$DIR"/b52-*.mp4 diff --git a/scripts/finalize-batch53.sh b/scripts/finalize-batch53.sh new file mode 100755 index 0000000..44f667c --- /dev/null +++ b/scripts/finalize-batch53.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch53 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b53-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch53' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b53-*.mp4 root@gw.724care.com:/var/www/html/batch53/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch53.html root@gw.724care.com:/var/www/html/batch53.html 2>/dev/null +echo "BATCH53 FINALIZED $(date)" +ls -la "$DIR"/b53-*.mp4 diff --git a/scripts/finalize-batch54.sh b/scripts/finalize-batch54.sh new file mode 100755 index 0000000..8ff57b2 --- /dev/null +++ b/scripts/finalize-batch54.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch54 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b54-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch54' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b54-*.mp4 root@gw.724care.com:/var/www/html/batch54/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch54.html root@gw.724care.com:/var/www/html/batch54.html 2>/dev/null +echo "BATCH54 FINALIZED $(date)" +ls -la "$DIR"/b54-*.mp4 diff --git a/scripts/finalize-batch55.sh b/scripts/finalize-batch55.sh new file mode 100755 index 0000000..cdf179d --- /dev/null +++ b/scripts/finalize-batch55.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch55 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b55-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch55' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b55-*.mp4 root@gw.724care.com:/var/www/html/batch55/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch55.html root@gw.724care.com:/var/www/html/batch55.html 2>/dev/null +echo "BATCH55 FINALIZED $(date)" +ls -la "$DIR"/b55-*.mp4 diff --git a/scripts/finalize-batch56.sh b/scripts/finalize-batch56.sh new file mode 100755 index 0000000..a153331 --- /dev/null +++ b/scripts/finalize-batch56.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch56 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b56-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch56' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b56-*.mp4 root@gw.724care.com:/var/www/html/batch56/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch56.html root@gw.724care.com:/var/www/html/batch56.html 2>/dev/null +echo "BATCH56 FINALIZED $(date)" +ls -la "$DIR"/b56-*.mp4 diff --git a/scripts/finalize-batch57.sh b/scripts/finalize-batch57.sh new file mode 100755 index 0000000..a6f5117 --- /dev/null +++ b/scripts/finalize-batch57.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch57 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b57-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch57' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b57-*.mp4 root@gw.724care.com:/var/www/html/batch57/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch57.html root@gw.724care.com:/var/www/html/batch57.html 2>/dev/null +echo "BATCH57 FINALIZED $(date)" +ls -la "$DIR"/b57-*.mp4 diff --git a/scripts/finalize-batch58.sh b/scripts/finalize-batch58.sh new file mode 100755 index 0000000..7f304de --- /dev/null +++ b/scripts/finalize-batch58.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-43 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch58 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b58-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch58' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b58-*.mp4 root@gw.724care.com:/var/www/html/batch58/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch58.html root@gw.724care.com:/var/www/html/batch58.html 2>/dev/null +echo "BATCH58 FINALIZED $(date)" +ls -la "$DIR"/b58-*.mp4 diff --git a/scripts/finalize-batch59.sh b/scripts/finalize-batch59.sh new file mode 100755 index 0000000..5b3d45e --- /dev/null +++ b/scripts/finalize-batch59.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Autonomous batch-59 finisher: wait for all 10 renders, deploy (NO captions). +set -u +cd /home/na/ai-management-dashboard +DIR=public/videos/ugc/batch59 + +echo "waiting for 10 rendered videos..." +until [ "$(ls "$DIR"/b59-*.mp4 2>/dev/null | grep -vc captioned)" -ge 10 ]; do + sleep 60 +done +echo "all 10 rendered. deploying (uncaptioned)..." + +ssh -o StrictHostKeyChecking=no root@gw.724care.com 'mkdir -p /var/www/html/batch59' 2>/dev/null +scp -o StrictHostKeyChecking=no "$DIR"/b59-*.mp4 root@gw.724care.com:/var/www/html/batch59/ 2>/dev/null +scp -o StrictHostKeyChecking=no public/research/batch59.html root@gw.724care.com:/var/www/html/batch59.html 2>/dev/null +echo "BATCH59 FINALIZED $(date)" +ls -la "$DIR"/b59-*.mp4 diff --git a/scripts/fish-male-audition.ts b/scripts/fish-male-audition.ts new file mode 100644 index 0000000..e2766da --- /dev/null +++ b/scripts/fish-male-audition.ts @@ -0,0 +1,78 @@ +/** + * Audition mature male Fish Audio voices for the older-couple ad (man speaking). + * npx tsx scripts/fish-male-audition.ts + */ +import 'dotenv/config' +import { writeFileSync, existsSync, mkdirSync } from 'fs' +import { pack as msgpackPack } from 'msgpackr' + +const KEY = process.env.FISH_API_KEY +if (!KEY) { console.error('FISH_API_KEY missing'); process.exit(1) } + +const SAMPLE = + "After thirty years together, my wife and I finally took the trip we always " + + "talked about. Five days in Mexico, all-inclusive, right on the beach. And I " + + "am telling you, it was worth every minute of the wait." + +interface V { slug: string; name: string; ref: string; note: string } +const VOICES: V[] = [ + { slug: 'm1-warm-conv', name: 'Warm Conversational Male', ref: 'c4723bc253cc4fac8c6b3d143f042636', note: 'Middle-aged, conversational, warm' }, + { slug: 'm2-dave-deep', name: 'Dave — Deep Voice for Media', ref: '0dd3903013144408b29b7e74ca9e8614', note: 'Deep, media/VO style' }, + { slug: 'm3-deep-old-calm', name: 'Deep Male (old, calm)', ref: 'a60cb2ef5d15412c8c4e63545640eadb', note: 'Older, narration, deep, calm' }, + { slug: 'm4-soft-smooth', name: 'American Soft Smooth Male', ref: '6290ad34543a487ab85d4f36defd6be4', note: 'Middle-aged, narration, smooth, professional' }, + { slug: 'm5-male-narrator', name: 'Male Narrator (warm)', ref: 'efc2f5153a24463dbfe54acd93a145f8', note: 'Middle-aged, educational, warm' }, + { slug: 'm6-confident-narrator', name: 'Confident Male Narrator', ref: 'c8b00ae2d256487bbb1971d70d782c9a', note: 'Older, narration, advertisement-tagged' }, + { slug: 'm7-w2w', name: 'w2w (calm, confident)', ref: '4746f1b456494387993af9041d0516c4', note: 'Middle-aged, calm, confident delivery' }, + { slug: 'm8-energetic', name: 'Energetic American Male', ref: '802e3bc2b27e49c2995d23ef70e6ac89', note: 'Young, energetic, clear American accent — most popular' }, +] + +const OUT = 'public/research/male-voice-samples' +if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true }) + +async function tts(text: string, ref: string): Promise { + const body = { text, format: 'mp3', latency: 'normal', chunk_length: 200, mp3_bitrate: 192, reference_id: ref } + const res = await fetch('https://api.fish.audio/v1/tts', { + method: 'POST', + headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/msgpack', model: 's2-pro' }, + body: new Uint8Array(msgpackPack(body)), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return Buffer.from(new Uint8Array(await res.arrayBuffer())) +} + +async function main() { + const ok: V[] = [] + for (const v of VOICES) { + try { + const buf = await tts(SAMPLE, v.ref) + writeFileSync(`${OUT}/${v.slug}.mp3`, buf) + ok.push(v) + console.log(`✓ ${v.name}`) + } catch (e: any) { console.error(`✗ ${v.name}: ${e.message}`) } + } + const cards = ok.map(v => ` +
${v.name}
+
${v.note}
+ + ${v.ref}
`).join('') + const html = ` + +Male Voice Audition — Older Couple Ad + +

Male Voice Audition — Older Couple Ad (man speaking)

+

Pick the voice that best sounds like a warm, older man. Same line in every clip.

+
"${SAMPLE}"
+
${cards}
` + writeFileSync('public/research/male-voice-audition.html', html) + console.log('\n✓ male-voice-audition.html written') +} +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/fish-tts.ts b/scripts/fish-tts.ts new file mode 100644 index 0000000..d294a35 --- /dev/null +++ b/scripts/fish-tts.ts @@ -0,0 +1,81 @@ +/** + * Fish Audio TTS client. + * + * Endpoint: POST https://api.fish.audio/v1/tts + * Auth: Authorization: Bearer $FISH_API_KEY + * Header: Content-Type: application/msgpack · model: s2-pro + * Body: msgpack of { text, reference_id?, format, mp3_bitrate?, latency?, chunk_length? } + * Returns: Binary audio stream in the requested format + * + * npx tsx scripts/fish-tts.ts \ + * --text "Hello world." \ + * --ref 933563129e564b19a115bedd57b7406a \ + * --out public/audio/fish-sarah.mp3 \ + * [--format mp3|wav|opus|pcm] + */ +import { writeFileSync, mkdirSync, existsSync } from 'fs' +import { dirname } from 'path' +import { pack as msgpackPack } from 'msgpackr' + +const args = parseArgs(process.argv.slice(2)) +const KEY = process.env.FISH_API_KEY +if (!KEY) { console.error('FISH_API_KEY missing from env'); process.exit(1) } + +const TEXT = args.text || 'Hello, this is a test of Fish Audio.' +const REF_ID = args.ref || args['reference-id'] +const FORMAT = (args.format || 'mp3').toLowerCase() +const OUT = args.out || `public/audio/fish-${Date.now()}.${FORMAT}` +const MODEL = args.model || 's2-pro' +const BITRATE = parseInt(args.bitrate || '192', 10) +const LATENCY = args.latency || 'normal' + +if (!existsSync(dirname(OUT))) mkdirSync(dirname(OUT), { recursive: true }) + +async function main() { + const body: Record = { + text: TEXT, + format: FORMAT, + latency: LATENCY, + chunk_length: 200, + } + if (FORMAT === 'mp3') body.mp3_bitrate = BITRATE + if (REF_ID) body.reference_id = REF_ID + + console.log(`POST https://api.fish.audio/v1/tts (model=${MODEL} format=${FORMAT} ref=${REF_ID || ''})`) + console.log(`Text: ${TEXT.slice(0, 80)}${TEXT.length > 80 ? '...' : ''}`) + + const t0 = Date.now() + const res = await fetch('https://api.fish.audio/v1/tts', { + method: 'POST', + headers: { + Authorization: `Bearer ${KEY}`, + 'Content-Type': 'application/msgpack', + model: MODEL, + }, + body: new Uint8Array(msgpackPack(body)), + }) + + if (!res.ok) { + console.error(`HTTP ${res.status}: ${await res.text()}`) + process.exit(1) + } + const buf = Buffer.from(await res.arrayBuffer()) + writeFileSync(OUT, buf) + const dt = ((Date.now() - t0) / 1000).toFixed(1) + console.log(`✓ saved ${OUT} (${(buf.length / 1024).toFixed(1)}kb, ${dt}s wall)`) +} + +function parseArgs(argv: string[]): Record { + const out: Record = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a.startsWith('--')) { + const key = a.slice(2) + const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true' + out[key] = val + } + } + return out +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/fish-voices-audition.ts b/scripts/fish-voices-audition.ts new file mode 100644 index 0000000..479b79e --- /dev/null +++ b/scripts/fish-voices-audition.ts @@ -0,0 +1,105 @@ +/** + * Generate audition samples for candidate Fish Audio UGC voices and build + * an HTML audition page. + * + * npx tsx scripts/fish-voices-audition.ts + * + * Writes public/research/voice-samples/.mp3 and voice-audition.html + */ +import 'dotenv/config' +import { writeFileSync, existsSync, mkdirSync } from 'fs' +import { pack as msgpackPack } from 'msgpackr' + +const KEY = process.env.FISH_API_KEY +if (!KEY) { console.error('FISH_API_KEY missing'); process.exit(1) } + +const SAMPLE_TEXT = + "Okay, I probably shouldn't be sharing this, but my husband found a way to take our " + + "whole family to Mexico for under four hundred dollars. Five days, all-inclusive, a " + + "real beachfront resort. I genuinely cannot believe it is real. The link is in my bio." + +interface Voice { slug: string; name: string; ref: string; note: string } + +const VOICES: Voice[] = [ + { slug: 'alle', name: 'ALLE', ref: '59e9dc1cb20c452584788a2690c80970', + note: 'Young female · energetic, friendly · 2.3k likes, 285k uses · built for social-media/product reviews' }, + { slug: 'teto', name: 'teto', ref: 'a3b3f0a9c49340bd8fa722d83c81cb08', + note: 'Youthful female · relaxed, informal, friendly · 1.1k likes, 209k uses' }, + { slug: 'taylor', name: 'Taylor (soft/sincere)', ref: 'cfc33da8775c47afacccf4eebabe44dc', + note: 'Young female · soft, sincere, intimate, slightly breathy · 121k uses' }, + { slug: 'megan', name: 'Megan', ref: 'fb43143e46f44cc6ad7d06230215bab6', + note: 'Young female · bright, energetic, confident, expressive · 38k uses' }, + { slug: 'sarah', name: 'Sarah (current)', ref: '933563129e564b19a115bedd57b7406a', + note: 'Young female · conversational, soft · the voice used in batches 10-13 · 527k uses' }, + { slug: 'energetic-male', name: 'Energetic Male', ref: '802e3bc2b27e49c2995d23ef70e6ac89', + note: 'Young male · energetic, enthusiastic, clear American accent · 2.1k likes, 380k uses' }, + { slug: 'adam', name: 'Adam', ref: '9259a7392c454a1eb6436141abb5a558', + note: 'Male · confident, energetic, friendly, conversational · 68k uses · advertisement-tagged' }, +] + +const OUT_DIR = 'public/research/voice-samples' +if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }) + +async function tts(text: string, ref: string): Promise { + const body = { text, format: 'mp3', latency: 'normal', chunk_length: 200, mp3_bitrate: 192, reference_id: ref } + const res = await fetch('https://api.fish.audio/v1/tts', { + method: 'POST', + headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/msgpack', model: 's2-pro' }, + body: new Uint8Array(msgpackPack(body)), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) + return Buffer.from(new Uint8Array(await res.arrayBuffer())) +} + +async function main() { + for (const v of VOICES) { + const dest = `${OUT_DIR}/${v.slug}.mp3` + try { + const buf = await tts(SAMPLE_TEXT, v.ref) + writeFileSync(dest, buf) + console.log(`✓ ${v.name} (${(buf.length / 1024).toFixed(0)} kb)`) + } catch (e: any) { + console.error(`✗ ${v.name}: ${e.message}`) + } + } + + // build audition page + const cards = VOICES.map(v => ` +
+
${v.name}
+
${v.note}
+ + ${v.ref} +
`).join('') + + const html = ` + + +hi2b.com — Fish Audio UGC Voice Audition + +

Fish Audio — UGC Voice Audition

+

Top candidate voices for hi2b.com TikTok ads, ranked from the Fish Audio library. Same script in every clip.

+
"${SAMPLE_TEXT}"
+
${cards}
+
hi2b.com · internal · voice audition
+` + writeFileSync('public/research/voice-audition.html', html) + console.log('\n✓ voice-audition.html written') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/fish-voices-discover.ts b/scripts/fish-voices-discover.ts new file mode 100644 index 0000000..c815da1 --- /dev/null +++ b/scripts/fish-voices-discover.ts @@ -0,0 +1,79 @@ +/** + * Discover great Fish Audio voices for UGC ads. + * + * npx tsx scripts/fish-voices-discover.ts + * + * Queries the Fish Audio model library, filters for English + * conversational / social-media / narration voices, ranks by popularity, + * and prints the top candidates with their reference_id. + */ +import 'dotenv/config' + +const KEY = process.env.FISH_API_KEY +if (!KEY) { console.error('FISH_API_KEY missing'); process.exit(1) } + +interface FishModel { + _id: string + title: string + description?: string + tags?: string[] + languages?: string[] + like_count?: number + task_count?: number + author?: { nickname?: string } +} + +// keywords that signal a good UGC ad voice +const GOOD = ['conversational', 'narration', 'social', 'advertisement', 'natural', + 'friendly', 'warm', 'storytell', 'podcast', 'casual', 'sincere', 'energetic', + 'young', 'commercial', 'influencer', 'vlog'] +const BAD = ['anime', 'character', 'game', 'robot', 'cartoon', 'singing', 'rap', + 'asmr', 'whisper', 'child', 'scary', 'meme'] + +function score(m: FishModel): number { + const hay = `${m.title} ${m.description || ''} ${(m.tags || []).join(' ')}`.toLowerCase() + let s = 0 + for (const k of GOOD) if (hay.includes(k)) s += 3 + for (const k of BAD) if (hay.includes(k)) s -= 6 + s += Math.log10((m.like_count || 0) + 1) * 2 + s += Math.log10((m.task_count || 0) + 1) + return s +} + +async function fetchPage(page: number, sort: string): Promise { + const url = `https://api.fish.audio/model?page_size=100&page_number=${page}&sort_by=${sort}&language=en` + const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }) + if (!res.ok) { console.warn(`page ${page} (${sort}): HTTP ${res.status}`); return [] } + const json = await res.json() as any + return (json.items || json.data || []) as FishModel[] +} + +async function main() { + const seen = new Map() + for (const sort of ['like_count', 'task_count']) { + for (let p = 1; p <= 3; p++) { + const items = await fetchPage(p, sort) + for (const m of items) if (m._id) seen.set(m._id, m) + } + } + console.log(`fetched ${seen.size} unique English voices\n`) + + const ranked = [...seen.values()] + .map(m => ({ m, s: score(m) })) + .filter(x => x.s > 2) + .sort((a, b) => b.s - a.s) + .slice(0, 20) + + console.log('=== TOP UGC VOICE CANDIDATES ===\n') + for (const { m, s } of ranked) { + const tags = (m.tags || []).slice(0, 6).join(', ') + console.log(`${m.title}`) + console.log(` ref_id : ${m._id}`) + console.log(` score : ${s.toFixed(1)} | likes ${m.like_count || 0} | uses ${m.task_count || 0}`) + if (tags) console.log(` tags : ${tags}`) + if (m.description) console.log(` desc : ${m.description.slice(0, 120).replace(/\n/g, ' ')}`) + console.log() + } +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/fish-voices.json b/scripts/fish-voices.json new file mode 100644 index 0000000..710dedc --- /dev/null +++ b/scripts/fish-voices.json @@ -0,0 +1,66 @@ +{ + "_note": "Curated Fish Audio voice IDs for hi2b.com UGC ads. Refresh by re-running scripts/fish-voices-refresh.ts (TODO). Use reference_id in your TTS request.", + "ugc_female_top_picks": [ + { + "ref_id": "933563129e564b19a115bedd57b7406a", + "name": "Sarah", + "tags": ["young", "conversational", "narration", "soft", "intimate", "gentle", "sincere"], + "best_for": "Sincere personal UGC testimonial — most natural-sounding storyteller", + "likes": 2020 + }, + { + "ref_id": "b545c585f631496c914815291da4e893", + "name": "Friendly Women", + "tags": ["young", "social-media", "entertainment", "bright", "energetic", "professional", "enthusiastic"], + "best_for": "High-energy social-media-native voice — built for TikTok/Reels ads", + "likes": 1090 + }, + { + "ref_id": "9a9cf47702da476aa4629e2506d4a857", + "name": "Hannah", + "tags": ["middle-aged", "advertisement", "professional", "confident", "clear", "friendly", "fitness"], + "best_for": "Polished ad-native voice — Fish explicitly tagged it 'advertisement'", + "likes": 590 + }, + { + "ref_id": "e80db686476f4ccda758da35cacfb993", + "name": "Angela White", + "tags": ["young", "social-media", "conversational", "friendly", "calm", "smooth", "enthusiastic"], + "best_for": "Warm conversational UGC — calm + enthusiastic blend", + "likes": 204 + }, + { + "ref_id": "e3cd384158934cc9a01029cd7d278634", + "name": "Laura", + "tags": ["middle-aged", "conversational", "deep", "warm", "calm", "professional", "clear"], + "best_for": "Mature warm voice — great for trust-builder spots", + "likes": 627 + } + ], + "ugc_female_southern_picks": [ + { + "ref_id": "844b16c4ff744d48b2666c19bab9579a", + "name": "Miranda Lambert (Southern young)", + "tags": ["young", "social-media", "energetic", "friendly", "warm", "cheerful", "advertisement", "Southern"], + "best_for": "Top pick — Southern + advertisement-tagged + young enthusiastic. Best for Mexico vacation UGC ads." + }, + { + "ref_id": "335569c3e54648d19b11548d7c03cb05", + "name": "Lainey Wilson (Country)", + "tags": ["middle-aged", "social-media", "energetic", "enthusiastic", "cheerful", "friendly", "Country"], + "best_for": "Country-music energy, friendly drawl. Higher relatability for Southern US targeting." + }, + { + "ref_id": "36a97b192d3e417d9da87933db80e6a1", + "name": "Rhenda (Southern young)", + "tags": ["young", "social-media", "conversational", "warm", "friendly", "expressive", "energetic"], + "best_for": "Warm conversational Southern — softer drawl, more intimate TikTok-friend vibe." + } + ], + "models": { + "default": "s2-pro", + "endpoint": "https://api.fish.audio/v1/tts", + "content_type": "application/msgpack", + "auth_header": "Authorization: Bearer ${FISH_API_KEY}" + } +} diff --git a/scripts/gen-mama-still.ts b/scripts/gen-mama-still.ts new file mode 100644 index 0000000..d610828 --- /dev/null +++ b/scripts/gen-mama-still.ts @@ -0,0 +1,43 @@ +/** + * Proof: generate ONE authentic "mama at ocean" still via Gemini (Nano Banana). + * npx tsx scripts/gen-mama-still.ts + * Saves to public/images/quote-cards/mama-01.png + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs' + +// load GEMINI_API_KEY from .env without printing it +const env = readFileSync('.env', 'utf8') +const KEY = (env.match(/^GEMINI_API_KEY=(.+)$/m)?.[1] || '').trim() +if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1) } + +const MODEL = 'gemini-2.5-flash-image' +const PROMPT = + 'Photorealistic candid documentary photograph, vertical 9:16 portrait orientation. ' + + 'A real, relatable everyday woman in her early 40s with a curvy natural figure and warm brown skin, ' + + 'dark curly hair in a loose bun, wearing a simple coral sarong and holding a straw sun hat. ' + + 'She stands at the edge of a crystal-clear turquoise Caribbean ocean at golden hour, seen from BEHIND ' + + 'in a three-quarter back view, looking out at the calm water and a soft pastel sunset. ' + + 'Her hair and sarong move gently in the sea breeze. Natural realistic skin texture, no makeup, ' + + 'an ordinary mom — NOT a model. Soft golden backlight, warm filmic 35mm film color grade, ' + + 'shallow depth of field, open sky filling the top third of the frame for text space. ' + + 'Avoid: supermodel, glamour, heavy makeup, plastic airbrushed skin, instagram filter, ' + + 'magazine cover, CGI, 3d render, cartoon, deformed hands, extra fingers, watermark, any text or letters.' + +async function main() { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}` + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contents: [{ parts: [{ text: PROMPT }] }] }), + }) + if (!res.ok) { console.error(`HTTP ${res.status}: ${(await res.text()).slice(0, 600)}`); process.exit(1) } + const j: any = await res.json() + const parts = j?.candidates?.[0]?.content?.parts || [] + const img = parts.find((p: any) => p.inlineData?.data) + if (!img) { console.error('no image in response: ' + JSON.stringify(j).slice(0, 600)); process.exit(1) } + if (!existsSync('public/images/quote-cards')) mkdirSync('public/images/quote-cards', { recursive: true }) + const buf = Buffer.from(img.inlineData.data, 'base64') + writeFileSync('public/images/quote-cards/mama-01.png', buf) + console.log(`saved public/images/quote-cards/mama-01.png (${(buf.length / 1024).toFixed(0)} KB, ${img.inlineData.mimeType})`) +} +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/gen-mama-stills.ts b/scripts/gen-mama-stills.ts new file mode 100644 index 0000000..7caa1ad --- /dev/null +++ b/scripts/gen-mama-stills.ts @@ -0,0 +1,62 @@ +/** + * Generate 8 authentic back-to-camera "mama at ocean" stills (9:16) via Gemini. + * npx tsx scripts/gen-mama-stills.ts + * Saves public/images/quote-cards/mama-01..08.png (skips existing) + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs' + +const env = readFileSync('.env', 'utf8') +const KEY = (env.match(/^GEMINI_API_KEY=(.+)$/m)?.[1] || '').trim() +if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1) } +const MODEL = 'gemini-2.5-flash-image' + +const BASE = + 'Photorealistic candid documentary photograph, vertical 9:16 tall portrait orientation, full standing height in frame. ' + + 'Seen entirely from BEHIND (back of the woman to the camera), she stands at the edge of a crystal-clear turquoise ' + + 'Caribbean ocean at golden hour, looking out at calm water and a soft pastel sunset. Open sky fills the top third for text. ' + + 'Hair and clothes drift gently in the sea breeze. Natural realistic skin texture, no makeup, an ordinary everyday mom, NOT a model. ' + + 'Warm filmic 35mm color grade, soft golden backlight, shallow depth of field. ' + + 'Avoid: supermodel, glamour, heavy makeup, plastic airbrushed skin, instagram filter, magazine cover, CGI, 3d render, ' + + 'cartoon, deformed hands, extra fingers, watermark, any text or letters.' + +const MAMAS = [ + 'A real woman in her early 40s, curvy natural figure, warm brown skin, dark curly hair in a loose bun, simple coral sarong, holding a straw sun hat.', + 'A real woman in her late 30s, average everyday build, fair skin, shoulder-length blonde hair blowing in the wind, white linen beach coverup.', + 'A real woman in her mid 40s, fuller figure, olive skin, long dark hair, flowy floral kaftan, standing barefoot in ankle-deep shallow water.', + 'A real woman in her 50s, slim, silver-grey bob, soft tan, navy one-piece swimsuit with a sheer wrap.', + 'A real Black woman in her late 30s, athletic-soft mom build, natural curls tucked under a straw hat, mustard-yellow beach coverup.', + 'A real East-Asian woman in her early 40s, petite, sleek low ponytail, pale-blue linen beach dress, arms slightly open to the breeze.', + 'A real woman in her mid 40s, plus-size, freckled fair skin, auburn hair, terracotta sundress.', + 'A real Latina woman in her late 40s, warm tan, dark wavy hair, green-print beach wrap, walking ankle-deep into the water.', +] + +async function gen(idx: number): Promise { + const n = String(idx + 1).padStart(2, '0') + const out = `public/images/quote-cards/mama-${n}.png` + if (existsSync(out)) { console.log(`mama-${n} exists, skip`); return } + const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}` + const body = { + contents: [{ parts: [{ text: `${MAMAS[idx]} ${BASE}` }] }], + generationConfig: { imageConfig: { aspectRatio: '9:16' } }, + } + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + if (!res.ok) { console.error(`mama-${n} HTTP ${res.status} (try ${attempt}): ${(await res.text()).slice(0, 300)}`); continue } + const j: any = await res.json() + const img = (j?.candidates?.[0]?.content?.parts || []).find((p: any) => p.inlineData?.data) + if (!img) { console.error(`mama-${n} no image (try ${attempt})`); continue } + writeFileSync(out, Buffer.from(img.inlineData.data, 'base64')) + console.log(`saved ${out} (${(Buffer.from(img.inlineData.data, 'base64').length / 1024).toFixed(0)} KB)`) + return + } catch (e: any) { console.error(`mama-${n} err (try ${attempt}): ${e.message}`) } + } + console.error(`mama-${n} FAILED after 3 tries`) +} + +async function main() { + if (!existsSync('public/images/quote-cards')) mkdirSync('public/images/quote-cards', { recursive: true }) + for (let i = 0; i < MAMAS.length; i++) await gen(i) + console.log('done') +} +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/generate-ebook.ts b/scripts/generate-ebook.ts new file mode 100644 index 0000000..3e21d9c --- /dev/null +++ b/scripts/generate-ebook.ts @@ -0,0 +1,553 @@ +/** + * Generate the Budget Luxury Travel e-book PDF + * Run: npx tsx scripts/generate-ebook.ts + * + * Pricing and branding are kept in one place (CONFIG) so this stays in sync + * with PAYMENT_CONFIG in src/app/lp/_config/types.ts. + */ +import PDFDocument from 'pdfkit' +import { createWriteStream } from 'fs' +import { join } from 'path' + +const outputPath = join(__dirname, '..', 'public', 'ebooks', 'budget-luxury-travel.pdf') + +// ─── SITE CONFIG (keep in sync with PAYMENT_CONFIG) ────── +const CONFIG = { + // Regular (anchor) price + regularMonthlyPrice: 39, + regularTotalPrice: 390, + // Promo / today's price + monthlyPrice: 29, + totalMonths: 10, + totalPrice: 290, + oneTimePrice: 249, + savingsVsRegularTotal: 141, // regularTotalPrice - oneTimePrice + savingsVsRegularMonthly: 100, // regularTotalPrice - totalPrice + savingsOneTime: 41, // totalPrice - oneTimePrice + nights: 4, + days: 5, + bookingMonths: 18, + guaranteeDays: 30, + phone: '888-602-2424', + domain: 'hi2b.com', + siteUrl: 'https://hi2b.com', + primaryLPSlug: 'golden-hour', + brand: 'Mexico Paradise Vacations', + dailyCost: 0.97, // monthlyPrice * 12 / 365 — one year of $29/mo +} + +const doc = new PDFDocument({ + size: 'letter', + // Bottom margin intentionally small so the page-number footer (rendered at + // y = page.height - 50) does not trigger PDFKit's auto-pagination. + margins: { top: 72, bottom: 30, left: 72, right: 72 }, + info: { + Title: `The ${CONFIG.brand} Insider Guide — Mexico for Under $1/Day`, + Author: CONFIG.brand, + Subject: 'Travel Guide', + }, +}) + +const stream = createWriteStream(outputPath) +doc.pipe(stream) + +// Color palette +const NAVY = '#15396B' +const GOLD = '#D4A574' +const DARK = '#1a1a2e' +const GRAY = '#444444' +const LIGHT_GRAY = '#888888' +const WHITE = '#FFFFFF' +const TEAL = '#0d7377' +const ACCENT = '#E8651A' +const GREEN = '#16A34A' + +// ─── COVER PAGE ──────────────────────────────────────────── +doc.rect(0, 0, doc.page.width, doc.page.height).fill(NAVY) + +doc.rect(60, 180, doc.page.width - 120, 2).fill(GOLD) + +doc.font('Helvetica-Bold').fontSize(42).fillColor(WHITE) + .text('Budget Luxury', 72, 220, { align: 'center' }) +doc.text('Travel in Mexico', 72, 275, { align: 'center' }) + +doc.font('Helvetica').fontSize(18).fillColor(GOLD) + .text(`5 Days of All-Inclusive Paradise for Under $${CONFIG.oneTimePrice}`, 72, 345, { align: 'center' }) +doc.text('The Insider Guide', 72, 370, { align: 'center' }) + +doc.rect(60, 420, doc.page.width - 120, 2).fill(GOLD) + +doc.font('Helvetica').fontSize(11).fillColor('#aabbcc') + .text(`A Free Guide from ${CONFIG.brand}`, 72, 460, { align: 'center' }) + +doc.font('Helvetica').fontSize(9).fillColor('#667799') + .text(CONFIG.siteUrl, 72, 680, { align: 'center' }) + .text(`© ${new Date().getFullYear()} ${CONFIG.brand}. All rights reserved.`, 72, 695, { align: 'center' }) + +// ─── TABLE OF CONTENTS ──────────────────────────────────── +doc.addPage() +doc.rect(0, 0, doc.page.width, 100).fill(NAVY) +doc.font('Helvetica-Bold').fontSize(28).fillColor(WHITE) + .text('Table of Contents', 72, 40, { align: 'center' }) + +const tocItems: [string, string, string][] = [ + ['Introduction', 'Why Mexico? Why Now?', '3'], + ['Secret #1', 'Vacation Certificates — The Insider Hack', '4'], + ['Secret #2', 'Shoulder Season = Best Season', '5'], + ['Secret #3', 'All-Inclusive: The Hidden Math', '6'], + ['Secret #4', 'Destination Deep Dives', '7'], + ['Secret #5', 'The $29/mo Payment Plan Advantage', '9'], + ['Bonus', 'Your Mexico Packing Checklist', '10'], + ['Next Steps', 'Ready to Claim Your Paradise?', '11'], +] + +let tocY = 140 +tocItems.forEach(([title, subtitle, page]) => { + doc.font('Helvetica-Bold').fontSize(14).fillColor(NAVY) + .text(title, 72, tocY) + doc.font('Helvetica').fontSize(11).fillColor(GRAY) + .text(subtitle, 72, tocY + 18) + doc.font('Helvetica').fontSize(11).fillColor(LIGHT_GRAY) + .text(page, 480, tocY + 5, { align: 'right', width: 60 }) + doc.font('Helvetica').fontSize(9).fillColor('#cccccc') + const titleWidth = doc.widthOfString(subtitle, { font: 'Helvetica', fontSize: 11 }) + const dotsStart = 80 + titleWidth + const dotsEnd = 475 + if (dotsEnd > dotsStart + 10) { + const dots = '.'.repeat(Math.floor((dotsEnd - dotsStart) / 4)) + doc.text(dots, dotsStart, tocY + 20, { width: dotsEnd - dotsStart }) + } + tocY += 55 +}) + +// ─── Helpers ─────────────────────────────────────────────── +function pageHeader(title: string, subtitle?: string) { + doc.addPage() + doc.rect(0, 0, doc.page.width, 90).fill(NAVY) + doc.font('Helvetica-Bold').fontSize(24).fillColor(WHITE) + .text(title, 72, 30, { width: doc.page.width - 144 }) + if (subtitle) { + doc.font('Helvetica').fontSize(12).fillColor(GOLD) + .text(subtitle, 72, 60, { width: doc.page.width - 144 }) + } + return 120 +} + +function sectionHeading(_y: number, text: string): number { + doc.font('Helvetica-Bold').fontSize(16).fillColor(NAVY) + .text(text, 72, _y, { width: doc.page.width - 144 }) + return doc.y + 10 +} + +function bodyText(_y: number, content: string): number { + doc.font('Helvetica').fontSize(11).fillColor(GRAY) + .text(content, 72, _y, { + width: doc.page.width - 144, + lineGap: 4, + }) + return doc.y + 10 +} + +function bulletPoint(_y: number, content: string): number { + doc.font('Helvetica').fontSize(11).fillColor(TEAL).text('•', 82, _y) + doc.font('Helvetica').fontSize(11).fillColor(GRAY) + .text(content, 100, _y, { width: doc.page.width - 172, lineGap: 3 }) + return doc.y + 4 +} + +function highlightBox(y: number, title: string, content: string, accent = TEAL, bg = '#f0f7ff'): number { + const contentH = doc.heightOfString(content, { width: doc.page.width - 180, fontSize: 10, lineGap: 3 }) + const boxHeight = contentH + 54 + doc.roundedRect(72, y, doc.page.width - 144, boxHeight, 8).fill(bg) + doc.rect(72, y, 4, boxHeight).fill(accent) + doc.font('Helvetica-Bold').fontSize(11).fillColor(accent) + .text(title, 88, y + 12, { width: doc.page.width - 180 }) + doc.font('Helvetica').fontSize(10).fillColor(GRAY) + .text(content, 88, y + 30, { width: doc.page.width - 180, lineGap: 3 }) + return y + boxHeight + 16 +} + +function pageFooter(pageNum: number) { + // Force footer to land on the current page by resetting the text cursor to the + // footer position before writing. Without this, if prior content pushed the + // internal cursor near/past the page bottom, PDFKit auto-breaks to a new page + // even when we pass explicit (x, y) to .text() — resulting in blank spacer pages. + doc.rect(72, doc.page.height - 60, doc.page.width - 144, 0.5).fill('#dddddd') + doc.save() + doc.y = doc.page.height - 50 + doc.x = 72 + doc.font('Helvetica').fontSize(8).fillColor(LIGHT_GRAY) + .text(`${CONFIG.brand} — Page ${pageNum} — ${CONFIG.domain}`, 72, doc.page.height - 50, { + align: 'center', + width: doc.page.width - 144, + lineBreak: false, + }) + doc.restore() +} + +// ─── PAGE 3: INTRODUCTION ───────────────────────────────── +let y = pageHeader('Introduction', 'Why Mexico? Why Now?') + +y = bodyText(y, 'Mexico has been one of the world\'s most beloved vacation destinations for decades — and for good reason. From the powdery white sands of Cancun to the dramatic cliffs of Cabo San Lucas, from the ancient Mayan ruins of the Riviera Maya to the cobblestone charm of Puerto Vallarta, Mexico offers an extraordinary range of experiences that few countries can match.') + +y = bodyText(y, `But here\'s what most travelers don\'t know: you don\'t have to spend $3,000-$5,000 to enjoy a luxury all-inclusive Mexican vacation. Savvy travelers have been enjoying 5-star resort experiences for a fraction of that price — often under $${CONFIG.oneTimePrice} total.`) + +y = bodyText(y, 'In this guide, we\'re pulling back the curtain on the five biggest secrets that budget-conscious luxury travelers use to experience world-class Mexican resorts without the world-class price tag.') + +y = sectionHeading(y + 8, 'What You\'ll Learn') +y = bulletPoint(y, 'How vacation certificates can save you 85-90% on luxury resort stays') +y = bulletPoint(y, 'The best times to travel for incredible weather AND incredible prices') +y = bulletPoint(y, 'Why all-inclusive actually saves you more money than you think') +y = bulletPoint(y, 'Which Mexican destination is perfect for YOUR travel style') +y = bulletPoint(y, `How the $${CONFIG.monthlyPrice}/mo payment plan makes luxury travel accessible to any budget`) + +y = highlightBox(y + 4, 'Did You Know?', `The average American spends $1,979 per person on a domestic vacation. For $${CONFIG.oneTimePrice} total — or just $${CONFIG.monthlyPrice}/month — you and a partner can enjoy a ${CONFIG.days}-day, ${CONFIG.nights}-night all-inclusive Mexico vacation. That\'s over 85% less than the average trip.`) + +pageFooter(3) + +// ─── PAGE 4: SECRET #1 ──────────────────────────────────── +y = pageHeader('Secret #1', 'Vacation Certificates — The Insider Hack') + +y = bodyText(y, 'Here\'s a little-known fact about the resort industry: luxury resorts in Mexico have a powerful incentive to let you stay for almost nothing. It\'s called a "vacation certificate," and it\'s the single most effective way to experience luxury travel on a budget.') + +y = sectionHeading(y, 'How Vacation Certificates Work') +y = bodyText(y, 'Luxury resorts invest millions in their properties — infinity pools, gourmet restaurants, spa facilities, world-class amenities. Their biggest challenge? Getting potential long-term customers through the door.') + +y = bodyText(y, `That\'s where vacation certificates come in. Resorts partner with companies like ${CONFIG.brand} to offer deeply discounted ${CONFIG.days}-day/${CONFIG.nights}-night all-inclusive stays. In exchange, guests attend a brief 90-minute resort tour and presentation about vacation ownership. There is absolutely no obligation to purchase anything.`) + +y = sectionHeading(y, 'The Numbers Speak for Themselves') +y = bulletPoint(y, `Rack rate for ${CONFIG.nights} nights at a luxury all-inclusive: $2,500 - $4,000`) +y = bulletPoint(y, 'Booking through Expedia or Hotels.com: $1,800 - $3,000') +y = bulletPoint(y, `Regular certificate price: $${CONFIG.regularMonthlyPrice}/mo x ${CONFIG.totalMonths} = $${CONFIG.regularTotalPrice}`) +y = bulletPoint(y, `Promo price today: $${CONFIG.monthlyPrice}/mo x ${CONFIG.totalMonths} = $${CONFIG.totalPrice} (save $${CONFIG.savingsVsRegularMonthly})`) +y = bulletPoint(y, `Best value: $${CONFIG.oneTimePrice} one-time (save $${CONFIG.savingsVsRegularTotal} vs regular)`) +y = bulletPoint(y, `Your savings vs retail: $1,510 - $3,751 per trip`) + +y = highlightBox(y + 4, 'Pro Tip', `The regular rate is $${CONFIG.regularMonthlyPrice}/mo, but our current promo drops it to $${CONFIG.monthlyPrice}/mo — under $1 a day. Or pay once at $${CONFIG.oneTimePrice} and save $${CONFIG.savingsVsRegularTotal} versus the regular total. Every certificate is covered by our ${CONFIG.guaranteeDays}-day 100% money-back guarantee, and bringing a guest is always free.`) + +pageFooter(4) + +// ─── PAGE 5: SECRET #2 ──────────────────────────────────── +y = pageHeader('Secret #2', 'Shoulder Season = Best Season') + +y = bodyText(y, 'Most travelers instinctively book during December holidays, spring break, or summer — exactly when prices are at their peak and beaches are packed. Smart travelers know that "shoulder season" offers the perfect sweet spot of great weather, low prices, and uncrowded beaches.') + +y = sectionHeading(y, 'Peak Season vs. Shoulder Season') + +const tableTop = y +const colW = (doc.page.width - 144) / 3 +doc.rect(72, tableTop, doc.page.width - 144, 25).fill(NAVY) +doc.font('Helvetica-Bold').fontSize(10).fillColor(WHITE) +doc.text('', 72, tableTop + 8, { width: colW, align: 'center' }) +doc.text('Peak Season', 72 + colW, tableTop + 8, { width: colW, align: 'center' }) +doc.text('Shoulder Season', 72 + colW * 2, tableTop + 8, { width: colW, align: 'center' }) + +const tableRows = [ + ['Months', 'Dec-Mar, Jun-Aug', 'Apr-May, Sep-Nov'], + ['Weather', '80-90°F, Sunny', '80-88°F, Sunny'], + ['Crowds', 'Very crowded', 'Light crowds'], + ['Prices', '+40-60% premium', '-30-50% savings'], + ['Service', 'Stretched thin', 'Attentive & personal'], + ['Upgrades', 'Rarely available', 'Often complimentary'], +] + +let rowY = tableTop + 25 +tableRows.forEach((row, i) => { + const bg = i % 2 === 0 ? '#f8f9fa' : WHITE + doc.rect(72, rowY, doc.page.width - 144, 22).fill(bg) + doc.font('Helvetica-Bold').fontSize(9).fillColor(DARK) + .text(row[0], 82, rowY + 6, { width: colW - 20 }) + doc.font('Helvetica').fontSize(9).fillColor(GRAY) + .text(row[1], 72 + colW, rowY + 6, { width: colW, align: 'center' }) + doc.font('Helvetica-Bold').fontSize(9).fillColor(TEAL) + .text(row[2], 72 + colW * 2, rowY + 6, { width: colW, align: 'center' }) + rowY += 22 +}) + +y = rowY + 20 +y = bodyText(y, 'The bottom line: you\'ll enjoy nearly identical weather, significantly fewer crowds, better service, and prices 30-50% lower. Many seasoned travelers say shoulder season is actually the BEST time to visit Mexico.') + +y = highlightBox(y, 'Best Shoulder Season Windows', '• April 15 - May 31: Perfect weather, post-spring-break calm\n• September 15 - November 30: Hurricane season winding down, incredible deals\n• Early December (before Dec 15): Holiday decorations without holiday crowds') + +pageFooter(5) + +// ─── PAGE 6: SECRET #3 ──────────────────────────────────── +y = pageHeader('Secret #3', 'All-Inclusive: The Hidden Math') + +y = bodyText(y, 'When most people see "all-inclusive" pricing, they think it\'s more expensive. But when you actually do the math, all-inclusive resorts almost always save you significant money — and eliminate the stress of watching every peso you spend.') + +y = sectionHeading(y, 'The Real Cost of a "Budget" Resort Stay') +y = bodyText(y, 'Let\'s break down what a couple typically spends per day at a non-all-inclusive resort in Mexico:') + +const expenses = [ + ['Breakfast', '$15-25/person', '$30-50'], + ['Lunch', '$20-35/person', '$40-70'], + ['Dinner', '$40-80/person', '$80-160'], + ['Drinks (pool/beach)', '$8-15 each × 4', '$32-60'], + ['Evening drinks', '$10-18 each × 3', '$30-54'], + ['Pool/beach chairs', '', '$20-40'], + ['Tips', '', '$25-40'], + ['Activities', '', '$40-100'], +] + +let expY = y +doc.rect(72, expY, doc.page.width - 144, 22).fill(NAVY) +doc.font('Helvetica-Bold').fontSize(9).fillColor(WHITE) +doc.text('Expense', 82, expY + 7) +doc.text('Per Person', 280, expY + 7) +doc.text('Couple Total', 420, expY + 7) +expY += 22 + +expenses.forEach((row, i) => { + const bg = i % 2 === 0 ? '#fef9f0' : WHITE + doc.rect(72, expY, doc.page.width - 144, 18).fill(bg) + doc.font('Helvetica').fontSize(9).fillColor(GRAY) + doc.text(row[0], 82, expY + 5) + doc.text(row[1], 280, expY + 5) + doc.font('Helvetica-Bold').fontSize(9).fillColor(ACCENT) + doc.text(row[2], 420, expY + 5) + expY += 18 +}) + +doc.rect(72, expY, doc.page.width - 144, 22).fill('#fff3e0') +doc.font('Helvetica-Bold').fontSize(10).fillColor(ACCENT) + .text('DAILY TOTAL (couple):', 82, expY + 6) + .text('$297 - $574', 420, expY + 6) +expY += 30 + +y = expY + 8 +y = bodyText(y, `Over a ${CONFIG.days}-day trip, that\'s $1,485 - $2,870 in additional costs on top of your room rate. With an all-inclusive certificate at $${CONFIG.oneTimePrice}, ALL of this is included — food, drinks, pools, beaches, resort amenities.`) + +y = highlightBox(y, 'The Bottom Line', `A "cheap" hotel room + food & drinks costs $2,000-$4,000 for a couple.\nAn all-inclusive vacation certificate: just $${CONFIG.oneTimePrice} — or $${CONFIG.monthlyPrice}/mo for ${CONFIG.totalMonths} months.\nYour savings: $1,750 - $3,750. It\'s not even close.`, ACCENT, '#fff3e0') + +pageFooter(6) + +// ─── PAGE 7: SECRET #4 ───────────────────────────────── +y = pageHeader('Secret #4', 'Destination Deep Dives — Find Your Perfect Match') + +y = bodyText(y, 'Not all Mexican destinations are created equal — and choosing the right one for your travel style can make the difference between a good vacation and an unforgettable one. Here\'s our insider guide to the four premier destinations your certificate covers.') + +y = sectionHeading(y, 'Cancun') +y = bodyText(y, 'Best for: Beach lovers, nightlife enthusiasts, and first-time Mexico visitors.') +y = bodyText(y, 'Cancun\'s Hotel Zone is a 14-mile strip of powder-white beach backed by turquoise Caribbean waters. It\'s the most popular tourist destination in Mexico for good reason — stunning beaches, world-class resorts, and easy access from most US cities with direct flights under 3 hours.') + +y = bulletPoint(y, 'Must-Do: Take the ferry to Isla Mujeres for a laid-back island day') +y = bulletPoint(y, 'Must-Do: Snorkel the underwater museum MUSA (500+ submerged sculptures)') +y = bulletPoint(y, 'Must-Do: Visit Chichen Itza — one of the New Seven Wonders of the World') +y = bulletPoint(y, 'Best For: Couples seeking beach + nightlife, families with older kids') + +y += 8 +y = sectionHeading(y, 'Cabo San Lucas') +y = bodyText(y, 'Best for: Dramatic scenery, deep-sea fishing, and luxury seekers.') +y = bodyText(y, 'Where the Pacific Ocean meets the Sea of Cortez, Cabo offers some of Mexico\'s most dramatic landscapes. The iconic El Arco rock formation, desert-meets-ocean terrain, and some of the finest resorts in the world make Cabo a favorite of celebrities and luxury travelers.') + +y = bulletPoint(y, 'Must-Do: See El Arco at sunset by boat or kayak') +y = bulletPoint(y, 'Must-Do: Whale watching (December - April) — grey and humpback whales') +y = bulletPoint(y, 'Must-Do: Take a desert ATV tour through the Baja landscape') +y = bulletPoint(y, 'Best For: Couples, golf enthusiasts, deep-sea fishing fans') + +pageFooter(7) + +// ─── PAGE 8: SECRET #4 (continued) ───────────────────────── +y = pageHeader('Secret #4 (continued)', 'More Dream Destinations') + +y = sectionHeading(y, 'Riviera Maya') +y = bodyText(y, 'Best for: Adventure seekers, culture lovers, and eco-tourists.') +y = bodyText(y, 'Stretching along the Caribbean coast south of Cancun, the Riviera Maya combines stunning beaches with ancient Mayan heritage. This is where you\'ll find cenotes (natural swimming holes), jungle adventures, and the cliff-top ruins of Tulum overlooking the turquoise sea.') + +y = bulletPoint(y, 'Must-Do: Swim in a cenote — there are over 6,000 in the Yucatan Peninsula') +y = bulletPoint(y, 'Must-Do: Visit Tulum ruins at sunrise before the crowds arrive') +y = bulletPoint(y, 'Must-Do: Snorkel with sea turtles in Akumal Bay') +y = bulletPoint(y, 'Best For: Adventure couples, eco-travelers, history buffs') + +y += 8 +y = sectionHeading(y, 'Puerto Vallarta') +y = bodyText(y, 'Best for: Culture enthusiasts, foodies, and sunset chasers.') +y = bodyText(y, 'Puerto Vallarta is Mexico\'s most authentic resort city. Unlike purpose-built resort zones, PV has a real downtown with cobblestone streets, local markets, world-class restaurants, and a legendary Malecón (boardwalk) that comes alive every evening with street performers and art.') + +y = bulletPoint(y, 'Must-Do: Walk the Malecón at sunset — the most beautiful boardwalk in Mexico') +y = bulletPoint(y, 'Must-Do: Take a food tour through the Romantic Zone') +y = bulletPoint(y, 'Must-Do: Day trip to the hidden beach town of Sayulita') +y = bulletPoint(y, 'Best For: Foodies, couples seeking authenticity, culture lovers') + +y += 12 +const quizBoxH = 90 +doc.roundedRect(72, y, doc.page.width - 144, quizBoxH, 8).fill('#e8f5e9') +doc.rect(72, y, 4, quizBoxH).fill(TEAL) +doc.font('Helvetica-Bold').fontSize(12).fillColor(TEAL) + .text('Not Sure Which Destination? Quick Guide:', 88, y + 12) +doc.font('Helvetica').fontSize(10).fillColor(GRAY) + .text('Want the best beaches? → Cancun', 88, y + 32) + .text('Want dramatic luxury? → Cabo San Lucas', 88, y + 47) + .text('Want adventure + culture? → Riviera Maya', 88, y + 62) + .text('Want authentic Mexico? → Puerto Vallarta', 88, y + 77) + +pageFooter(8) + +// ─── PAGE 9: SECRET #5 ──────────────────────────────────── +y = pageHeader('Secret #5', `The $${CONFIG.monthlyPrice}/mo Payment Plan Advantage`) + +y = bodyText(y, 'The biggest psychological barrier to booking a vacation isn\'t the total cost — it\'s the upfront cost. Dropping $2,000+ in one transaction feels painful, even when you can afford it. Smart travelers use payment plans to eliminate this friction entirely.') + +y = sectionHeading(y, 'The Under-$1-a-Day Vacation') +y = bodyText(y, `At ${CONFIG.brand}, you can lock in your all-inclusive vacation certificate for just $${CONFIG.monthlyPrice}/month over ${CONFIG.totalMonths} months. Let\'s put that in perspective:`) + +const comparisons: Array<[string, string, string]> = [ + [`Your daily ${CONFIG.brand} cost`, `$${CONFIG.dailyCost.toFixed(2)}/day`, TEAL], + ['Pack of gum', '$1.50/day', GRAY], + ['Streaming subscription', '$1.80/day', GRAY], + ['Starbucks coffee', '$5.50/day', GRAY], + ['Fast food lunch', '$9.00/day', GRAY], + ['Daily takeout dinner', '$18.00/day', GRAY], +] + +let compY = y +comparisons.forEach(([label, cost, color]) => { + const amount = parseFloat(cost.replace(/[$\/day]/g, '')) + const barWidth = Math.min(amount * 25, 280) + doc.roundedRect(200, compY, barWidth, 18, 4).fill(color === TEAL ? '#e0f2f1' : '#f5f5f5') + doc.roundedRect(200, compY, barWidth, 18, 4).fill(color === TEAL ? TEAL : '#ccc') + .fillOpacity(color === TEAL ? 0.2 : 0.15) + doc.fillOpacity(1) + doc.font('Helvetica').fontSize(9).fillColor(GRAY) + .text(label, 82, compY + 4) + doc.font('Helvetica-Bold').fontSize(9).fillColor(color) + .text(cost, 200 + barWidth + 8, compY + 4) + compY += 24 +}) + +y = compY + 12 +y = sectionHeading(y, 'Why Payment Plans Are Smart') +y = bulletPoint(y, 'Lock in today\'s price before rates increase') +y = bulletPoint(y, 'No credit check required — available to everyone') +y = bulletPoint(y, `Book your travel dates immediately after your first $${CONFIG.monthlyPrice} payment`) +y = bulletPoint(y, `No interest charges — promo price $${CONFIG.monthlyPrice} x ${CONFIG.totalMonths} = $${CONFIG.totalPrice} total (vs regular $${CONFIG.regularTotalPrice})`) +y = bulletPoint(y, `Or save $${CONFIG.savingsVsRegularTotal} with a one-time payment of $${CONFIG.oneTimePrice}`) +y = bulletPoint(y, `${CONFIG.guaranteeDays}-day 100% money-back guarantee — cancel anytime in the first month for a full refund`) + +y = highlightBox(y + 4, 'Important Note', `Regular price is $${CONFIG.regularMonthlyPrice}/mo — you are locking in the $${CONFIG.monthlyPrice}/mo promo. Your certificate is activated after your first $${CONFIG.monthlyPrice} payment, so you can begin booking travel dates immediately. You have ${CONFIG.bookingMonths} months to travel, and bringing a guest is always free.`) + +pageFooter(9) + +// ─── PAGE 10: PACKING CHECKLIST ─────────────────────────── +y = pageHeader('Bonus', 'Your Complete Mexico Packing Checklist') + +const checklistSections = [ + { + title: 'Essential Documents', + items: ['Valid passport (6+ months before expiry)', 'Travel insurance documents', 'Vacation certificate confirmation', 'Digital + paper copies of all documents', 'Hotel/resort confirmation email'], + }, + { + title: 'Clothing', + items: ['Swimsuits (2-3)', 'Light cover-ups and sundresses', 'One nice outfit for resort dining', 'Comfortable walking shoes', 'Flip flops / sandals', 'Light layers for air-conditioned spaces'], + }, + { + title: 'Sun & Health', + items: ['Reef-safe sunscreen SPF 50+ (required at many resorts)', 'UV-protection sunglasses', 'Wide-brim hat', 'Insect repellent with DEET', 'Basic medications & first aid', 'Prescription medications in original bottles'], + }, + { + title: 'Tech & Extras', + items: ['Waterproof phone case', 'Portable battery charger', 'Camera / GoPro', 'Universal power adapter (Mexico uses US plugs, but just in case)', 'Dry bag for beach/boat excursions'], + }, +] + +checklistSections.forEach((section) => { + doc.font('Helvetica-Bold').fontSize(13).fillColor(NAVY) + .text(section.title, 72, y) + y += 20 + section.items.forEach((item) => { + doc.font('Helvetica').fontSize(10).fillColor(GRAY) + doc.rect(82, y + 2, 10, 10).lineWidth(0.5).stroke('#aaaaaa') + doc.text(item, 100, y + 1, { width: doc.page.width - 172 }) + y += 18 + }) + y += 8 +}) + +pageFooter(10) + +// ─── PAGE 11: CTA / CLOSING ────────────────────────────── +doc.addPage() +doc.rect(0, 0, doc.page.width, doc.page.height).fill(NAVY) + +doc.font('Helvetica-Bold').fontSize(32).fillColor(WHITE) + .text('Ready to Claim', 72, 100, { align: 'center' }) + .text('Your Paradise?', 72, 145, { align: 'center' }) + +doc.rect(200, 195, doc.page.width - 400, 2).fill(GOLD) + +doc.font('Helvetica').fontSize(14).fillColor('#aabbcc') + .text('You now have all 5 secrets to budget luxury travel in Mexico.', 72, 225, { align: 'center' }) + .text('Here\'s the quick recap:', 72, 245, { align: 'center' }) + +const recapItems = [ + '1. Use vacation certificates for 85-90% savings', + '2. Travel shoulder season for best weather & prices', + '3. Choose all-inclusive to eliminate hidden costs', + '4. Pick the right destination for your style', + `5. Use the $${CONFIG.monthlyPrice}/mo payment plan to make it effortless`, +] + +let recapY = 290 +recapItems.forEach((item) => { + doc.font('Helvetica').fontSize(13).fillColor(GOLD) + .text(item, 100, recapY, { align: 'center', width: doc.page.width - 200 }) + recapY += 28 +}) + +// Offer box +const offerY = 445 +doc.roundedRect(100, offerY, doc.page.width - 200, 175, 12) + .lineWidth(2).stroke(GOLD) + +doc.font('Helvetica-Bold').fontSize(16).fillColor(WHITE) + .text('YOUR SPECIAL PROMO', 100, offerY + 16, { align: 'center', width: doc.page.width - 200 }) + +doc.font('Helvetica').fontSize(13).fillColor('#aabbcc') + .text(`${CONFIG.days} Days / ${CONFIG.nights} Nights All-Inclusive Mexico Vacation`, 100, offerY + 45, { align: 'center', width: doc.page.width - 200 }) + +// Struck-through regular price +const regularText = `Regular price: $${CONFIG.regularMonthlyPrice}/month` +const regularWidth = doc.widthOfString(regularText, { font: 'Helvetica', fontSize: 12 }) +const regularX = 100 + ((doc.page.width - 200) - regularWidth) / 2 +doc.font('Helvetica').fontSize(12).fillColor('#8899aa') + .text(regularText, regularX, offerY + 70) +// Strikethrough line +doc.rect(regularX, offerY + 78, regularWidth, 1).fill('#8899aa') + +// Today's promo price — big and gold +doc.font('Helvetica-Bold').fontSize(26).fillColor(GOLD) + .text(`Today: $${CONFIG.monthlyPrice}/month for ${CONFIG.totalMonths} months`, 100, offerY + 88, { align: 'center', width: doc.page.width - 200 }) + +doc.font('Helvetica').fontSize(11).fillColor('#aabbcc') + .text(`or save $${CONFIG.savingsVsRegularTotal} with a one-time payment of $${CONFIG.oneTimePrice}`, 100, offerY + 122, { align: 'center', width: doc.page.width - 200 }) + +doc.font('Helvetica-Bold').fontSize(10).fillColor(GREEN) + .text(`${CONFIG.guaranteeDays}-Day Money-Back • Bring a Guest Free • ${CONFIG.bookingMonths}-Month Booking Window`, 100, offerY + 146, { align: 'center', width: doc.page.width - 200 }) + +// Book now CTA — emphasize hi2b.com +doc.font('Helvetica-Bold').fontSize(18).fillColor(WHITE) + .text(`Visit ${CONFIG.domain}`, 72, 645, { align: 'center', width: doc.page.width - 144 }) + +doc.font('Helvetica-Bold').fontSize(16).fillColor(GOLD) + .text(`Or call ${CONFIG.phone}`, 72, 674, { align: 'center', width: doc.page.width - 144 }) + +doc.font('Helvetica').fontSize(9).fillColor('#aabbcc') + .text('Mon-Fri 9am-8pm | Sat 10am-4pm EST | Toll-Free', 72, 698, { align: 'center', width: doc.page.width - 144 }) + +doc.font('Helvetica').fontSize(8).fillColor('#667799') + .text(`© ${new Date().getFullYear()} ${CONFIG.brand}. All rights reserved.`, 72, 720, { align: 'center', width: doc.page.width - 144 }) + .text('This guide is informational. Offers subject to availability and resort terms. ' + + 'Certificate requires attendance at a 90-minute resort tour with no obligation to purchase.', + 72, 734, { align: 'center', width: doc.page.width - 144 }) + +// Finalize +doc.end() + +stream.on('finish', () => { + console.log(`✅ PDF generated: ${outputPath}`) + const fs = require('fs') + const stats = fs.statSync(outputPath) + console.log(` Size: ${(stats.size / 1024).toFixed(1)} KB`) + console.log(` Pages: 11`) +}) diff --git a/scripts/generate-keyframe.ts b/scripts/generate-keyframe.ts new file mode 100644 index 0000000..b29b2ac --- /dev/null +++ b/scripts/generate-keyframe.ts @@ -0,0 +1,88 @@ +/** + * Generate a talking-head keyframe for the InfiniteTalk UGC ad via Gemini image. + * + * npx tsx scripts/generate-keyframe.ts [--out public/audio/fish/keyframe-v2.jpg] + * + * Produces a vertical 9:16 photoreal selfie-style frame of the presenter, + * suitable as the InfiniteTalk start_image. + */ +import 'dotenv/config' +import { writeFileSync, existsSync, mkdirSync } from 'fs' +import { dirname } from 'path' + +const API_KEY = process.env.GEMINI_API_KEY +if (!API_KEY) { console.error('GEMINI_API_KEY missing'); process.exit(1) } + +const args = process.argv.slice(2) +const outIdx = args.indexOf('--out') +const OUT = outIdx >= 0 ? args[outIdx + 1] : 'public/audio/fish/keyframe-v2.jpg' +if (!existsSync(dirname(OUT))) mkdirSync(dirname(OUT), { recursive: true }) + +const MODELS = [ + 'gemini-3.1-flash-image-preview', + 'gemini-3-pro-image-preview', + 'gemini-2.5-flash-image-preview', + 'gemini-2.0-flash-exp-image-generation', +] + +const DEFAULT_PROMPT = + 'A vertical 9:16 selfie-style photo, shot on an iPhone front camera, of a friendly 34-year-old woman ' + + 'with warm sun-kissed freckled skin and slightly messy sun-bleached beach hair. She is sitting on a ' + + 'wicker chair on the terrace of a luxury beachfront resort in the Mexican Caribbean. Soft golden-hour ' + + 'light on her face. She is looking directly into the camera with a relaxed, genuine, mid-conversation ' + + 'expression — lips slightly parted as if she just started talking, eyebrows naturally raised, warm and ' + + 'candid, like she is telling a friend a secret. She wears a simple linen sundress. Behind her, softly ' + + 'blurred (shallow depth of field): turquoise Caribbean ocean, palm fronds, a glimpse of a resort pool. ' + + 'Natural amateur UGC aesthetic — NOT a professional studio portrait, NO professional lighting, slight ' + + 'handheld imperfection, realistic skin texture with visible pores and fine lines, no heavy retouching. ' + + 'Head and upper shoulders framed in the center, plenty of headroom, mouth fully visible and unobstructed. ' + + 'Photorealistic, true-to-life color, the look of a real person filming a TikTok. ' + + 'No text, no logos, no watermarks, no captions. Aspect ratio 9:16, vertical portrait orientation.' + +const PROMPT = process.env.KEYFRAME_PROMPT || DEFAULT_PROMPT + +async function generateOne(model: string): Promise { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${API_KEY}` + const body = { + contents: [{ parts: [{ text: PROMPT }] }], + generationConfig: { responseModalities: ['IMAGE'] }, + } + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), + }) + if (!res.ok) { + console.warn(` [${model}] HTTP ${res.status}: ${(await res.text()).slice(0, 180)}`) + return null + } + const data = (await res.json()) as any + for (const p of data?.candidates?.[0]?.content?.parts || []) { + const inline = p.inlineData || p.inline_data + if (inline?.data) return Buffer.from(inline.data, 'base64') + } + console.warn(` [${model}] no image in response`) + return null + } catch (err: any) { + console.warn(` [${model}] error: ${err?.message || err}`) + return null + } +} + +async function main() { + for (const model of MODELS) { + console.log(`→ trying ${model}`) + const buf = await generateOne(model) + if (buf) { + writeFileSync(OUT, buf) + console.log(`✓ saved ${OUT} (${(buf.length / 1024).toFixed(1)} kb) via ${model}`) + return + } + } + console.error('✗ all models failed') + process.exit(1) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/generate-showcase-images.ts b/scripts/generate-showcase-images.ts new file mode 100644 index 0000000..9e67cf7 --- /dev/null +++ b/scripts/generate-showcase-images.ts @@ -0,0 +1,124 @@ +/** + * Generate the /pay showcase carousel images via Google AI Studio (Gemini image). + * + * npx tsx scripts/generate-showcase-images.ts + * + * Tries the user-requested preview models first, falls back to the + * currently-released image generation models if those names aren't + * recognized. Writes JPEG files to public/images/showcase/. + */ +import 'dotenv/config' +import { writeFileSync, existsSync, mkdirSync } from 'fs' +import { join } from 'path' + +const API_KEY = process.env.GEMINI_API_KEY +if (!API_KEY) { + console.error('GEMINI_API_KEY missing from env') + process.exit(1) +} + +const OUT_DIR = join(__dirname, '..', 'public', 'images', 'showcase') +if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }) + +const MODELS = [ + 'gemini-3.1-flash-image-preview', + 'gemini-3-pro-image-preview', + 'gemini-2.5-flash-image-preview', + 'gemini-2.0-flash-exp-image-generation', + 'imagen-3.0-generate-002', +] + +interface Shot { + slug: string + prompt: string +} + +const SHOTS: Shot[] = [ + { + slug: 'resort-beachfront', + prompt: + 'Aerial drone photograph at golden hour, a five-star all-inclusive beachfront resort on the Mexican Caribbean coast. Crescent of pristine sugar-white sand beach, gradient turquoise-to-deep-cobalt water with visible coral patterns below the surface, a long horizon-edge infinity pool reflecting the warm orange sky, palm trees casting long sunset shadows. Modern curved-architecture suites with private plunge pools. Two thatched-roof palapa cabanas on the sand. Shot on Sony A7R V with 24mm lens, f/8, sharp focus across the frame, vibrant but natural color grade, soft volumetric haze, cinematic travel photography in the style of Condé Nast Traveler. Hyper-realistic, magazine-quality, 8K detail. No people, no logos, no text, no watermarks, no boats. Aspect ratio 16:9.', + }, + { + slug: 'infinity-pool-sunset', + prompt: + 'Eye-level photograph from inside an infinity pool at a luxury Mexican resort during a vivid sunset. Two stemmed cocktail glasses with garnish sit on the pool edge in the foreground, water gently rippling, the pool seamlessly meeting the Caribbean sea at the horizon. Sky is painted in dramatic pink, coral, and lavender gradients reflected on the water. Silhouettes of three palm trees on the right. A pair of unoccupied teak loungers with white cushions on the deck. Shot on Canon R5 with 35mm lens, f/2.8, shallow depth of field on the cocktail glasses, golden-hour warm tones, soft bokeh of pool lights starting to glow. Hyper-realistic, romantic, aspirational travel editorial style. No people, no text, no logos. Aspect ratio 16:9.', + }, + { + slug: 'dashboard-screen', + prompt: + 'Studio product photograph of a sleek 13-inch silver MacBook open on a clean light-oak desk. The screen displays a modern minimalist customer-portal web interface with: a friendly header reading "Welcome back, Sarah", a prominent orange-and-gold certificate card showing the monospace number "MPV-2026-7K9XQ4" with an "Active" green pill, three stat tiles labeled "Next Payment", "Days Until Travel", "Resort Choice", a chart of monthly billing history, and an orange "Book My Dates" CTA button. Color palette: warm white background, soft orange accents (#E8651A), sage green for status. Beside the laptop: a small succulent plant, a beige ceramic coffee mug with rising steam, a small notebook. Bright daylight from camera-left window, soft shadows, shallow depth of field on the steam, sharp focus on the screen. Photographed in the style of Apple product photography. Hyper-realistic, magazine-quality. No real brand logos on the screen besides the abstract orange certificate card, no text outside what is described. Aspect ratio 16:9.', + }, + { + slug: 'certificate-design', + prompt: + 'Macro studio photograph of a luxury printed vacation certificate lying on a cream linen surface, dramatically lit by a single warm window light from the upper-left. The certificate is on heavyweight ivory cardstock with a thin gold-foil decorative border featuring subtle palm-leaf motifs in the corners. Centered headline in elegant serif: "MEXICO PARADISE VACATIONS". Below in smaller letters: "CERTIFICATE OF VACATION OWNERSHIP". A large monospace certificate number reads "MPV-2026-7K9XQ4" in deep navy. A circular embossed gold-foil seal sits on the lower-right with a small palm-tree icon inside it. A satin navy ribbon drapes across the bottom-left corner. Single ornate calligraphic signature in navy ink at the bottom. Subtle drop shadow on the page edge revealing tactile thickness. Shot on Hasselblad H6D-100c with 100mm macro lens, f/5.6, ultra-fine paper texture visible. Hyper-realistic, like a high-end stationery campaign. No additional text beyond what is described, no other props, no people. Aspect ratio 16:9.', + }, + { + slug: 'family-vacation-joy', + prompt: + 'Candid travel photograph of a multi-generational family of four wading happily in shallow crystal-clear turquoise Caribbean water at a Mexican luxury beach. A dad in his 40s tossing a laughing six-year-old daughter (white sundress) gently into the air, mom in her 30s watching with hands on her chest smiling tearfully, grandmother in a sun hat in the background sipping a coconut. Bright tropical mid-day sunlight, water splashing in slow-motion droplets catching the light. White sand, palm trees, a thatched palapa in the soft-focus background. Genuine candid joy, no posed smiles. Shot on Sony A1 with 70-200mm lens at 135mm, f/4, frozen action, vibrant true colors, sunlight backlight creating rim light on the splashes. National Geographic / Travel + Leisure editorial photography style. Hyper-realistic, emotionally moving, 8K detail. No text, no logos, no watermarks. Ethnically diverse family. Aspect ratio 16:9.', + }, +] + +async function generateOne(prompt: string, model: string): Promise { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${API_KEY}` + const body = { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { responseModalities: ['IMAGE'] }, + } + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), + }) + if (!res.ok) { + const txt = await res.text() + console.warn(` [${model}] HTTP ${res.status}: ${txt.slice(0, 200)}`) + return null + } + const data = await res.json() as any + const parts = data?.candidates?.[0]?.content?.parts || [] + for (const p of parts) { + const inline = p.inlineData || p.inline_data + if (inline?.data) return Buffer.from(inline.data, 'base64') + } + console.warn(` [${model}] no image in response`) + return null + } catch (err: any) { + console.warn(` [${model}] error:`, err?.message || err) + return null + } +} + +async function main() { + let chosenModel: string | null = null + for (const shot of SHOTS) { + const dest = join(OUT_DIR, `${shot.slug}.jpg`) + if (existsSync(dest)) { + console.log(`✓ ${shot.slug} (already exists, skipping)`) + continue + } + console.log(`→ ${shot.slug}`) + let buf: Buffer | null = null + const tryOrder = chosenModel ? [chosenModel, ...MODELS.filter(m => m !== chosenModel)] : MODELS + for (const model of tryOrder) { + buf = await generateOne(shot.prompt, model) + if (buf) { + chosenModel = model + console.log(` ✓ generated via ${model} (${buf.length} bytes)`) + break + } + } + if (!buf) { + console.error(` ✗ all models failed for ${shot.slug}`) + continue + } + writeFileSync(dest, buf) + } + console.log(`\nDone. Files in ${OUT_DIR}`) +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/scripts/infinitetalk-render.ts b/scripts/infinitetalk-render.ts new file mode 100644 index 0000000..50a1bc8 --- /dev/null +++ b/scripts/infinitetalk-render.ts @@ -0,0 +1,261 @@ +/** + * InfiniteTalk single-pass lip-sync render on Vast Inst 2. + * + * Builds an API-format ComfyUI workflow for the Kijai WanVideoWrapper + * InfiniteTalk pipeline and submits it via /prompt, proxied through an + * SSH local-forward on localhost:18889. + * + * npx tsx scripts/infinitetalk-render.ts + * + * Inputs (already uploaded to Inst 2 /workspace/ComfyUI/input/): + * - keyframe-husband.jpg + * - miranda-v2-husband.mp3 (37.2s clean TTS voiceover, no music) + * + * Schemas verified against the live /object_info on Inst 2. MelBandRoFormer + * vocal separation is skipped — the audio is a clean voiceover with no music. + */ +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const HOST = process.env.INST2_URL || 'http://localhost:18889' +const JSON_HEADERS = { 'Content-Type': 'application/json' } + +const REF_IMAGE = process.env.IT_REF_IMAGE || 'keyframe-husband.jpg' +const AUDIO = process.env.IT_AUDIO || 'miranda-v2-husband.mp3' +const OUT_NAME = process.env.IT_OUT || 'infinitetalk-FULL.mp4' + +const WIDTH = 480 +const HEIGHT = 832 +const FPS = 25 +const AUDIO_SECONDS = parseFloat(process.env.IT_AUDIO_SECONDS || '37.2') +const NUM_FRAMES = Math.round(AUDIO_SECONDS * FPS) +const FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A 34-year-old woman with sun-kissed freckled skin and messy beach hair sits on a wicker chair at a luxury Mexican resort. Golden hour sunlight. Soft natural smile. She talks casually to the camera in a conspiratorial way. Subtle natural head movements. Caribbean ocean and palm trees softly blurred in background.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +const WORKFLOW: Record = { + // ---------- image ---------- + '1': { class_type: 'LoadImage', inputs: { image: REF_IMAGE } }, + '2': { + class_type: 'ImageResizeKJv2', + inputs: { + image: ['1', 0], + width: WIDTH, height: HEIGHT, + upscale_method: 'bicubic', + keep_proportion: 'resize', + pad_color: '0, 0, 0', + crop_position: 'center', + divisible_by: 16, + device: 'cpu', + }, + }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['2', 0] } }, + + // ---------- clip vision ---------- + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { + class_type: 'WanVideoClipVisionEncode', + inputs: { + clip_vision: ['4', 0], + image_1: ['3', 0], + strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', + force_offload: true, tiles: 0, ratio: 0.5, + }, + }, + + // ---------- audio → wav2vec embeds (no MelBandRoFormer, clean voiceover) ---------- + '6': { class_type: 'LoadAudio', inputs: { audio: AUDIO } }, + '7': { + class_type: 'DownloadAndLoadWav2VecModel', + inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', + base_precision: 'fp16', + load_device: 'main_device', + }, + }, + '8': { + class_type: 'MultiTalkWav2VecEmbeds', + inputs: { + wav2vec_model: ['7', 0], + audio_1: ['6', 0], + normalize_loudness: true, + num_frames: NUM_FRAMES, + fps: FPS, + audio_scale: 1.0, + audio_cfg_scale: 1.0, + multi_audio_type: 'para', + }, + }, + + // ---------- model stack ---------- + '9': { + class_type: 'WanVideoBlockSwap', + inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, + prefetch_blocks: 1, block_swap_debug: false, + }, + }, + '10': { + class_type: 'WanVideoLoraSelect', + inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false, + }, + }, + '11': { + class_type: 'MultiTalkModelLoader', + inputs: { model: 'Wan2_1-InfiniteTalk_Single_Q8.gguf' }, + }, + '12': { + class_type: 'WanVideoModelLoader', + inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', + base_precision: 'fp16_fast', + quantization: 'disabled', + load_device: 'offload_device', + attention_mode: 'sdpa', + block_swap_args: ['9', 0], + lora: ['10', 0], + multitalk_model: ['11', 0], + }, + }, + '13': { + class_type: 'WanVideoVAELoader', + inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' }, + }, + '14': { + class_type: 'WanVideoTextEncodeCached', + inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', + precision: 'bf16', + positive_prompt: POS_PROMPT, + negative_prompt: NEG_PROMPT, + quantization: 'disabled', + use_disk_cache: false, + device: 'gpu', + }, + }, + + // ---------- i2v multitalk + sampler ---------- + '15': { + class_type: 'WanVideoImageToVideoMultiTalk', + inputs: { + vae: ['13', 0], + width: ['3', 1], + height: ['3', 2], + frame_window_size: FRAME_WINDOW, + motion_frame: 25, + force_offload: false, + colormatch: 'disabled', + start_image: ['3', 0], + tiled_vae: false, + clip_embeds: ['5', 0], + mode: 'infinitetalk', + }, + }, + '16': { + class_type: 'WanVideoSampler', + inputs: { + model: ['12', 0], + image_embeds: ['15', 0], + text_embeds: ['14', 0], + steps: 6, + cfg: 1.0, + shift: 11.0, + seed: 2, + force_offload: true, + scheduler: 'unipc', + riflex_freq_index: 0, + denoise_strength: 1.0, + multitalk_embeds: ['8', 0], + }, + }, + + // ---------- decode + save ---------- + '17': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['16', 0] } }, + '18': { + class_type: 'VHS_VideoCombine', + inputs: { + images: ['17', 0], + audio: ['6', 0], + frame_rate: FPS, + loop_count: 0, + filename_prefix: 'hi2b_infinitetalk', + format: 'video/h264-mp4', + pix_fmt: 'yuv420p', + crf: 19, + save_metadata: true, + pingpong: false, + save_output: true, + }, + }, +} + +async function main() { + const clientId = `hi2b-inftalk-${Date.now()}` + console.log(`Submitting InfiniteTalk workflow (client=${clientId})`) + const submitRes = await fetch(`${HOST}/prompt`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ prompt: WORKFLOW, client_id: clientId }), + }) + if (!submitRes.ok) { + console.error(`HTTP ${submitRes.status}: ${await submitRes.text()}`) + process.exit(1) + } + const submitJson = (await submitRes.json()) as any + const promptId = submitJson.prompt_id + console.log(`✓ queued: ${promptId}`) + if (submitJson.node_errors && Object.keys(submitJson.node_errors).length > 0) { + console.error('NODE ERRORS:', JSON.stringify(submitJson.node_errors, null, 2)) + process.exit(1) + } + + let attempts = 0 + while (attempts < 720) { // 60 min ceiling + attempts++ + await new Promise(r => setTimeout(r, 5000)) + let hist: any + try { + const histRes = await fetch(`${HOST}/history/${promptId}`) + if (!histRes.ok) continue + hist = await histRes.json() + } catch { continue } + const entry = hist[promptId] + if (!entry) { + if (attempts % 6 === 0) { + const q = await fetch(`${HOST}/queue`).then(r => r.json()).catch(() => ({})) as any + console.log(` [${attempts * 5}s] running=${q?.queue_running?.length ?? 0} pending=${q?.queue_pending?.length ?? 0}`) + } + continue + } + const status = entry.status || {} + if (status.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + console.log(`✓ completed in ~${attempts * 5}s`) + const node18 = entry.outputs?.['18'] + const files = (node18?.gifs || node18?.videos || node18?.images || []) as any[] + const file = files.find(f => f.filename?.includes('-audio')) || files[0] + if (!file) { console.error('no outputs:', JSON.stringify(entry.outputs).slice(0, 600)); process.exit(1) } + const url = `${HOST}/view?filename=${encodeURIComponent(file.filename)}&subfolder=${encodeURIComponent(file.subfolder || '')}&type=${file.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const outDir = 'public/videos/ugc' + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) + const dest = `${outDir}/${OUT_NAME}` + writeFileSync(dest, buf) + console.log(`✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB)`) + return + } + if (status.status_str === 'error') { + console.error('EXECUTION ERROR:', JSON.stringify(status, null, 2).slice(0, 2000)) + process.exit(1) + } + } + console.error('Timed out after 60 min') + process.exit(1) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/make_captions.py b/scripts/make_captions.py new file mode 100644 index 0000000..ad55614 --- /dev/null +++ b/scripts/make_captions.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Generate UGC-style ASS caption files from VO audio using faster-whisper +word-level timestamps. + + python3 scripts/make_captions.py + +For every .mp3 in it writes /.ass — big bold +uppercase captions, lower-third, 2-3 words per chunk, synced to speech. +The .ass is later burned into the rendered video with ffmpeg. +""" +import sys, os, glob +from faster_whisper import WhisperModel + +AUDIO_DIR = sys.argv[1] if len(sys.argv) > 1 else 'public/audio/fish/batch11' +OUT_DIR = sys.argv[2] if len(sys.argv) > 2 else 'public/videos/ugc/batch11' +os.makedirs(OUT_DIR, exist_ok=True) + +# Video canvas (matches the InfiniteTalk render). +PLAY_W, PLAY_H = 480, 832 +MAX_WORDS = 2 # words per caption chunk — keep it short for TikTok +MAX_CHARS = 13 # break before a chunk gets wider than the frame +MAX_CHUNK_SEC = 1.0 # don't let a chunk linger too long + +# WrapStyle 0 = smart word wrap (a rare too-wide chunk drops to 2 lines +# instead of overflowing off-screen). Font 54 + 20px side margins keeps +# almost every 2-word chunk on a single line inside the 480px frame. +ASS_HEADER = f"""[Script Info] +ScriptType: v4.00+ +PlayResX: {PLAY_W} +PlayResY: {PLAY_H} +WrapStyle: 0 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: UGC,DejaVu Sans,54,&H00FFFFFF,&H000000FF,&H00111111,&H96000000,-1,0,0,0,100,100,0,0,1,5,2,2,20,20,210,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + + +def ts(seconds: float) -> str: + """Seconds -> ASS time H:MM:SS.cc""" + if seconds < 0: + seconds = 0 + cs = int(round(seconds * 100)) + h, cs = divmod(cs, 360000) + m, cs = divmod(cs, 6000) + s, cs = divmod(cs, 100) + return f"{h}:{m:02d}:{s:02d}.{cs:02d}" + + +def chunk_words(words): + """Group whisper word objects into short caption chunks that fit the frame.""" + chunks, cur = [], [] + for w in words: + word = w.word.strip() + if not word: + continue + # would adding this word overflow the line? close the chunk first. + if cur: + cur_chars = sum(len(x.word.strip()) + 1 for x in cur) - 1 + span = w.end - cur[0].start + if (len(cur) >= MAX_WORDS + or cur_chars + 1 + len(word) > MAX_CHARS + or span >= MAX_CHUNK_SEC): + chunks.append(cur) + cur = [] + cur.append(w) + if cur: + chunks.append(cur) + return chunks + + +def build_ass(words) -> str: + lines = [ASS_HEADER] + chunks = chunk_words(words) + for i, ch in enumerate(chunks): + start = ch[0].start + # extend each chunk to the next chunk's start so there is no flicker gap + end = chunks[i + 1][0].start if i + 1 < len(chunks) else ch[-1].end + 0.15 + text = ' '.join(w.word.strip() for w in ch).upper() + text = text.replace('\n', ' ') + # subtle pop-in + lines.append( + f"Dialogue: 0,{ts(start)},{ts(end)},UGC,,0,0,0,,{{\\fad(60,0)}}{text}" + ) + return '\n'.join(lines) + '\n' + + +def main(): + print('loading faster-whisper (base)...') + model = WhisperModel('base', device='cpu', compute_type='int8') + + audios = sorted(glob.glob(os.path.join(AUDIO_DIR, '*.mp3'))) + if not audios: + print(f'no mp3 in {AUDIO_DIR}') + sys.exit(1) + + for path in audios: + vid_id = os.path.splitext(os.path.basename(path))[0] + segments, _ = model.transcribe(path, word_timestamps=True, language='en') + words = [] + for seg in segments: + if seg.words: + words.extend(seg.words) + if not words: + print(f' ! {vid_id}: no words') + continue + out = os.path.join(OUT_DIR, f'{vid_id}.ass') + with open(out, 'w') as f: + f.write(build_ass(words)) + print(f' ✓ {vid_id}.ass ({len(words)} words)') + + print('done') + + +if __name__ == '__main__': + main() diff --git a/scripts/mix-ambiance.sh b/scripts/mix-ambiance.sh new file mode 100755 index 0000000..041245c --- /dev/null +++ b/scripts/mix-ambiance.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mix kids-playing ambiance under each batch-15 video (if ambiance file present). +set -u +DIR=public/videos/ugc/batch15 +AMB="${1:-public/audio/fish/kids-ambiance.mp3}" +[ -f "$AMB" ] || { echo "no ambiance file ($AMB) — skipping mix"; exit 0; } +for v in "$DIR"/b15-*.mp4; do + [ -e "$v" ] || continue + case "$v" in *-amb.mp4) continue;; esac + out="${v%.mp4}-amb.mp4" + ffmpeg -y -i "$v" -stream_loop -1 -i "$AMB" -filter_complex \ + "[1:a]volume=0.18[amb];[0:a][amb]amix=inputs=2:duration=first:dropout_transition=0[a]" \ + -map 0:v -map "[a]" -c:v copy -c:a aac -shortest "$out" 2>/dev/null \ + && echo "mixed $(basename "$out")" +done diff --git a/scripts/moshi-tts.ts b/scripts/moshi-tts.ts new file mode 100644 index 0000000..34495ad --- /dev/null +++ b/scripts/moshi-tts.ts @@ -0,0 +1,141 @@ +/** + * Moshi TTS client — generates voiceover via the Kyutai Rust server at + * ws://45.135.163.226:32574/api/tts_streaming. + * + * Protocol (per official tts_rust_server.py): + * Connect to: ws:///api/tts_streaming?voice=&format= + * Header: kyutai-api-key: (default "public_token") + * Send (msgpack): {type: "Text", text: ""} per word + * Send (msgpack): {type: "Eos"} to finish + * Receive: PcmMessagePack → msgpack {type:"Audio", pcm:[f32...]} @ 24kHz + * OggOpus → raw binary Ogg-encapsulated Opus @ 48kHz (this server) + * + * npx tsx scripts/moshi-tts.ts \ + * --text "Hello, this is a test." \ + * --out public/audio/test.wav \ + * [--voice expresso/ex03-ex01_happy_001_channel1_334s.wav] + */ +import { writeFileSync, mkdirSync, existsSync } from 'fs' +import { dirname } from 'path' +import { execSync } from 'child_process' +import WebSocket from 'ws' +import { pack as msgpackPack, unpack as msgpackUnpack } from 'msgpackr' + +const args = parseArgs(process.argv.slice(2)) + +const SERVER = args.url || 'ws://45.135.163.226:32574' +const VOICE = args.voice || 'expresso/ex03-ex01_calm_001_channel1_334s.wav' +const API_KEY = args['api-key'] || 'public_token' +const FORMAT = args.format || 'PcmMessagePack' // PcmMessagePack (24kHz raw, recommended) | OggOpus (48kHz lossy) +const TEXT = args.text || 'Day four in Mexico. I paid two hundred ninety dollars for all of this.' +const OUT_RAW = args.outRaw || 'public/audio/moshi-tts.opus' +const OUT = args.out || 'public/audio/moshi-tts.wav' + +if (!existsSync(dirname(OUT))) mkdirSync(dirname(OUT), { recursive: true }) +if (!existsSync(dirname(OUT_RAW))) mkdirSync(dirname(OUT_RAW), { recursive: true }) + +const qs = new URLSearchParams({ voice: VOICE, format: FORMAT, auth_id: API_KEY }).toString() +const uri = `${SERVER}/api/tts_streaming?${qs}` + +console.log(`Connecting to ${uri}`) +console.log(`Voice: ${VOICE}`) +console.log(`Text: ${TEXT}`) + +const ws = new WebSocket(uri, { + headers: { 'kyutai-api-key': API_KEY }, +}) + +const audioChunks: Buffer[] = [] +let pcmFloats: number[] = [] +let receivedAny = false +let outputFormat: 'pcm' | 'opus' | 'unknown' = 'unknown' + +ws.on('open', () => { + console.log('✓ connected — streaming text word-by-word') + for (const word of TEXT.split(/\s+/).filter(Boolean)) { + ws.send(msgpackPack({ type: 'Text', text: word })) + } + ws.send(msgpackPack({ type: 'Eos' })) + console.log(' sent EOS, waiting for audio…') +}) + +ws.on('message', (data: Buffer) => { + receivedAny = true + // Try msgpack first (PcmMessagePack format) + try { + const msg = msgpackUnpack(data) as any + if (msg && typeof msg === 'object' && msg.type === 'Audio' && Array.isArray(msg.pcm)) { + outputFormat = 'pcm' + pcmFloats.push(...msg.pcm) + return + } + if (msg && typeof msg === 'object' && msg.type === 'Ready') { + console.log(' ← Ready') + return + } + if (msg && typeof msg === 'object' && msg.type) { + console.log(` ← ${msg.type}`, JSON.stringify(msg).slice(0, 100)) + return + } + } catch { + // Not msgpack — treat as raw Ogg/Opus bytes + } + outputFormat = 'opus' + audioChunks.push(data) +}) + +ws.on('close', () => { + if (!receivedAny) { console.error('No data received'); process.exit(1) } + console.log(`✓ stream closed (format=${outputFormat})`) + + if (outputFormat === 'opus') { + const buf = Buffer.concat(audioChunks) + writeFileSync(OUT_RAW, buf) + console.log(` saved raw stream: ${OUT_RAW} (${(buf.length / 1024).toFixed(1)}kb)`) + // Convert to WAV via ffmpeg + execSync(`ffmpeg -y -i "${OUT_RAW}" -ar 48000 -ac 1 "${OUT}"`, { stdio: 'pipe' }) + const dur = execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${OUT}"`).toString().trim() + console.log(`✓ converted: ${OUT} (${dur}s)`) + } else if (outputFormat === 'pcm') { + // Write as WAV manually (24kHz mono float32 → int16) + const SR = 24000 + const samples = pcmFloats + const int16 = new Int16Array(samples.length) + for (let i = 0; i < samples.length; i++) { + const s = Math.max(-1, Math.min(1, samples[i])) + int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff + } + const wav = wavFromInt16(int16, SR) + writeFileSync(OUT, wav) + console.log(`✓ saved WAV (24kHz PCM): ${OUT} (${(samples.length / SR).toFixed(2)}s, ${(wav.length / 1024).toFixed(1)}kb)`) + } else { + console.error('Unknown format — saved nothing'); process.exit(1) + } +}) + +ws.on('error', (err) => { console.error('WS error:', err.message); process.exit(1) }) + +function wavFromInt16(samples: Int16Array, sr: number): Buffer { + const dataLen = samples.length * 2 + const buf = Buffer.alloc(44 + dataLen) + buf.write('RIFF', 0); buf.writeUInt32LE(36 + dataLen, 4); buf.write('WAVE', 8) + buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20) + buf.writeUInt16LE(1, 22); buf.writeUInt32LE(sr, 24); buf.writeUInt32LE(sr * 2, 28) + buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34) + buf.write('data', 36); buf.writeUInt32LE(dataLen, 40) + Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength).copy(buf, 44) + return buf +} + +function parseArgs(argv: string[]): Record { + const out: Record = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a.startsWith('--')) { + const key = a.slice(2) + const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true' + out[key] = val + } + } + return out +} diff --git a/scripts/multitalk-render.ts b/scripts/multitalk-render.ts new file mode 100644 index 0000000..df122ed --- /dev/null +++ b/scripts/multitalk-render.ts @@ -0,0 +1,150 @@ +/** + * MultiTalk multi-speaker render on Instance 2. + * + * Two people in frame, only one talks: the woman is masked to the voiceover, + * the man is masked to a silent track — so InfiniteTalk Multi only animates + * the speaker's mouth. + * + * IT_FRAME=v5-1-frame.jpg IT_MASK_W=v5-1-mask-w.png IT_MASK_M=v5-1-mask-m.png \ + * IT_AUDIO=b13-01-anniversary.mp3 IT_SILENCE=silence-33s.mp3 \ + * IT_AUDIO_SECONDS=31.634 IT_OUT=b13-couple-multitalk-test.mp4 \ + * npx tsx scripts/multitalk-render.ts + * + * All inputs must already be in Instance 2's /workspace/ComfyUI/input/. + */ +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const HOST = process.env.INST2_URL || 'http://localhost:18892' +const JSON_HEADERS = { 'Content-Type': 'application/json' } + +const FRAME = process.env.IT_FRAME || 'v5-1-frame.jpg' +const MASK_W = process.env.IT_MASK_W || 'v5-1-mask-w.png' +const MASK_M = process.env.IT_MASK_M || 'v5-1-mask-m.png' +const MASK_BG = process.env.IT_MASK_BG || 'v5-1-mask-bg.png' +const AUDIO = process.env.IT_AUDIO || 'b13-01-anniversary.mp3' +const SILENCE = process.env.IT_SILENCE || 'silence-33s.mp3' +const OUT_NAME = process.env.IT_OUT || 'multitalk-test.mp4' +const FPS = 25 +const AUDIO_SECONDS = parseFloat(process.env.IT_AUDIO_SECONDS || '31.6') +const NUM_FRAMES = Math.round(AUDIO_SECONDS * FPS) +const FRAME_WINDOW = 81 + +const POS_PROMPT = + 'A happy couple standing close together on a beach at a luxury Mexican beach resort, each holding a tropical drink. The woman in the foreground talks warmly to the camera while her partner stands beside her smiling, listening. Natural relaxed expressions, gentle head movements. Turquoise ocean, palm trees and white sand softly blurred behind them. Warm golden sunlight.' +const NEG_PROMPT = + 'bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards' + +const WORKFLOW: Record = { + // keyframe (already 480x832) + '1': { class_type: 'LoadImage', inputs: { image: FRAME } }, + '3': { class_type: 'GetImageSizeAndCount', inputs: { image: ['1', 0] } }, + + // clip vision + '4': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'clip_vision_h.safetensors' } }, + '5': { class_type: 'WanVideoClipVisionEncode', inputs: { + clip_vision: ['4', 0], image_1: ['1', 0], strength_1: 1.0, strength_2: 1.0, + crop: 'center', combine_embeds: 'average', force_offload: true, tiles: 0, ratio: 0.5 } }, + + // audio: woman VO + silent track for the man + '6': { class_type: 'LoadAudio', inputs: { audio: AUDIO } }, + '8': { class_type: 'LoadAudio', inputs: { audio: SILENCE } }, + '7': { class_type: 'DownloadAndLoadWav2VecModel', inputs: { + model: 'TencentGameMate/chinese-wav2vec2-base', base_precision: 'fp16', load_device: 'main_device' } }, + + // per-person masks → batch + '9': { class_type: 'LoadImage', inputs: { image: MASK_W } }, + '10': { class_type: 'ImageToMask', inputs: { image: ['9', 0], channel: 'red' } }, + '11': { class_type: 'LoadImage', inputs: { image: MASK_M } }, + '12': { class_type: 'ImageToMask', inputs: { image: ['11', 0], channel: 'red' } }, + '25': { class_type: 'LoadImage', inputs: { image: MASK_BG } }, + '26': { class_type: 'ImageToMask', inputs: { image: ['25', 0], channel: 'red' } }, + '13': { class_type: 'MaskBatchMulti', inputs: { inputcount: 3, mask_1: ['10', 0], mask_2: ['12', 0], mask_3: ['26', 0] } }, + + // multitalk embeds — audio_1=woman VO, audio_2=silence, masks bind voice→person + '14': { class_type: 'MultiTalkWav2VecEmbeds', inputs: { + wav2vec_model: ['7', 0], audio_1: ['6', 0], audio_2: ['8', 0], + ref_target_masks: ['13', 0], + normalize_loudness: true, num_frames: NUM_FRAMES, fps: FPS, + audio_scale: 1.0, audio_cfg_scale: 1.0, multi_audio_type: 'para' } }, + + // model stack + '15': { class_type: 'WanVideoBlockSwap', inputs: { + blocks_to_swap: 20, offload_img_emb: false, offload_txt_emb: false, + use_non_blocking: true, vace_blocks_to_swap: 0, prefetch_blocks: 1, block_swap_debug: false } }, + '16': { class_type: 'WanVideoLoraSelect', inputs: { + lora: 'lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors', + strength: 1.0, low_mem_load: false, merge_loras: false } }, + '17': { class_type: 'MultiTalkModelLoader', inputs: { model: 'Wan2_1-InfiniteTalk_Multi_Q8.gguf' } }, + '18': { class_type: 'WanVideoModelLoader', inputs: { + model: 'Wan2.1-I2V-14B-480P-Q8_0.gguf', base_precision: 'fp16_fast', + quantization: 'disabled', load_device: 'offload_device', attention_mode: 'sdpa', + block_swap_args: ['15', 0], lora: ['16', 0], multitalk_model: ['17', 0] } }, + '19': { class_type: 'WanVideoVAELoader', inputs: { model_name: 'Wan2_1_VAE_bf16.safetensors', precision: 'bf16' } }, + '20': { class_type: 'WanVideoTextEncodeCached', inputs: { + model_name: 'umt5-xxl-enc-bf16.safetensors', precision: 'bf16', + positive_prompt: POS_PROMPT, negative_prompt: NEG_PROMPT, + quantization: 'disabled', use_disk_cache: false, device: 'gpu' } }, + + // i2v multitalk + sampler + '21': { class_type: 'WanVideoImageToVideoMultiTalk', inputs: { + vae: ['19', 0], width: ['3', 1], height: ['3', 2], frame_window_size: FRAME_WINDOW, + motion_frame: 25, force_offload: false, colormatch: 'disabled', + start_image: ['1', 0], tiled_vae: false, clip_embeds: ['5', 0], mode: 'infinitetalk' } }, + '22': { class_type: 'WanVideoSampler', inputs: { + model: ['18', 0], image_embeds: ['21', 0], text_embeds: ['20', 0], + steps: 6, cfg: 1.0, shift: 11.0, seed: 2, force_offload: true, + scheduler: 'unipc', riflex_freq_index: 0, denoise_strength: 1.0, multitalk_embeds: ['14', 0] } }, + + // decode + save + '23': { class_type: 'WanVideoPassImagesFromSamples', inputs: { samples: ['22', 0] } }, + '24': { class_type: 'VHS_VideoCombine', inputs: { + images: ['23', 0], audio: ['6', 0], frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_multitalk', format: 'video/h264-mp4', pix_fmt: 'yuv420p', + crf: 19, save_metadata: true, pingpong: false, save_output: true } }, +} + +async function main() { + console.log(`MultiTalk render — frame=${FRAME} audio=${AUDIO} (${NUM_FRAMES}f)`) + const submit = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: JSON_HEADERS, + body: JSON.stringify({ prompt: WORKFLOW, client_id: `multitalk-${Date.now()}` }), + }) + if (!submit.ok) { console.error(`HTTP ${submit.status}: ${await submit.text()}`); process.exit(1) } + const sj = await submit.json() as any + if (sj.node_errors && Object.keys(sj.node_errors).length > 0) { + console.error('NODE ERRORS:', JSON.stringify(sj.node_errors, null, 2)); process.exit(1) + } + const promptId = sj.prompt_id + console.log(`✓ queued: ${promptId}`) + + for (let i = 0; i < 900; i++) { + await new Promise(r => setTimeout(r, 5000)) + let entry: any + try { + const h = await fetch(`${HOST}/history/${promptId}`) + if (!h.ok) continue + entry = (await h.json())[promptId] + } catch { continue } + if (!entry) { if (i % 12 === 0) console.log(` [${i * 5}s] running...`); continue } + const st = entry.status || {} + if (st.status_str === 'error') { console.error('ERROR:', JSON.stringify(st, null, 2).slice(0, 2000)); process.exit(1) } + if (st.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const n24 = entry.outputs?.['24'] + const files = (n24?.gifs || n24?.videos || []) as any[] + const f = files.find(x => x.filename?.includes('-audio')) || files[0] + if (!f) { console.error('no output:', JSON.stringify(entry.outputs).slice(0, 600)); process.exit(1) } + console.log(`✓ done in ~${i * 5}s → ${f.filename}`) + const url = `${HOST}/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || '')}&type=${f.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const outDir = 'public/videos/ugc' + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) + writeFileSync(`${outDir}/${OUT_NAME}`, buf) + console.log(`✓ saved ${outDir}/${OUT_NAME} (${(buf.length / 1024 / 1024).toFixed(2)} MB)`) + return + } + } + console.error('timed out') + process.exit(1) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/orchestrator.log b/scripts/orchestrator.log new file mode 100644 index 0000000..538c9be --- /dev/null +++ b/scripts/orchestrator.log @@ -0,0 +1,3865 @@ +[2026-06-05 12:22:38] orchestrator started (pid 177546) +[2026-06-05 12:22:42] === rendering batch20 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b20-01-the-test (keyframe-v13-1.jpg, 23.5s, 586f) === + queued 6f16d26e-3a9a-4781-8495-a615bb217a87 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + [900s] running... + [960s] running... + [1020s] running... + [1080s] running... + [1140s] running... + [1200s] running... + [1260s] running... + ✓ saved public/videos/ugc/batch20/b20-01-the-test.mp4 (11.05 MB, ~1320s) + +[inst1] === b20-02-90-minutes (keyframe-v13-1.jpg, 17.3s, 434f) === + queued d0489f29-9482-473b-9cf0-380aba994ce3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + [900s] running... + ✓ saved public/videos/ugc/batch20/b20-02-90-minutes.mp4 (8.28 MB, ~955s) + +[inst1] === b20-03-what-they-ask (keyframe-v13-2.jpg, 15.3s, 383f) === + queued b6bdc962-1740-4d52-8f19-63d4329bc0cb + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch20/b20-03-what-they-ask.mp4 (4.59 MB, ~835s) + +[inst1] === b20-04-easy-test (keyframe-v13-2.jpg, 16.6s, 415f) === + queued 2866a209-6a33-4ffd-90b4-19226a7893d1 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch20/b20-04-easy-test.mp4 (4.65 MB, ~835s) + +[inst1] === b20-05-skeptic-test (keyframe-v13-3.jpg, 14.3s, 357f) === + queued 3a6363cf-2f53-4eeb-84cd-df956c0a482c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch20/b20-05-skeptic-test.mp4 (6.22 MB, ~715s) + +[inst1] === b20-06-bring-something (keyframe-v13-3.jpg, 13.8s, 345f) === + queued d4631a5b-b43e-4dc0-94dd-0894890575b4 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch20/b20-06-bring-something.mp4 (6.05 MB, ~715s) + +[inst1] === b20-07-honest-pitch (keyframe-v13-4.jpg, 14.4s, 360f) === + queued 16043282-887d-40ae-acb0-e42ea0911907 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch20/b20-07-honest-pitch.mp4 (5.18 MB, ~715s) + +[inst1] === b20-08-no-pressure (keyframe-v13-4.jpg, 15.3s, 381f) === + queued 91548934-fba4-426c-855e-63fb933b5543 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch20/b20-08-no-pressure.mp4 (5.76 MB, ~830s) + +[inst1] === b20-09-hour-vs-airbnb (keyframe-v13-5.jpg, 15.4s, 386f) === + queued a003865b-4b57-4ad5-b720-941034badfcd + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch20/b20-09-hour-vs-airbnb.mp4 (4.39 MB, ~825s) + +[inst1] === b20-10-cta (keyframe-v13-5.jpg, 14.8s, 371f) === + queued f65f8831-1583-4c28-b052-2cee89bffc03 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch20/b20-10-cta.mp4 (4.44 MB, ~830s) + +[inst1] BATCH COMPLETE +[2026-06-05 15:18:34] batch20 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH20 FINALIZED vie 05 jun 2026 15:19:07 EDT +-rw-rw-r-- 1 na na 11590642 jun 5 12:50 public/videos/ugc/batch20/b20-01-the-test.mp4 +-rw-rw-r-- 1 na na 8677726 jun 5 13:09 public/videos/ugc/batch20/b20-02-90-minutes.mp4 +-rw-rw-r-- 1 na na 4817245 jun 5 13:26 public/videos/ugc/batch20/b20-03-what-they-ask.mp4 +-rw-rw-r-- 1 na na 4873296 jun 5 13:43 public/videos/ugc/batch20/b20-04-easy-test.mp4 +-rw-rw-r-- 1 na na 6522765 jun 5 13:58 public/videos/ugc/batch20/b20-05-skeptic-test.mp4 +-rw-rw-r-- 1 na na 6342031 jun 5 14:12 public/videos/ugc/batch20/b20-06-bring-something.mp4 +-rw-rw-r-- 1 na na 5430705 jun 5 14:27 public/videos/ugc/batch20/b20-07-honest-pitch.mp4 +-rw-rw-r-- 1 na na 6038874 jun 5 14:44 public/videos/ugc/batch20/b20-08-no-pressure.mp4 +-rw-rw-r-- 1 na na 4604832 jun 5 15:01 public/videos/ugc/batch20/b20-09-hour-vs-airbnb.mp4 +-rw-rw-r-- 1 na na 4651239 jun 5 15:18 public/videos/ugc/batch20/b20-10-cta.mp4 +[2026-06-05 15:19:07] batch20 DONE + deployed to gw +[2026-06-05 15:19:10] === rendering batch22 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b22-01-i-set-a-timer (keyframe-v13-1.jpg, 16.4s, 409f) === + queued 3e2f53a2-971b-42ce-8da2-4eb598070f8e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch22/b22-01-i-set-a-timer.mp4 (7.48 MB, ~840s) + +[inst1] === b22-02-15-min-in (keyframe-v13-1.jpg, 13.3s, 334f) === + queued ca5fa75f-03e8-4a97-b1a0-eb219b87aea0 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch22/b22-02-15-min-in.mp4 (6.56 MB, ~705s) + +[inst1] === b22-03-30-min-coffee (keyframe-v13-2.jpg, 13.3s, 332f) === + queued b2ee9754-c94a-4996-b5bf-93928cd175b3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch22/b22-03-30-min-coffee.mp4 (4.10 MB, ~715s) + +[inst1] === b22-04-45-the-numbers (keyframe-v13-2.jpg, 12.1s, 302f) === + queued 4ee9b086-f799-4975-a571-ef0f4a6534f4 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-04-45-the-numbers.mp4 (3.52 MB, ~595s) + +[inst1] === b22-05-60-min-pitch (keyframe-v13-3.jpg, 11.2s, 280f) === + queued c9ed0547-ce4e-4ecc-aff5-e45cc670e039 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-05-60-min-pitch.mp4 (5.18 MB, ~595s) + +[inst1] === b22-06-said-no (keyframe-v13-3.jpg, 12.2s, 306f) === + queued 37e241ca-a2ba-4088-98cb-a51682ab6bca + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-06-said-no.mp4 (5.11 MB, ~595s) + +[inst1] === b22-07-72-min-done (keyframe-v13-4.jpg, 10.6s, 265f) === + queued d4a301f3-41d6-4319-ad08-b5074d4d3739 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-07-72-min-done.mp4 (4.45 MB, ~595s) + +[inst1] === b22-08-the-other-shoe (keyframe-v13-4.jpg, 10.8s, 271f) === + queued 89845a29-bfee-424a-9caa-f149f51fe6b7 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-08-the-other-shoe.mp4 (4.53 MB, ~595s) + +[inst1] === b22-09-five-days-later (keyframe-v13-5.jpg, 11.5s, 289f) === + queued c73c7435-5eec-485e-8285-83bc047d3090 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-09-five-days-later.mp4 (3.44 MB, ~595s) + +[inst1] === b22-10-cta-timer (keyframe-v13-5.jpg, 11.9s, 296f) === + queued b485afe4-9ab0-4d21-9673-824ca166c175 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch22/b22-10-cta-timer.mp4 (3.56 MB, ~595s) + +[inst1] BATCH COMPLETE +[2026-06-05 17:31:18] batch22 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH22 FINALIZED vie 05 jun 2026 17:31:34 EDT +-rw-rw-r-- 1 na na 7841407 jun 5 15:36 public/videos/ugc/batch22/b22-01-i-set-a-timer.mp4 +-rw-rw-r-- 1 na na 6874113 jun 5 15:51 public/videos/ugc/batch22/b22-02-15-min-in.mp4 +-rw-rw-r-- 1 na na 4294444 jun 5 16:05 public/videos/ugc/batch22/b22-03-30-min-coffee.mp4 +-rw-rw-r-- 1 na na 3690203 jun 5 16:18 public/videos/ugc/batch22/b22-04-45-the-numbers.mp4 +-rw-rw-r-- 1 na na 5426534 jun 5 16:30 public/videos/ugc/batch22/b22-05-60-min-pitch.mp4 +-rw-rw-r-- 1 na na 5359617 jun 5 16:42 public/videos/ugc/batch22/b22-06-said-no.mp4 +-rw-rw-r-- 1 na na 4670882 jun 5 16:54 public/videos/ugc/batch22/b22-07-72-min-done.mp4 +-rw-rw-r-- 1 na na 4752071 jun 5 17:06 public/videos/ugc/batch22/b22-08-the-other-shoe.mp4 +-rw-rw-r-- 1 na na 3610084 jun 5 17:19 public/videos/ugc/batch22/b22-09-five-days-later.mp4 +-rw-rw-r-- 1 na na 3731771 jun 5 17:31 public/videos/ugc/batch22/b22-10-cta-timer.mp4 +[2026-06-05 17:31:34] batch22 DONE + deployed to gw +[2026-06-05 17:31:37] === rendering batch23 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b23-01-the-math (keyframe-v13-1.jpg, 16.2s, 406f) === + queued 76c9f7ba-9fc1-42c2-9050-d02af1e91971 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch23/b23-01-the-math.mp4 (7.69 MB, ~840s) + +[inst1] === b23-02-18-min-per-day (keyframe-v13-1.jpg, 11.9s, 296f) === + queued 41d5630d-51e9-4d0b-804d-d3769d8e40ce + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch23/b23-02-18-min-per-day.mp4 (5.75 MB, ~595s) + +[inst1] === b23-03-vs-airbnb-fee (keyframe-v13-2.jpg, 13.4s, 336f) === + queued e7fb79a8-4676-424f-9cfb-dedbecfd9a0b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch23/b23-03-vs-airbnb-fee.mp4 (4.16 MB, ~700s) + +[inst1] === b23-04-vs-tsa-line (keyframe-v13-2.jpg, 10.1s, 252f) === + queued 0319e4d2-0cb0-4136-bec0-755c49b393f1 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch23/b23-04-vs-tsa-line.mp4 (3.60 MB, ~595s) + +[inst1] === b23-05-per-meal (keyframe-v13-3.jpg, 11.2s, 280f) === + queued fa21b0e9-4323-4c7c-a320-2b844aab402a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch23/b23-05-per-meal.mp4 (5.21 MB, ~595s) + +[inst1] === b23-06-skeptic-math (keyframe-v13-3.jpg, 14.5s, 362f) === + queued 7418d627-8ba4-4115-9691-f21ae9620c00 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + ✓ saved public/videos/ugc/batch23/b23-06-skeptic-math.mp4 (6.81 MB, ~845s) + +[inst1] === b23-07-vs-cruise-line (keyframe-v13-4.jpg, 11.1s, 278f) === + queued b0bc32a7-5569-408e-8ea7-116eb197f609 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch23/b23-07-vs-cruise-line.mp4 (4.56 MB, ~605s) + +[inst1] === b23-08-hour-i-have (keyframe-v13-4.jpg, 12.7s, 317f) === + queued ad8f129e-2d85-4354-b443-3d47172ea5ec + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch23/b23-08-hour-i-have.mp4 (5.08 MB, ~725s) + +[inst1] === b23-09-the-real-cost (keyframe-v13-5.jpg, 12.7s, 319f) === + queued 4adf3978-9ca1-43d2-ba3e-dd94c02e121e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch23/b23-09-the-real-cost.mp4 (3.97 MB, ~725s) + +[inst1] === b23-10-cta-math (keyframe-v13-5.jpg, 11.1s, 277f) === + queued 53dda8e5-2934-4cd1-849b-c6414fa1d166 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch23/b23-10-cta-math.mp4 (3.50 MB, ~605s) + +[inst1] BATCH COMPLETE +[2026-06-05 19:51:13] batch23 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH23 FINALIZED vie 05 jun 2026 19:51:28 EDT +-rw-rw-r-- 1 na na 8058510 jun 5 17:49 public/videos/ugc/batch23/b23-01-the-math.mp4 +-rw-rw-r-- 1 na na 6027999 jun 5 18:01 public/videos/ugc/batch23/b23-02-18-min-per-day.mp4 +-rw-rw-r-- 1 na na 4365409 jun 5 18:15 public/videos/ugc/batch23/b23-03-vs-airbnb-fee.mp4 +-rw-rw-r-- 1 na na 3773063 jun 5 18:28 public/videos/ugc/batch23/b23-04-vs-tsa-line.mp4 +-rw-rw-r-- 1 na na 5461814 jun 5 18:40 public/videos/ugc/batch23/b23-05-per-meal.mp4 +-rw-rw-r-- 1 na na 7139634 jun 5 18:57 public/videos/ugc/batch23/b23-06-skeptic-math.mp4 +-rw-rw-r-- 1 na na 4785500 jun 5 19:09 public/videos/ugc/batch23/b23-07-vs-cruise-line.mp4 +-rw-rw-r-- 1 na na 5326906 jun 5 19:24 public/videos/ugc/batch23/b23-08-hour-i-have.mp4 +-rw-rw-r-- 1 na na 4159562 jun 5 19:39 public/videos/ugc/batch23/b23-09-the-real-cost.mp4 +-rw-rw-r-- 1 na na 3673598 jun 5 19:51 public/videos/ugc/batch23/b23-10-cta-math.mp4 +[2026-06-05 19:51:28] batch23 DONE + deployed to gw +[2026-06-05 19:51:31] === rendering batch25 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b25-01-i-was-warned (keyframe-v13-1.jpg, 18.7s, 467f) === + queued bfd2dcd6-fb85-4784-9531-0d6e049bc3e7 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + [900s] running... + [960s] running... + ✓ saved public/videos/ugc/batch25/b25-01-i-was-warned.mp4 (8.39 MB, ~965s) + +[inst1] === b25-02-no-locked-doors (keyframe-v13-1.jpg, 15.8s, 395f) === + queued aa3e61af-b444-4fb0-86e6-0bfcafab8b9b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch25/b25-02-no-locked-doors.mp4 (7.52 MB, ~830s) + +[inst1] === b25-03-no-bait-switch (keyframe-v13-2.jpg, 14.8s, 371f) === + queued 3d8f3661-9169-4c6b-bf5e-95618d8e73d9 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch25/b25-03-no-bait-switch.mp4 (4.54 MB, ~805s) + +[inst1] === b25-04-no-shouting (keyframe-v13-2.jpg, 15.3s, 383f) === + queued 93c2c459-268c-4856-a04d-09828a58af2b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch25/b25-04-no-shouting.mp4 (4.65 MB, ~815s) + +[inst1] === b25-05-reddit-was-wrong (keyframe-v13-3.jpg, 16.3s, 408f) === + queued 8d54ff8d-049a-42fa-8bcf-eeccd977e700 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch25/b25-05-reddit-was-wrong.mp4 (6.94 MB, ~805s) + +[inst1] === b25-06-mom-warned (keyframe-v13-3.jpg, 14.5s, 362f) === + queued 0f6af91c-1aab-474b-9363-f9e786cb383f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch25/b25-06-mom-warned.mp4 (6.11 MB, ~700s) + +[inst1] === b25-07-no-followups (keyframe-v13-4.jpg, 13.9s, 347f) === + queued 23af4e9d-2547-458a-b831-eb39fc07f6e9 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch25/b25-07-no-followups.mp4 (5.17 MB, ~710s) + +[inst1] === b25-08-one-ask (keyframe-v13-4.jpg, 12.9s, 322f) === + queued cb34a38c-84aa-43bb-8eb1-44a0acccdc8b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch25/b25-08-one-ask.mp4 (5.14 MB, ~705s) + +[inst1] === b25-09-walking-out (keyframe-v13-5.jpg, 15.6s, 389f) === + queued e8ad53bb-b26a-4ca7-acb8-8414f667b84c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch25/b25-09-walking-out.mp4 (4.48 MB, ~835s) + +[inst1] === b25-10-cta-no-fear (keyframe-v13-5.jpg, 14.1s, 353f) === + queued a9fddda1-f7f2-4f59-b333-7d32f5c0c6ee + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch25/b25-10-cta-no-fear.mp4 (4.04 MB, ~710s) + +[inst1] BATCH COMPLETE +[2026-06-05 22:35:34] batch25 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH25 FINALIZED vie 05 jun 2026 22:35:52 EDT +-rw-rw-r-- 1 na na 8799761 jun 5 20:11 public/videos/ugc/batch25/b25-01-i-was-warned.mp4 +-rw-rw-r-- 1 na na 7883625 jun 5 20:28 public/videos/ugc/batch25/b25-02-no-locked-doors.mp4 +-rw-rw-r-- 1 na na 4765161 jun 5 20:45 public/videos/ugc/batch25/b25-03-no-bait-switch.mp4 +-rw-rw-r-- 1 na na 4873844 jun 5 21:02 public/videos/ugc/batch25/b25-04-no-shouting.mp4 +-rw-rw-r-- 1 na na 7279184 jun 5 21:19 public/videos/ugc/batch25/b25-05-reddit-was-wrong.mp4 +-rw-rw-r-- 1 na na 6402600 jun 5 21:34 public/videos/ugc/batch25/b25-06-mom-warned.mp4 +-rw-rw-r-- 1 na na 5416305 jun 5 21:49 public/videos/ugc/batch25/b25-07-no-followups.mp4 +-rw-rw-r-- 1 na na 5391910 jun 5 22:03 public/videos/ugc/batch25/b25-08-one-ask.mp4 +-rw-rw-r-- 1 na na 4701267 jun 5 22:20 public/videos/ugc/batch25/b25-09-walking-out.mp4 +-rw-rw-r-- 1 na na 4232615 jun 5 22:35 public/videos/ugc/batch25/b25-10-cta-no-fear.mp4 +[2026-06-05 22:35:52] batch25 DONE + deployed to gw +[2026-06-05 22:35:55] === rendering batch27 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b27-01-the-countdown (keyframe-v13-1.jpg, 15.4s, 386f) === + queued c1855707-b5c6-4db7-8dba-3c1ce603bd9b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch27/b27-01-the-countdown.mp4 (7.41 MB, ~815s) + +[inst1] === b27-02-bags-still-packed (keyframe-v13-1.jpg, 15.0s, 376f) === + queued 60631034-4f05-4c63-82cb-a5ca34084aaa + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch27/b27-02-bags-still-packed.mp4 (7.39 MB, ~815s) + +[inst1] === b27-03-they-said-90 (keyframe-v13-2.jpg, 12.6s, 316f) === + queued ee958db6-12eb-42a7-83c8-11781f4e65e6 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch27/b27-03-they-said-90.mp4 (4.00 MB, ~690s) + +[inst1] === b27-04-i-can-see-it (keyframe-v13-2.jpg, 14.4s, 360f) === + queued 7c77cc3d-e267-4278-ba1e-46469d3a90a2 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch27/b27-04-i-can-see-it.mp4 (4.16 MB, ~700s) + +[inst1] === b27-05-watching-clock (keyframe-v13-3.jpg, 12.4s, 310f) === + queued 696f18d0-2c18-481c-a0c5-f9d8fd49f2d6 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch27/b27-05-watching-clock.mp4 (6.08 MB, ~680s) + +[inst1] === b27-06-husband-pacing (keyframe-v13-3.jpg, 13.8s, 345f) === + queued c23da906-17d2-4740-9f94-37f6d73173e5 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch27/b27-06-husband-pacing.mp4 (5.98 MB, ~630s) + +[inst1] === b27-07-this-is-the-trade (keyframe-v13-4.jpg, 12.4s, 310f) === + queued 01f7350a-d408-45cb-ad0c-ff06e686f2a9 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch27/b27-07-this-is-the-trade.mp4 (5.08 MB, ~595s) + +[inst1] === b27-08-coffee-and-questions (keyframe-v13-4.jpg, 13.8s, 345f) === + queued a9957b2d-0508-4e2f-a11a-e0006cb5612f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch27/b27-08-coffee-and-questions.mp4 (5.25 MB, ~680s) + +[inst1] === b27-09-30-min-left (keyframe-v13-5.jpg, 15.4s, 386f) === + queued 39684186-ef31-4ba3-9ade-27d24d279942 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch27/b27-09-30-min-left.mp4 (4.34 MB, ~835s) + +[inst1] === b27-10-cta-countdown (keyframe-v13-5.jpg, 12.6s, 315f) === + queued f3bad9ee-ecbd-4f86-92d5-3593eb48bd38 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch27/b27-10-cta-countdown.mp4 (4.00 MB, ~710s) + +[inst1] BATCH COMPLETE +[2026-06-06 01:11:08] batch27 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH27 FINALIZED sáb 06 jun 2026 01:11:41 EDT +-rw-rw-r-- 1 na na 7769835 jun 5 22:53 public/videos/ugc/batch27/b27-01-the-countdown.mp4 +-rw-rw-r-- 1 na na 7748572 jun 5 23:10 public/videos/ugc/batch27/b27-02-bags-still-packed.mp4 +-rw-rw-r-- 1 na na 4197315 jun 5 23:25 public/videos/ugc/batch27/b27-03-they-said-90.mp4 +-rw-rw-r-- 1 na na 4358007 jun 5 23:40 public/videos/ugc/batch27/b27-04-i-can-see-it.mp4 +-rw-rw-r-- 1 na na 6375806 jun 5 23:54 public/videos/ugc/batch27/b27-05-watching-clock.mp4 +-rw-rw-r-- 1 na na 6273957 jun 6 00:09 public/videos/ugc/batch27/b27-06-husband-pacing.mp4 +-rw-rw-r-- 1 na na 5330920 jun 6 00:24 public/videos/ugc/batch27/b27-07-this-is-the-trade.mp4 +-rw-rw-r-- 1 na na 5501320 jun 6 00:39 public/videos/ugc/batch27/b27-08-coffee-and-questions.mp4 +-rw-rw-r-- 1 na na 4553750 jun 6 00:56 public/videos/ugc/batch27/b27-09-30-min-left.mp4 +-rw-rw-r-- 1 na na 4195869 jun 6 01:11 public/videos/ugc/batch27/b27-10-cta-countdown.mp4 +[2026-06-06 01:11:41] batch27 DONE + deployed to gw +[2026-06-06 01:11:44] === rendering batch43 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b43-01-play-by-play (keyframe-v13-1.jpg, 13.5s, 337f) === + queued 4201e390-a0f4-4aef-afd8-8d1fe06573d9 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch43/b43-01-play-by-play.mp4 (6.54 MB, ~695s) + +[inst1] === b43-02-first-ten (keyframe-v13-1.jpg, 12.6s, 314f) === + queued f8690cc6-5933-4eae-9731-801cc2ed8aea + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch43/b43-02-first-ten.mp4 (6.54 MB, ~625s) + +[inst1] === b43-03-the-tour (keyframe-v13-2.jpg, 14.7s, 366f) === + queued 79710f0a-2523-4cdf-9282-24ec5ff6740f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch43/b43-03-the-tour.mp4 (4.67 MB, ~815s) + +[inst1] === b43-04-the-offer (keyframe-v13-2.jpg, 14.2s, 356f) === + queued 84b16f54-c527-42e6-9a0b-c2e4452846d5 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch43/b43-04-the-offer.mp4 (4.17 MB, ~700s) + +[inst1] === b43-05-the-no (keyframe-v13-3.jpg, 13.7s, 342f) === + queued 08ff5340-524c-4847-939d-4daf013373d5 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch43/b43-05-the-no.mp4 (6.11 MB, ~695s) + +[inst1] === b43-06-the-wristband (keyframe-v13-3.jpg, 13.2s, 329f) === + queued f2579e01-ef78-4b3e-adf3-612dd2ac2399 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch43/b43-06-the-wristband.mp4 (6.15 MB, ~645s) + +[inst1] === b43-07-no-tricks (keyframe-v13-4.jpg, 15.4s, 386f) === + queued 26d719cf-ae0c-4053-99a0-fc34cb9837e7 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch43/b43-07-no-tricks.mp4 (5.84 MB, ~795s) + +[inst1] === b43-08-what-to-say (keyframe-v13-4.jpg, 13.8s, 344f) === + queued 7c567f4c-c3f8-4362-9ceb-4ff6b92f5558 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch43/b43-08-what-to-say.mp4 (5.17 MB, ~710s) + +[inst1] === b43-09-worth-it (keyframe-v13-5.jpg, 15.3s, 381f) === + queued 51a34992-df56-4827-8bcd-66fbeb5ddc86 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch43/b43-09-worth-it.mp4 (4.73 MB, ~830s) + +[inst1] === b43-10-cta-demystified (keyframe-v13-5.jpg, 11.2s, 279f) === + queued 827b3092-7492-4013-a75d-41b2254d489a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch43/b43-10-cta-demystified.mp4 (3.41 MB, ~590s) + +[inst1] BATCH COMPLETE +[2026-06-06 03:44:12] batch43 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH43 FINALIZED sáb 06 jun 2026 03:44:36 EDT +-rw-rw-r-- 1 na na 6857012 jun 6 01:27 public/videos/ugc/batch43/b43-01-play-by-play.mp4 +-rw-rw-r-- 1 na na 6854964 jun 6 01:41 public/videos/ugc/batch43/b43-02-first-ten.mp4 +-rw-rw-r-- 1 na na 4898937 jun 6 01:58 public/videos/ugc/batch43/b43-03-the-tour.mp4 +-rw-rw-r-- 1 na na 4371916 jun 6 02:13 public/videos/ugc/batch43/b43-04-the-offer.mp4 +-rw-rw-r-- 1 na na 6402586 jun 6 02:28 public/videos/ugc/batch43/b43-05-the-no.mp4 +-rw-rw-r-- 1 na na 6451586 jun 6 02:43 public/videos/ugc/batch43/b43-06-the-wristband.mp4 +-rw-rw-r-- 1 na na 6123350 jun 6 03:00 public/videos/ugc/batch43/b43-07-no-tricks.mp4 +-rw-rw-r-- 1 na na 5420074 jun 6 03:14 public/videos/ugc/batch43/b43-08-what-to-say.mp4 +-rw-rw-r-- 1 na na 4959706 jun 6 03:31 public/videos/ugc/batch43/b43-09-worth-it.mp4 +-rw-rw-r-- 1 na na 3577061 jun 6 03:44 public/videos/ugc/batch43/b43-10-cta-demystified.mp4 +[2026-06-06 03:44:36] batch43 DONE + deployed to gw +[2026-06-06 03:44:39] === rendering batch44 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b44-01-the-catches (keyframe-v13-1.jpg, 15.6s, 389f) === + queued 0fa7978a-0fc9-4fba-a97d-e260b80d0573 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-01-the-catches.mp4 (7.42 MB, ~825s) + +[inst1] === b44-02-catch-the-hour (keyframe-v13-1.jpg, 16.0s, 400f) === + queued c4ded1b0-aff5-451c-a051-c4d60b316f19 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-02-catch-the-hour.mp4 (7.55 MB, ~825s) + +[inst1] === b44-03-catch-dates (keyframe-v13-2.jpg, 15.6s, 389f) === + queued 11dce0e4-2418-4eca-afa3-f2d00e7c01be + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-03-catch-dates.mp4 (4.61 MB, ~825s) + +[inst1] === b44-04-catch-upsell (keyframe-v13-2.jpg, 15.9s, 398f) === + queued 23c5630a-50a1-45b5-853d-cdf374d52643 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-04-catch-upsell.mp4 (4.68 MB, ~840s) + +[inst1] === b44-05-still-worth (keyframe-v13-3.jpg, 15.3s, 381f) === + queued 541a425b-47e9-4922-9ea6-f8e86577a64d + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-05-still-worth.mp4 (6.73 MB, ~820s) + +[inst1] === b44-06-not-a-scam (keyframe-v13-3.jpg, 15.8s, 395f) === + queued 593981f7-033b-4ae9-a9d3-7c2b5ad92901 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-06-not-a-scam.mp4 (6.81 MB, ~830s) + +[inst1] === b44-07-who-its-for (keyframe-v13-4.jpg, 15.3s, 383f) === + queued 1576165a-3358-4a02-8e7d-01a28cf3a760 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-07-who-its-for.mp4 (5.93 MB, ~820s) + +[inst1] === b44-08-who-its-not (keyframe-v13-4.jpg, 14.8s, 370f) === + queued a5cc60e3-f383-4573-89f6-321ca92c696f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-08-who-its-not.mp4 (5.71 MB, ~805s) + +[inst1] === b44-09-my-verdict (keyframe-v13-5.jpg, 15.0s, 374f) === + queued 1fbded78-f567-462a-9fb4-329342fc1859 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch44/b44-09-my-verdict.mp4 (4.42 MB, ~835s) + +[inst1] === b44-10-cta-eyes-open (keyframe-v13-5.jpg, 13.4s, 336f) === + queued d71e5d27-e311-49e4-a856-43c1745fa4a0 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch44/b44-10-cta-eyes-open.mp4 (4.04 MB, ~725s) + +[inst1] BATCH COMPLETE +[2026-06-06 06:33:41] batch44 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH44 FINALIZED sáb 06 jun 2026 06:34:00 EDT +-rw-rw-r-- 1 na na 7780141 jun 6 04:02 public/videos/ugc/batch44/b44-01-the-catches.mp4 +-rw-rw-r-- 1 na na 7921861 jun 6 04:19 public/videos/ugc/batch44/b44-02-catch-the-hour.mp4 +-rw-rw-r-- 1 na na 4829268 jun 6 04:36 public/videos/ugc/batch44/b44-03-catch-dates.mp4 +-rw-rw-r-- 1 na na 4909334 jun 6 04:53 public/videos/ugc/batch44/b44-04-catch-upsell.mp4 +-rw-rw-r-- 1 na na 7059090 jun 6 05:10 public/videos/ugc/batch44/b44-05-still-worth.mp4 +-rw-rw-r-- 1 na na 7136941 jun 6 05:27 public/videos/ugc/batch44/b44-06-not-a-scam.mp4 +-rw-rw-r-- 1 na na 6219463 jun 6 05:44 public/videos/ugc/batch44/b44-07-who-its-for.mp4 +-rw-rw-r-- 1 na na 5988593 jun 6 06:01 public/videos/ugc/batch44/b44-08-who-its-not.mp4 +-rw-rw-r-- 1 na na 4634598 jun 6 06:19 public/videos/ugc/batch44/b44-09-my-verdict.mp4 +-rw-rw-r-- 1 na na 4238344 jun 6 06:33 public/videos/ugc/batch44/b44-10-cta-eyes-open.mp4 +[2026-06-06 06:34:00] batch44 DONE + deployed to gw +[2026-06-06 06:34:03] === rendering batch45 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b45-01-your-dms (keyframe-v13-1.jpg, 13.2s, 330f) === + queued 1ca7be4c-0bcc-4259-92b3-8014153e7dc8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch45/b45-01-your-dms.mp4 (6.60 MB, ~715s) + +[inst1] === b45-02-is-it-real (keyframe-v13-1.jpg, 19.8s, 495f) === + queued 8be3bcef-82f2-43ca-94be-9fe9db658e6e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + [900s] running... + [960s] running... + [1020s] running... + ✓ saved public/videos/ugc/batch45/b45-02-is-it-real.mp4 (9.26 MB, ~1065s) + +[inst1] === b45-03-hidden-fees (keyframe-v13-2.jpg, 15.2s, 379f) === + queued 3ad6959a-040e-466e-b58d-7e4915545a72 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch45/b45-03-hidden-fees.mp4 (4.64 MB, ~830s) + +[inst1] === b45-04-the-catch-q (keyframe-v13-2.jpg, 15.2s, 380f) === + queued f320e483-84bb-47c2-a842-ef3a2458b59e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch45/b45-04-the-catch-q.mp4 (4.50 MB, ~830s) + +[inst1] === b45-05-which-resorts (keyframe-v13-3.jpg, 16.6s, 415f) === + queued f183f22a-26e1-4c9c-8bed-0cecfc222d95 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch45/b45-05-which-resorts.mp4 (7.06 MB, ~830s) + +[inst1] === b45-06-kids-free (keyframe-v13-3.jpg, 14.8s, 370f) === + queued 017566e7-52e9-4a10-984d-eb136e47f06a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch45/b45-06-kids-free.mp4 (6.94 MB, ~810s) + +[inst1] === b45-07-how-book (keyframe-v13-4.jpg, 13.2s, 329f) === + queued a5d85275-c82a-4ada-b932-d3b7416af225 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch45/b45-07-how-book.mp4 (5.21 MB, ~690s) + +[inst1] === b45-08-flights-q (keyframe-v13-4.jpg, 15.5s, 388f) === + queued 7d229bc1-1872-417f-b301-f085aec71727 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch45/b45-08-flights-q.mp4 (6.07 MB, ~810s) + +[inst1] === b45-09-timeshare-q (keyframe-v13-5.jpg, 16.6s, 414f) === + queued 34dc0a32-cfb8-4efa-acec-933115ec92c5 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch45/b45-09-timeshare-q.mp4 (4.51 MB, ~815s) + +[inst1] === b45-10-cta-dm-me (keyframe-v13-5.jpg, 13.5s, 338f) === + queued 4b68ce75-1a46-4181-b812-ae649864513a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch45/b45-10-cta-dm-me.mp4 (4.03 MB, ~695s) + +[inst1] BATCH COMPLETE +[2026-06-06 09:22:59] batch45 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH45 FINALIZED sáb 06 jun 2026 09:23:24 EDT +-rw-rw-r-- 1 na na 6922472 jun 6 06:49 public/videos/ugc/batch45/b45-01-your-dms.mp4 +-rw-rw-r-- 1 na na 9712577 jun 6 07:11 public/videos/ugc/batch45/b45-02-is-it-real.mp4 +-rw-rw-r-- 1 na na 4863571 jun 6 07:28 public/videos/ugc/batch45/b45-03-hidden-fees.mp4 +-rw-rw-r-- 1 na na 4719244 jun 6 07:45 public/videos/ugc/batch45/b45-04-the-catch-q.mp4 +-rw-rw-r-- 1 na na 7407574 jun 6 08:02 public/videos/ugc/batch45/b45-05-which-resorts.mp4 +-rw-rw-r-- 1 na na 7274894 jun 6 08:19 public/videos/ugc/batch45/b45-06-kids-free.mp4 +-rw-rw-r-- 1 na na 5459238 jun 6 08:34 public/videos/ugc/batch45/b45-07-how-book.mp4 +-rw-rw-r-- 1 na na 6361914 jun 6 08:51 public/videos/ugc/batch45/b45-08-flights-q.mp4 +-rw-rw-r-- 1 na na 4727099 jun 6 09:08 public/videos/ugc/batch45/b45-09-timeshare-q.mp4 +-rw-rw-r-- 1 na na 4228359 jun 6 09:22 public/videos/ugc/batch45/b45-10-cta-dm-me.mp4 +[2026-06-06 09:23:24] batch45 DONE + deployed to gw +[2026-06-06 09:23:24] queue drained — idle, recheck in 120s +[2026-06-06 09:25:24] queue drained — idle, recheck in 120s +[2026-06-06 09:27:24] queue drained — idle, recheck in 120s +[2026-06-06 09:29:24] queue drained — idle, recheck in 120s +[2026-06-06 09:31:24] queue drained — idle, recheck in 120s +[2026-06-06 09:33:24] queue drained — idle, recheck in 120s +[2026-06-06 09:35:24] queue drained — idle, recheck in 120s +[2026-06-06 09:37:24] queue drained — idle, recheck in 120s +[2026-06-06 09:39:24] queue drained — idle, recheck in 120s +[2026-06-06 09:41:24] queue drained — idle, recheck in 120s +[2026-06-06 09:43:24] queue drained — idle, recheck in 120s +[2026-06-06 09:45:24] queue drained — idle, recheck in 120s +[2026-06-06 09:47:24] queue drained — idle, recheck in 120s +[2026-06-06 09:49:24] queue drained — idle, recheck in 120s +[2026-06-06 09:51:24] queue drained — idle, recheck in 120s +[2026-06-06 09:53:24] queue drained — idle, recheck in 120s +[2026-06-06 09:55:24] queue drained — idle, recheck in 120s +[2026-06-06 09:57:24] queue drained — idle, recheck in 120s +[2026-06-06 09:59:24] queue drained — idle, recheck in 120s +[2026-06-06 10:01:24] queue drained — idle, recheck in 120s +[2026-06-06 10:03:24] queue drained — idle, recheck in 120s +[2026-06-06 10:05:24] queue drained — idle, recheck in 120s +[2026-06-06 10:07:24] queue drained — idle, recheck in 120s +[2026-06-06 10:09:24] queue drained — idle, recheck in 120s +[2026-06-06 10:11:24] queue drained — idle, recheck in 120s +[2026-06-06 10:13:25] queue drained — idle, recheck in 120s +[2026-06-06 10:15:25] queue drained — idle, recheck in 120s +[2026-06-06 10:17:25] queue drained — idle, recheck in 120s +[2026-06-06 10:19:25] queue drained — idle, recheck in 120s +[2026-06-06 10:21:25] queue drained — idle, recheck in 120s +[2026-06-06 10:23:25] queue drained — idle, recheck in 120s +[2026-06-06 10:25:25] queue drained — idle, recheck in 120s +[2026-06-06 10:27:25] queue drained — idle, recheck in 120s +[2026-06-06 10:29:25] queue drained — idle, recheck in 120s +[2026-06-06 10:31:25] queue drained — idle, recheck in 120s +[2026-06-06 10:33:25] queue drained — idle, recheck in 120s +[2026-06-06 10:35:25] queue drained — idle, recheck in 120s +[2026-06-06 10:37:25] queue drained — idle, recheck in 120s +[2026-06-06 10:39:25] queue drained — idle, recheck in 120s +[2026-06-06 10:41:25] queue drained — idle, recheck in 120s +[2026-06-06 10:43:25] queue drained — idle, recheck in 120s +[2026-06-06 10:45:25] queue drained — idle, recheck in 120s +[2026-06-06 10:47:25] queue drained — idle, recheck in 120s +[2026-06-06 10:49:25] queue drained — idle, recheck in 120s +[2026-06-06 10:51:25] queue drained — idle, recheck in 120s +[2026-06-06 10:53:25] queue drained — idle, recheck in 120s +[2026-06-06 10:55:25] queue drained — idle, recheck in 120s +[2026-06-06 10:57:25] queue drained — idle, recheck in 120s +[2026-06-06 10:59:25] queue drained — idle, recheck in 120s +[2026-06-06 11:01:25] queue drained — idle, recheck in 120s +[2026-06-06 11:03:26] queue drained — idle, recheck in 120s +[2026-06-06 11:05:26] queue drained — idle, recheck in 120s +[2026-06-06 11:07:26] queue drained — idle, recheck in 120s +[2026-06-06 11:09:26] queue drained — idle, recheck in 120s +[2026-06-06 11:11:26] queue drained — idle, recheck in 120s +[2026-06-06 11:13:26] queue drained — idle, recheck in 120s +[2026-06-06 11:15:26] queue drained — idle, recheck in 120s +[2026-06-06 11:17:26] queue drained — idle, recheck in 120s +[2026-06-06 11:19:26] queue drained — idle, recheck in 120s +[2026-06-06 11:21:26] queue drained — idle, recheck in 120s +[2026-06-06 11:23:26] queue drained — idle, recheck in 120s +[2026-06-06 11:25:26] queue drained — idle, recheck in 120s +[2026-06-06 11:27:26] queue drained — idle, recheck in 120s +[2026-06-06 11:29:26] queue drained — idle, recheck in 120s +[2026-06-06 11:31:26] queue drained — idle, recheck in 120s +[2026-06-06 11:33:26] queue drained — idle, recheck in 120s +[2026-06-06 11:35:26] queue drained — idle, recheck in 120s +[2026-06-06 11:37:26] queue drained — idle, recheck in 120s +[2026-06-06 11:39:26] queue drained — idle, recheck in 120s +[2026-06-06 11:41:26] queue drained — idle, recheck in 120s +[2026-06-06 11:43:26] queue drained — idle, recheck in 120s +[2026-06-06 11:45:26] queue drained — idle, recheck in 120s +[2026-06-06 11:47:26] queue drained — idle, recheck in 120s +[2026-06-06 11:49:27] queue drained — idle, recheck in 120s +[2026-06-06 11:51:27] queue drained — idle, recheck in 120s +[2026-06-06 11:53:27] queue drained — idle, recheck in 120s +[2026-06-06 11:55:27] queue drained — idle, recheck in 120s +[2026-06-06 11:57:27] queue drained — idle, recheck in 120s +[2026-06-06 11:59:27] queue drained — idle, recheck in 120s +[2026-06-06 12:01:27] queue drained — idle, recheck in 120s +[2026-06-06 12:03:27] queue drained — idle, recheck in 120s +[2026-06-06 12:05:27] queue drained — idle, recheck in 120s +[2026-06-06 12:07:27] queue drained — idle, recheck in 120s +[2026-06-06 12:09:27] queue drained — idle, recheck in 120s +[2026-06-06 12:11:27] queue drained — idle, recheck in 120s +[2026-06-06 12:13:27] queue drained — idle, recheck in 120s +[2026-06-06 12:15:27] queue drained — idle, recheck in 120s +[2026-06-06 12:17:27] queue drained — idle, recheck in 120s +[2026-06-06 12:19:27] queue drained — idle, recheck in 120s +[2026-06-06 12:21:27] queue drained — idle, recheck in 120s +[2026-06-06 12:23:27] queue drained — idle, recheck in 120s +[2026-06-06 12:25:27] queue drained — idle, recheck in 120s +[2026-06-06 12:27:27] queue drained — idle, recheck in 120s +[2026-06-06 12:29:27] queue drained — idle, recheck in 120s +[2026-06-06 12:31:27] queue drained — idle, recheck in 120s +[2026-06-06 12:33:28] queue drained — idle, recheck in 120s +[2026-06-06 12:35:28] queue drained — idle, recheck in 120s +[2026-06-06 12:37:28] queue drained — idle, recheck in 120s +[2026-06-06 12:39:28] queue drained — idle, recheck in 120s +[2026-06-06 12:41:28] queue drained — idle, recheck in 120s +[2026-06-06 12:43:28] queue drained — idle, recheck in 120s +[2026-06-06 12:45:28] queue drained — idle, recheck in 120s +[2026-06-06 12:47:28] queue drained — idle, recheck in 120s +[2026-06-06 12:49:28] queue drained — idle, recheck in 120s +[2026-06-06 12:51:28] queue drained — idle, recheck in 120s +[2026-06-06 12:53:28] queue drained — idle, recheck in 120s +[2026-06-06 12:55:28] queue drained — idle, recheck in 120s +[2026-06-06 12:57:28] queue drained — idle, recheck in 120s +[2026-06-06 12:59:28] queue drained — idle, recheck in 120s +[2026-06-06 13:01:28] queue drained — idle, recheck in 120s +[2026-06-06 13:03:28] queue drained — idle, recheck in 120s +[2026-06-06 13:05:28] queue drained — idle, recheck in 120s +[2026-06-06 13:07:28] queue drained — idle, recheck in 120s +[2026-06-06 13:09:28] queue drained — idle, recheck in 120s +[2026-06-06 13:11:28] queue drained — idle, recheck in 120s +[2026-06-06 13:13:28] queue drained — idle, recheck in 120s +[2026-06-06 13:15:28] queue drained — idle, recheck in 120s +[2026-06-06 13:17:28] queue drained — idle, recheck in 120s +[2026-06-06 13:19:28] queue drained — idle, recheck in 120s +[2026-06-06 13:21:28] queue drained — idle, recheck in 120s +[2026-06-06 13:23:29] queue drained — idle, recheck in 120s +[2026-06-06 13:25:29] queue drained — idle, recheck in 120s +[2026-06-06 13:27:29] queue drained — idle, recheck in 120s +[2026-06-06 13:29:29] queue drained — idle, recheck in 120s +[2026-06-06 13:31:29] queue drained — idle, recheck in 120s +[2026-06-06 13:33:29] queue drained — idle, recheck in 120s +[2026-06-06 13:35:29] queue drained — idle, recheck in 120s +[2026-06-06 13:37:29] queue drained — idle, recheck in 120s +[2026-06-06 13:39:29] queue drained — idle, recheck in 120s +[2026-06-06 13:41:29] queue drained — idle, recheck in 120s +[2026-06-06 13:43:29] queue drained — idle, recheck in 120s +[2026-06-06 13:45:29] queue drained — idle, recheck in 120s +[2026-06-06 13:47:29] queue drained — idle, recheck in 120s +[2026-06-06 13:49:29] queue drained — idle, recheck in 120s +[2026-06-06 13:51:29] queue drained — idle, recheck in 120s +[2026-06-06 13:53:29] queue drained — idle, recheck in 120s +[2026-06-06 13:55:29] queue drained — idle, recheck in 120s +[2026-06-06 13:57:29] queue drained — idle, recheck in 120s +[2026-06-06 13:59:29] queue drained — idle, recheck in 120s +[2026-06-06 14:01:29] queue drained — idle, recheck in 120s +[2026-06-06 14:03:29] queue drained — idle, recheck in 120s +[2026-06-06 14:05:29] queue drained — idle, recheck in 120s +[2026-06-06 14:07:29] queue drained — idle, recheck in 120s +[2026-06-06 14:09:29] queue drained — idle, recheck in 120s +[2026-06-06 14:11:29] queue drained — idle, recheck in 120s +[2026-06-06 14:13:29] queue drained — idle, recheck in 120s +[2026-06-06 14:15:29] queue drained — idle, recheck in 120s +[2026-06-06 14:17:29] queue drained — idle, recheck in 120s +[2026-06-06 14:19:30] queue drained — idle, recheck in 120s +[2026-06-06 14:21:30] queue drained — idle, recheck in 120s +[2026-06-06 14:23:30] queue drained — idle, recheck in 120s +[2026-06-06 14:25:30] queue drained — idle, recheck in 120s +[2026-06-06 14:27:30] queue drained — idle, recheck in 120s +[2026-06-06 14:29:30] queue drained — idle, recheck in 120s +[2026-06-06 14:31:30] queue drained — idle, recheck in 120s +[2026-06-06 14:33:30] queue drained — idle, recheck in 120s +[2026-06-06 14:35:30] queue drained — idle, recheck in 120s +[2026-06-06 14:37:30] queue drained — idle, recheck in 120s +[2026-06-06 14:39:30] queue drained — idle, recheck in 120s +[2026-06-06 14:41:30] queue drained — idle, recheck in 120s +[2026-06-06 14:43:30] queue drained — idle, recheck in 120s +[2026-06-06 14:45:30] queue drained — idle, recheck in 120s +[2026-06-06 14:47:30] queue drained — idle, recheck in 120s +[2026-06-06 14:49:30] queue drained — idle, recheck in 120s +[2026-06-06 14:51:30] queue drained — idle, recheck in 120s +[2026-06-06 14:53:30] queue drained — idle, recheck in 120s +[2026-06-06 14:55:30] queue drained — idle, recheck in 120s +[2026-06-06 14:57:30] queue drained — idle, recheck in 120s +[2026-06-06 14:59:30] queue drained — idle, recheck in 120s +[2026-06-06 15:01:30] queue drained — idle, recheck in 120s +[2026-06-06 15:03:30] queue drained — idle, recheck in 120s +[2026-06-06 15:05:30] queue drained — idle, recheck in 120s +[2026-06-06 15:07:30] queue drained — idle, recheck in 120s +[2026-06-06 15:09:30] queue drained — idle, recheck in 120s +[2026-06-06 15:11:30] queue drained — idle, recheck in 120s +[2026-06-06 15:13:30] queue drained — idle, recheck in 120s +[2026-06-06 15:15:31] queue drained — idle, recheck in 120s +[2026-06-06 15:17:31] queue drained — idle, recheck in 120s +[2026-06-06 15:19:31] queue drained — idle, recheck in 120s +[2026-06-06 15:21:31] queue drained — idle, recheck in 120s +[2026-06-06 15:23:31] queue drained — idle, recheck in 120s +[2026-06-06 15:25:31] queue drained — idle, recheck in 120s +[2026-06-06 15:27:31] queue drained — idle, recheck in 120s +[2026-06-06 15:29:31] queue drained — idle, recheck in 120s +[2026-06-06 15:31:31] queue drained — idle, recheck in 120s +[2026-06-06 15:33:31] queue drained — idle, recheck in 120s +[2026-06-06 15:35:31] queue drained — idle, recheck in 120s +[2026-06-06 15:37:31] queue drained — idle, recheck in 120s +[2026-06-06 15:39:31] queue drained — idle, recheck in 120s +[2026-06-06 15:41:31] queue drained — idle, recheck in 120s +[2026-06-06 15:43:31] queue drained — idle, recheck in 120s +[2026-06-06 15:45:31] queue drained — idle, recheck in 120s +[2026-06-06 15:47:31] queue drained — idle, recheck in 120s +[2026-06-06 15:49:31] queue drained — idle, recheck in 120s +[2026-06-06 15:51:31] queue drained — idle, recheck in 120s +[2026-06-06 15:53:31] queue drained — idle, recheck in 120s +[2026-06-06 15:55:31] queue drained — idle, recheck in 120s +[2026-06-06 15:57:31] queue drained — idle, recheck in 120s +[2026-06-06 15:59:31] queue drained — idle, recheck in 120s +[2026-06-06 16:01:31] queue drained — idle, recheck in 120s +[2026-06-06 16:03:31] queue drained — idle, recheck in 120s +[2026-06-06 16:05:31] queue drained — idle, recheck in 120s +[2026-06-06 16:07:31] queue drained — idle, recheck in 120s +[2026-06-06 16:09:31] queue drained — idle, recheck in 120s +[2026-06-06 16:11:31] queue drained — idle, recheck in 120s +[2026-06-06 16:13:32] queue drained — idle, recheck in 120s +[2026-06-06 16:15:32] queue drained — idle, recheck in 120s +[2026-06-06 16:17:32] queue drained — idle, recheck in 120s +[2026-06-06 16:19:32] queue drained — idle, recheck in 120s +[2026-06-06 16:21:32] queue drained — idle, recheck in 120s +[2026-06-06 16:23:32] queue drained — idle, recheck in 120s +[2026-06-06 16:25:32] queue drained — idle, recheck in 120s +[2026-06-06 16:27:32] queue drained — idle, recheck in 120s +[2026-06-06 16:29:32] queue drained — idle, recheck in 120s +[2026-06-06 16:31:32] queue drained — idle, recheck in 120s +[2026-06-06 16:33:32] queue drained — idle, recheck in 120s +[2026-06-06 16:35:32] queue drained — idle, recheck in 120s +[2026-06-06 16:37:32] queue drained — idle, recheck in 120s +[2026-06-06 16:39:32] queue drained — idle, recheck in 120s +[2026-06-06 16:41:32] queue drained — idle, recheck in 120s +[2026-06-06 16:43:32] queue drained — idle, recheck in 120s +[2026-06-06 16:45:32] queue drained — idle, recheck in 120s +[2026-06-06 16:47:32] queue drained — idle, recheck in 120s +[2026-06-06 16:49:32] queue drained — idle, recheck in 120s +[2026-06-06 16:51:32] queue drained — idle, recheck in 120s +[2026-06-06 16:53:32] queue drained — idle, recheck in 120s +[2026-06-06 16:55:32] queue drained — idle, recheck in 120s +[2026-06-06 16:57:32] queue drained — idle, recheck in 120s +[2026-06-06 16:59:32] queue drained — idle, recheck in 120s +[2026-06-06 17:01:32] queue drained — idle, recheck in 120s +[2026-06-06 17:03:32] queue drained — idle, recheck in 120s +[2026-06-06 17:05:33] queue drained — idle, recheck in 120s +[2026-06-06 17:07:33] queue drained — idle, recheck in 120s +[2026-06-06 17:09:33] queue drained — idle, recheck in 120s +[2026-06-06 17:11:33] queue drained — idle, recheck in 120s +[2026-06-06 17:13:33] queue drained — idle, recheck in 120s +[2026-06-06 17:15:33] queue drained — idle, recheck in 120s +[2026-06-06 17:17:33] queue drained — idle, recheck in 120s +[2026-06-06 17:19:33] queue drained — idle, recheck in 120s +[2026-06-06 17:21:33] queue drained — idle, recheck in 120s +[2026-06-06 17:23:33] queue drained — idle, recheck in 120s +[2026-06-06 17:25:33] queue drained — idle, recheck in 120s +[2026-06-06 17:27:33] queue drained — idle, recheck in 120s +[2026-06-06 17:29:33] queue drained — idle, recheck in 120s +[2026-06-06 17:31:33] queue drained — idle, recheck in 120s +[2026-06-06 17:33:33] queue drained — idle, recheck in 120s +[2026-06-06 17:35:33] queue drained — idle, recheck in 120s +[2026-06-06 17:37:33] queue drained — idle, recheck in 120s +[2026-06-06 17:39:33] queue drained — idle, recheck in 120s +[2026-06-06 17:41:36] === rendering batch46 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b46-01-stop-overthinking (keyframe-v13-1.jpg, 12.5s, 313f) === + queued 34b67292-5224-423b-9cd1-6c25704bb8f3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch46/b46-01-stop-overthinking.mp4 (6.49 MB, ~705s) + +[inst1] === b46-02-you-keep-scrolling (keyframe-v13-1.jpg, 12.6s, 316f) === + queued c95c04e7-259d-4e72-80fa-eb67db45f964 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch46/b46-02-you-keep-scrolling.mp4 (6.69 MB, ~705s) + +[inst1] === b46-03-its-249 (keyframe-v13-2.jpg, 16.6s, 414f) === + queued ded11ef2-183f-43a9-bb37-274b48b32050 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch46/b46-03-its-249.mp4 (4.67 MB, ~820s) + +[inst1] === b46-04-worst-case (keyframe-v13-2.jpg, 14.7s, 366f) === + queued 4c1de7a5-ffac-4247-bc9b-242e92bc7efd + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch46/b46-04-worst-case.mp4 (4.71 MB, ~835s) + +[inst1] === b46-05-we-almost-didnt (keyframe-v13-3.jpg, 14.2s, 356f) === + queued a066f274-30fb-46d3-8845-c0b22e7918ed + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch46/b46-05-we-almost-didnt.mp4 (6.25 MB, ~720s) + +[inst1] === b46-06-no-perfect-time (keyframe-v13-3.jpg, 14.5s, 362f) === + queued d1bcf655-332a-487c-aa68-fd465657c465 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch46/b46-06-no-perfect-time.mp4 (6.17 MB, ~720s) + +[inst1] === b46-07-decision-fatigue (keyframe-v13-4.jpg, 16.9s, 422f) === + queued 7491a71d-1099-4c8c-95dc-29a5e8abf5d9 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + [900s] running... + ✓ saved public/videos/ugc/batch46/b46-07-decision-fatigue.mp4 (6.49 MB, ~930s) + +[inst1] === b46-08-just-pick-dates (keyframe-v13-4.jpg, 15.3s, 383f) === + queued 592858c1-d958-447b-8544-c1bbd8886291 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch46/b46-08-just-pick-dates.mp4 (5.91 MB, ~825s) + +[inst1] === b46-09-future-you (keyframe-v13-5.jpg, 12.8s, 321f) === + queued 2b3f4fa9-2207-42e2-8b49-7f49b7234f11 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch46/b46-09-future-you.mp4 (3.97 MB, ~700s) + +[inst1] === b46-10-cta-do-it-now (keyframe-v13-5.jpg, 13.1s, 328f) === + queued 6ba23431-3755-4489-b7f1-ac59bd540aad + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch46/b46-10-cta-do-it-now.mp4 (4.09 MB, ~715s) + +[inst1] BATCH COMPLETE +[2026-06-06 20:20:27] batch46 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH46 FINALIZED sáb 06 jun 2026 20:20:45 EDT +-rw-rw-r-- 1 na na 6803513 jun 6 17:56 public/videos/ugc/batch46/b46-01-stop-overthinking.mp4 +-rw-rw-r-- 1 na na 7016276 jun 6 18:11 public/videos/ugc/batch46/b46-02-you-keep-scrolling.mp4 +-rw-rw-r-- 1 na na 4900710 jun 6 18:28 public/videos/ugc/batch46/b46-03-its-249.mp4 +-rw-rw-r-- 1 na na 4939345 jun 6 18:45 public/videos/ugc/batch46/b46-04-worst-case.mp4 +-rw-rw-r-- 1 na na 6552460 jun 6 19:00 public/videos/ugc/batch46/b46-05-we-almost-didnt.mp4 +-rw-rw-r-- 1 na na 6469772 jun 6 19:14 public/videos/ugc/batch46/b46-06-no-perfect-time.mp4 +-rw-rw-r-- 1 na na 6810344 jun 6 19:34 public/videos/ugc/batch46/b46-07-decision-fatigue.mp4 +-rw-rw-r-- 1 na na 6201769 jun 6 19:51 public/videos/ugc/batch46/b46-08-just-pick-dates.mp4 +-rw-rw-r-- 1 na na 4157698 jun 6 20:05 public/videos/ugc/batch46/b46-09-future-you.mp4 +-rw-rw-r-- 1 na na 4284969 jun 6 20:20 public/videos/ugc/batch46/b46-10-cta-do-it-now.mp4 +[2026-06-06 20:20:45] batch46 DONE + deployed to gw +[2026-06-06 20:20:48] === rendering batch47 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b47-01-lunch-break (keyframe-v13-1.jpg, 13.8s, 344f) === + queued b79a04ee-5c29-46f3-a9f8-e0cdc5198821 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch47/b47-01-lunch-break.mp4 (6.50 MB, ~720s) + +[inst1] === b47-02-five-minutes (keyframe-v13-1.jpg, 12.0s, 301f) === + queued b04fd3c8-4142-40aa-8a6e-6e3330facc3e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch47/b47-02-five-minutes.mp4 (5.70 MB, ~600s) + +[inst1] === b47-03-no-call (keyframe-v13-2.jpg, 14.5s, 362f) === + queued 2b0d2b01-61a2-461a-99b9-9f9d1ae0eeec + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch47/b47-03-no-call.mp4 (4.05 MB, ~720s) + +[inst1] === b47-04-confirmation (keyframe-v13-2.jpg, 12.4s, 310f) === + queued bac7a759-d3a1-4167-ba7a-2ce30871a649 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch47/b47-04-confirmation.mp4 (4.09 MB, ~720s) + +[inst1] === b47-05-dates-later (keyframe-v13-3.jpg, 13.5s, 337f) === + queued fd9983c4-9f3e-46e7-9440-382792e037ba + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch47/b47-05-dates-later.mp4 (6.14 MB, ~720s) + +[inst1] === b47-06-on-my-phone (keyframe-v13-3.jpg, 10.7s, 268f) === + queued 1fbec8fb-f7ce-4ba4-ab1e-b25097336244 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch47/b47-06-on-my-phone.mp4 (5.29 MB, ~605s) + +[inst1] === b47-07-compared-hotels (keyframe-v13-4.jpg, 13.5s, 338f) === + queued a040c9aa-7ed4-4c25-ad22-476aaebe7a4e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch47/b47-07-compared-hotels.mp4 (5.07 MB, ~720s) + +[inst1] === b47-08-impulse-good (keyframe-v13-4.jpg, 13.9s, 347f) === + queued 247eb6b6-40f7-4416-9f51-915983919f9c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch47/b47-08-impulse-good.mp4 (5.13 MB, ~725s) + +[inst1] === b47-09-tell-friend (keyframe-v13-5.jpg, 12.1s, 304f) === + queued 8a420e7a-7e47-4485-b6b9-48d007c62bbd + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + ✓ saved public/videos/ugc/batch47/b47-09-tell-friend.mp4 (3.56 MB, ~605s) + +[inst1] === b47-10-cta-five-min (keyframe-v13-5.jpg, 13.4s, 336f) === + queued 406b71cf-52e9-4046-8f48-d808879c551f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch47/b47-10-cta-five-min.mp4 (4.05 MB, ~725s) + +[inst1] BATCH COMPLETE +[2026-06-06 22:40:38] batch47 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH47 FINALIZED sáb 06 jun 2026 22:40:54 EDT +-rw-rw-r-- 1 na na 6816538 jun 6 20:35 public/videos/ugc/batch47/b47-01-lunch-break.mp4 +-rw-rw-r-- 1 na na 5972625 jun 6 20:48 public/videos/ugc/batch47/b47-02-five-minutes.mp4 +-rw-rw-r-- 1 na na 4242707 jun 6 21:02 public/videos/ugc/batch47/b47-03-no-call.mp4 +-rw-rw-r-- 1 na na 4291971 jun 6 21:17 public/videos/ugc/batch47/b47-04-confirmation.mp4 +-rw-rw-r-- 1 na na 6436165 jun 6 21:32 public/videos/ugc/batch47/b47-05-dates-later.mp4 +-rw-rw-r-- 1 na na 5545876 jun 6 21:44 public/videos/ugc/batch47/b47-06-on-my-phone.mp4 +-rw-rw-r-- 1 na na 5315469 jun 6 21:59 public/videos/ugc/batch47/b47-07-compared-hotels.mp4 +-rw-rw-r-- 1 na na 5378481 jun 6 22:13 public/videos/ugc/batch47/b47-08-impulse-good.mp4 +-rw-rw-r-- 1 na na 3737563 jun 6 22:25 public/videos/ugc/batch47/b47-09-tell-friend.mp4 +-rw-rw-r-- 1 na na 4243888 jun 6 22:40 public/videos/ugc/batch47/b47-10-cta-five-min.mp4 +[2026-06-06 22:40:54] batch47 DONE + deployed to gw +[2026-06-06 22:40:57] === rendering batch48 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b48-01-past-self (keyframe-v13-1.jpg, 13.5s, 337f) === + queued 4a40165f-c595-46ec-8a05-b588352b3669 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch48/b48-01-past-self.mp4 (6.47 MB, ~720s) + +[inst1] === b48-02-waited-too-long (keyframe-v13-1.jpg, 15.4s, 386f) === + queued 8db55b02-9905-4043-b0f1-c8a0be808b6a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch48/b48-02-waited-too-long.mp4 (7.63 MB, ~840s) + +[inst1] === b48-03-thought-expensive (keyframe-v13-2.jpg, 14.9s, 373f) === + queued 14a13c64-7ed2-4ca5-9ea7-1c069dd56698 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch48/b48-03-thought-expensive.mp4 (4.54 MB, ~840s) + +[inst1] === b48-04-kids-grow (keyframe-v13-2.jpg, 11.4s, 285f) === + queued e364fcb5-90c3-4eb1-ae9a-6e404fed4a8a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch48/b48-04-kids-grow.mp4 (3.58 MB, ~600s) + +[inst1] === b48-05-no-regrets (keyframe-v13-3.jpg, 12.4s, 310f) === + queued e5eec4fd-8417-4e32-83bf-947f2881dc6a + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch48/b48-05-no-regrets.mp4 (5.84 MB, ~720s) + +[inst1] === b48-06-money-comes-back (keyframe-v13-3.jpg, 11.3s, 283f) === + queued 3821a7a8-e94e-48c7-b3df-112c1053c613 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch48/b48-06-money-comes-back.mp4 (5.30 MB, ~600s) + +[inst1] === b48-07-first-of-many (keyframe-v13-4.jpg, 14.9s, 373f) === + queued a8cc668d-4dea-457b-8804-e2f88b68cab3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch48/b48-07-first-of-many.mp4 (5.82 MB, ~830s) + +[inst1] === b48-08-photos (keyframe-v13-4.jpg, 16.5s, 411f) === + queued 52f2911f-8633-4d53-9856-87de5dfc0b2c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch48/b48-08-photos.mp4 (5.75 MB, ~835s) + +[inst1] === b48-09-permission (keyframe-v13-5.jpg, 14.7s, 368f) === + queued e31c6db2-a7b7-42a2-8f8a-61f3383eb9d4 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch48/b48-09-permission.mp4 (4.48 MB, ~840s) + +[inst1] === b48-10-cta-dont-wait (keyframe-v13-5.jpg, 12.7s, 319f) === + queued 75e0f9a0-6f01-4025-9770-e6d7b42e7aed + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch48/b48-10-cta-dont-wait.mp4 (3.94 MB, ~720s) + +[inst1] BATCH COMPLETE +[2026-06-07 01:14:38] batch48 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH48 FINALIZED dom 07 jun 2026 01:15:53 EDT +-rw-rw-r-- 1 na na 6787144 jun 6 22:55 public/videos/ugc/batch48/b48-01-past-self.mp4 +-rw-rw-r-- 1 na na 8005040 jun 6 23:12 public/videos/ugc/batch48/b48-02-waited-too-long.mp4 +-rw-rw-r-- 1 na na 4765456 jun 6 23:29 public/videos/ugc/batch48/b48-03-thought-expensive.mp4 +-rw-rw-r-- 1 na na 3759110 jun 6 23:42 public/videos/ugc/batch48/b48-04-kids-grow.mp4 +-rw-rw-r-- 1 na na 6122589 jun 6 23:56 public/videos/ugc/batch48/b48-05-no-regrets.mp4 +-rw-rw-r-- 1 na na 5555752 jun 7 00:08 public/videos/ugc/batch48/b48-06-money-comes-back.mp4 +-rw-rw-r-- 1 na na 6101748 jun 7 00:25 public/videos/ugc/batch48/b48-07-first-of-many.mp4 +-rw-rw-r-- 1 na na 6031518 jun 7 00:42 public/videos/ugc/batch48/b48-08-photos.mp4 +-rw-rw-r-- 1 na na 4694935 jun 7 00:59 public/videos/ugc/batch48/b48-09-permission.mp4 +-rw-rw-r-- 1 na na 4129139 jun 7 01:14 public/videos/ugc/batch48/b48-10-cta-dont-wait.mp4 +[2026-06-07 01:15:53] batch48 DONE + deployed to gw +[2026-06-07 01:15:56] === rendering batch49 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b49-01-girls-trip (keyframe-v13-1.jpg, 13.5s, 338f) === + queued f6b8327a-caca-4bc8-8e3b-7ed2f8a1ce22 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-01-girls-trip.mp4 (6.49 MB, ~715s) + +[inst1] === b49-02-why-it-never (keyframe-v13-1.jpg, 13.1s, 328f) === + queued df450ae4-c773-49e8-90a6-20f881dd973b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-02-why-it-never.mp4 (6.53 MB, ~705s) + +[inst1] === b49-03-split-easy (keyframe-v13-2.jpg, 13.9s, 349f) === + queued 4e753c6d-5766-4b20-9ba0-9264b2a428f8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-03-split-easy.mp4 (4.17 MB, ~710s) + +[inst1] === b49-04-no-planner (keyframe-v13-2.jpg, 14.4s, 359f) === + queued 541605e3-242c-459e-ba62-f32827c05578 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-04-no-planner.mp4 (4.07 MB, ~710s) + +[inst1] === b49-05-the-hour-girls (keyframe-v13-3.jpg, 13.9s, 349f) === + queued 37647829-ef44-4880-a162-56e7f8528869 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-05-the-hour-girls.mp4 (6.05 MB, ~715s) + +[inst1] === b49-06-all-inclusive (keyframe-v13-3.jpg, 13.3s, 334f) === + queued e4968a1c-2975-48f7-b621-3996a8345385 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-06-all-inclusive.mp4 (6.14 MB, ~705s) + +[inst1] === b49-07-memories (keyframe-v13-4.jpg, 12.8s, 320f) === + queued 5acf0527-78d9-46ee-88a0-f37e1ce80a19 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-07-memories.mp4 (5.11 MB, ~715s) + +[inst1] === b49-08-mom-friends (keyframe-v13-4.jpg, 12.1s, 302f) === + queued bfb4832c-b37c-435a-8f00-11d1442e9ff3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch49/b49-08-mom-friends.mp4 (4.57 MB, ~600s) + +[inst1] === b49-09-already-next (keyframe-v13-5.jpg, 13.7s, 342f) === + queued 13078b98-404a-4551-a794-beba1feb6120 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-09-already-next.mp4 (4.03 MB, ~715s) + +[inst1] === b49-10-cta-text-group (keyframe-v13-5.jpg, 14.5s, 362f) === + queued 29b69a23-c2c8-4a0c-ad26-394a865df7a0 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch49/b49-10-cta-text-group.mp4 (3.89 MB, ~720s) + +[inst1] BATCH COMPLETE +[2026-06-07 03:40:20] batch49 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH49 FINALIZED dom 07 jun 2026 03:40:35 EDT +-rw-rw-r-- 1 na na 6805730 jun 7 01:30 public/videos/ugc/batch49/b49-01-girls-trip.mp4 +-rw-rw-r-- 1 na na 6844362 jun 7 01:45 public/videos/ugc/batch49/b49-02-why-it-never.mp4 +-rw-rw-r-- 1 na na 4373108 jun 7 02:00 public/videos/ugc/batch49/b49-03-split-easy.mp4 +-rw-rw-r-- 1 na na 4266540 jun 7 02:15 public/videos/ugc/batch49/b49-04-no-planner.mp4 +-rw-rw-r-- 1 na na 6345008 jun 7 02:29 public/videos/ugc/batch49/b49-05-the-hour-girls.mp4 +-rw-rw-r-- 1 na na 6433164 jun 7 02:44 public/videos/ugc/batch49/b49-06-all-inclusive.mp4 +-rw-rw-r-- 1 na na 5358131 jun 7 02:58 public/videos/ugc/batch49/b49-07-memories.mp4 +-rw-rw-r-- 1 na na 4793736 jun 7 03:11 public/videos/ugc/batch49/b49-08-mom-friends.mp4 +-rw-rw-r-- 1 na na 4228709 jun 7 03:25 public/videos/ugc/batch49/b49-09-already-next.mp4 +-rw-rw-r-- 1 na na 4074501 jun 7 03:40 public/videos/ugc/batch49/b49-10-cta-text-group.mp4 +[2026-06-07 03:40:35] batch49 DONE + deployed to gw +[2026-06-07 03:40:38] === rendering batch50 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b50-01-anniversary (keyframe-v13-1.jpg, 14.8s, 371f) === + queued e8afeccb-bda8-498f-a655-32787e8d2733 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + ✓ saved public/videos/ugc/batch50/b50-01-anniversary.mp4 (7.41 MB, ~845s) + +[inst1] === b50-02-autopilot (keyframe-v13-1.jpg, 15.3s, 381f) === + queued 0e0a3746-faae-4d6c-a6a2-090db6ac296c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch50/b50-02-autopilot.mp4 (7.47 MB, ~825s) + +[inst1] === b50-03-cheaper-counseling (keyframe-v13-2.jpg, 16.0s, 401f) === + queued f3f7bb29-7ece-4f99-ae77-37cf17bb5fb4 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch50/b50-03-cheaper-counseling.mp4 (4.73 MB, ~840s) + +[inst1] === b50-04-kids-free-too (keyframe-v13-2.jpg, 12.9s, 323f) === + queued 2d1ab37b-c31f-454e-8810-098d1db126f6 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch50/b50-04-kids-free-too.mp4 (4.04 MB, ~720s) + +[inst1] === b50-05-the-hour-us (keyframe-v13-3.jpg, 14.5s, 362f) === + queued 0d44cc0f-0546-4942-9b6b-7d854a460845 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch50/b50-05-the-hour-us.mp4 (6.69 MB, ~840s) + +[inst1] === b50-06-talked-again (keyframe-v13-3.jpg, 14.1s, 352f) === + queued b8569019-c0b1-4613-861b-0e6aafb842e6 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch50/b50-06-talked-again.mp4 (6.01 MB, ~725s) + +[inst1] === b50-07-no-money-fight (keyframe-v13-4.jpg, 14.3s, 358f) === + queued 38da143c-ba71-4e3e-8a6e-85096deda521 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch50/b50-07-no-money-fight.mp4 (5.32 MB, ~720s) + +[inst1] === b50-08-recommend-couples (keyframe-v13-4.jpg, 15.8s, 394f) === + queued 9f46600e-8245-4c46-8a67-135e77fba4de + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch50/b50-08-recommend-couples.mp4 (5.88 MB, ~840s) + +[inst1] === b50-09-felt-young (keyframe-v13-5.jpg, 13.8s, 345f) === + queued c3f5810b-dfb4-4e9d-95e7-cbd64dba4cd8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch50/b50-09-felt-young.mp4 (3.88 MB, ~720s) + +[inst1] === b50-10-cta-book-partner (keyframe-v13-5.jpg, 14.3s, 357f) === + queued a3d22093-4b0f-4e76-b9c0-d249f18a09d1 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch50/b50-10-cta-book-partner.mp4 (3.98 MB, ~715s) + +[inst1] BATCH COMPLETE +[2026-06-07 06:19:28] batch50 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH50 FINALIZED dom 07 jun 2026 06:19:45 EDT +-rw-rw-r-- 1 na na 7765660 jun 7 03:58 public/videos/ugc/batch50/b50-01-anniversary.mp4 +-rw-rw-r-- 1 na na 7834666 jun 7 04:15 public/videos/ugc/batch50/b50-02-autopilot.mp4 +-rw-rw-r-- 1 na na 4956817 jun 7 04:32 public/videos/ugc/batch50/b50-03-cheaper-counseling.mp4 +-rw-rw-r-- 1 na na 4234813 jun 7 04:46 public/videos/ugc/batch50/b50-04-kids-free-too.mp4 +-rw-rw-r-- 1 na na 7017369 jun 7 05:03 public/videos/ugc/batch50/b50-05-the-hour-us.mp4 +-rw-rw-r-- 1 na na 6301026 jun 7 05:18 public/videos/ugc/batch50/b50-06-talked-again.mp4 +-rw-rw-r-- 1 na na 5577446 jun 7 05:33 public/videos/ugc/batch50/b50-07-no-money-fight.mp4 +-rw-rw-r-- 1 na na 6166723 jun 7 05:50 public/videos/ugc/batch50/b50-08-recommend-couples.mp4 +-rw-rw-r-- 1 na na 4073450 jun 7 06:04 public/videos/ugc/batch50/b50-09-felt-young.mp4 +-rw-rw-r-- 1 na na 4176162 jun 7 06:19 public/videos/ugc/batch50/b50-10-cta-book-partner.mp4 +[2026-06-07 06:19:45] batch50 DONE + deployed to gw +[2026-06-07 06:19:48] === rendering batch51 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b51-01-solo-trip (keyframe-v13-1.jpg, 16.1s, 402f) === + queued 295dddd8-329e-4fa2-b062-641a516c306f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-01-solo-trip.mp4 (7.46 MB, ~840s) + +[inst1] === b51-02-deserve-it (keyframe-v13-1.jpg, 15.2s, 379f) === + queued 694648e5-2df1-4a1a-9b4d-ce2188e62b23 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-02-deserve-it.mp4 (7.39 MB, ~840s) + +[inst1] === b51-03-cheap-recharge (keyframe-v13-2.jpg, 12.1s, 302f) === + queued 74cf1347-9052-4128-94d6-9b085226d083 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch51/b51-03-cheap-recharge.mp4 (3.66 MB, ~600s) + +[inst1] === b51-04-no-compromise (keyframe-v13-2.jpg, 15.8s, 395f) === + queued fe8ee26a-b347-4ab6-8fc7-d9a2aafb3475 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-04-no-compromise.mp4 (4.61 MB, ~840s) + +[inst1] === b51-05-the-hour-solo (keyframe-v13-3.jpg, 14.8s, 370f) === + queued 30430377-648d-4b9d-ae4c-d7ffd12ec1e4 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-05-the-hour-solo.mp4 (6.77 MB, ~840s) + +[inst1] === b51-06-safe-easy (keyframe-v13-3.jpg, 15.0s, 374f) === + queued 691eda48-50ec-4e5a-92ef-9384f92c7e8e + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-06-safe-easy.mp4 (6.49 MB, ~840s) + +[inst1] === b51-07-met-people (keyframe-v13-4.jpg, 13.2s, 329f) === + queued dda76046-7841-4609-bb5d-b3d8d3a1252c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch51/b51-07-met-people.mp4 (5.14 MB, ~720s) + +[inst1] === b51-08-came-back-better (keyframe-v13-4.jpg, 16.0s, 401f) === + queued 9f39eab0-b493-4952-b3e2-785bff0613fe + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-08-came-back-better.mp4 (5.96 MB, ~835s) + +[inst1] === b51-09-do-it-yearly (keyframe-v13-5.jpg, 14.1s, 351f) === + queued d95d5800-c5e6-4292-adde-b4b2229bf630 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch51/b51-09-do-it-yearly.mp4 (3.87 MB, ~720s) + +[inst1] === b51-10-cta-book-you (keyframe-v13-5.jpg, 15.2s, 379f) === + queued 136ecb86-3f15-471b-9f2f-50847c2f19fb + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch51/b51-10-cta-book-you.mp4 (4.49 MB, ~840s) + +[inst1] BATCH COMPLETE +[2026-06-07 09:01:18] batch51 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH51 FINALIZED dom 07 jun 2026 09:01:35 EDT +-rw-rw-r-- 1 na na 7827104 jun 7 06:37 public/videos/ugc/batch51/b51-01-solo-trip.mp4 +-rw-rw-r-- 1 na na 7751315 jun 7 06:54 public/videos/ugc/batch51/b51-02-deserve-it.mp4 +-rw-rw-r-- 1 na na 3832823 jun 7 07:06 public/videos/ugc/batch51/b51-03-cheap-recharge.mp4 +-rw-rw-r-- 1 na na 4828981 jun 7 07:23 public/videos/ugc/batch51/b51-04-no-compromise.mp4 +-rw-rw-r-- 1 na na 7100247 jun 7 07:40 public/videos/ugc/batch51/b51-05-the-hour-solo.mp4 +-rw-rw-r-- 1 na na 6802116 jun 7 07:57 public/videos/ugc/batch51/b51-06-safe-easy.mp4 +-rw-rw-r-- 1 na na 5386003 jun 7 08:12 public/videos/ugc/batch51/b51-07-met-people.mp4 +-rw-rw-r-- 1 na na 6252056 jun 7 08:29 public/videos/ugc/batch51/b51-08-came-back-better.mp4 +-rw-rw-r-- 1 na na 4058376 jun 7 08:44 public/videos/ugc/batch51/b51-09-do-it-yearly.mp4 +-rw-rw-r-- 1 na na 4707601 jun 7 09:01 public/videos/ugc/batch51/b51-10-cta-book-you.mp4 +[2026-06-07 09:01:35] batch51 DONE + deployed to gw +[2026-06-07 09:01:38] === rendering batch52 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b52-01-was-skeptic (keyframe-v13-1.jpg, 14.3s, 357f) === + queued 4ee578fe-b914-4870-af33-c7d7b1bebea3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch52/b52-01-was-skeptic.mp4 (6.62 MB, ~725s) + +[inst1] === b52-02-too-good (keyframe-v13-1.jpg, 12.9s, 323f) === + queued 51867e5c-dcae-4967-86dd-c7e2221fc6d3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch52/b52-02-too-good.mp4 (6.60 MB, ~725s) + +[inst1] === b52-03-did-research (keyframe-v13-2.jpg, 14.1s, 353f) === + queued a1c6cb11-f07f-47bf-b98e-22ee60c0c7ae + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch52/b52-03-did-research.mp4 (4.14 MB, ~725s) + +[inst1] === b52-04-looked-for-trap (keyframe-v13-2.jpg, 14.0s, 350f) === + queued 7ce158c6-72ea-4b6d-93b1-3db721a1996c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch52/b52-04-looked-for-trap.mp4 (4.10 MB, ~725s) + +[inst1] === b52-05-hour-confirmed (keyframe-v13-3.jpg, 17.3s, 434f) === + queued 4768be9b-c32d-4879-9f90-eb6671a31d72 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + [900s] running... + [960s] running... + ✓ saved public/videos/ugc/batch52/b52-05-hour-confirmed.mp4 (7.65 MB, ~965s) + +[inst1] === b52-06-husband-skeptic (keyframe-v13-3.jpg, 14.3s, 357f) === + queued 5f630b12-c5f4-4776-9944-6df1b13fedfc + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch52/b52-06-husband-skeptic.mp4 (6.34 MB, ~725s) + +[inst1] === b52-07-now-i-tell (keyframe-v13-4.jpg, 13.3s, 332f) === + queued 23bc4852-fc71-4d9b-9e1a-7cf41ce77948 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch52/b52-07-now-i-tell.mp4 (5.13 MB, ~720s) + +[inst1] === b52-08-proof-photos (keyframe-v13-4.jpg, 15.3s, 381f) === + queued 34f75bc1-a2f9-4db3-a5b4-b83b7afeb6a0 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + ✓ saved public/videos/ugc/batch52/b52-08-proof-photos.mp4 (5.84 MB, ~845s) + +[inst1] === b52-09-what-changed (keyframe-v13-5.jpg, 13.5s, 337f) === + queued fa06db82-b85a-4f8d-a781-278c582d68f7 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + ✓ saved public/videos/ugc/batch52/b52-09-what-changed.mp4 (3.97 MB, ~725s) + +[inst1] === b52-10-cta-skeptics (keyframe-v13-5.jpg, 15.4s, 385f) === + queued 6141178c-d6b0-4e5f-b0b1-766d2c69a49b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch52/b52-10-cta-skeptics.mp4 (4.56 MB, ~840s) + +[inst1] BATCH COMPLETE +[2026-06-07 11:37:59] batch52 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH52 FINALIZED dom 07 jun 2026 11:38:24 EDT +-rw-rw-r-- 1 na na 6941673 jun 7 09:16 public/videos/ugc/batch52/b52-01-was-skeptic.mp4 +-rw-rw-r-- 1 na na 6921967 jun 7 09:31 public/videos/ugc/batch52/b52-02-too-good.mp4 +-rw-rw-r-- 1 na na 4343799 jun 7 09:45 public/videos/ugc/batch52/b52-03-did-research.mp4 +-rw-rw-r-- 1 na na 4304236 jun 7 10:00 public/videos/ugc/batch52/b52-04-looked-for-trap.mp4 +-rw-rw-r-- 1 na na 8022086 jun 7 10:20 public/videos/ugc/batch52/b52-05-hour-confirmed.mp4 +-rw-rw-r-- 1 na na 6642920 jun 7 10:34 public/videos/ugc/batch52/b52-06-husband-skeptic.mp4 +-rw-rw-r-- 1 na na 5378671 jun 7 10:49 public/videos/ugc/batch52/b52-07-now-i-tell.mp4 +-rw-rw-r-- 1 na na 6126991 jun 7 11:06 public/videos/ugc/batch52/b52-08-proof-photos.mp4 +-rw-rw-r-- 1 na na 4165989 jun 7 11:20 public/videos/ugc/batch52/b52-09-what-changed.mp4 +-rw-rw-r-- 1 na na 4777934 jun 7 11:37 public/videos/ugc/batch52/b52-10-cta-skeptics.mp4 +[2026-06-07 11:38:24] batch52 DONE + deployed to gw +[2026-06-07 11:38:27] === rendering batch53 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b53-01-bucket-list (keyframe-v13-1.jpg, 15.1s, 377f) === + queued e9c036e9-ba67-4677-9c0e-d2aae386901f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch53/b53-01-bucket-list.mp4 (7.36 MB, ~830s) + +[inst1] === b53-02-on-the-list (keyframe-v13-1.jpg, 15.0s, 374f) === + queued dc1c109e-aa02-4c95-b672-3858fc70cfe8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch53/b53-02-on-the-list.mp4 (7.56 MB, ~805s) + +[inst1] === b53-03-postcard (keyframe-v13-2.jpg, 14.3s, 357f) === + queued 457a739f-5736-402c-a0e9-47924980bc00 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch53/b53-03-postcard.mp4 (4.23 MB, ~720s) + +[inst1] === b53-04-not-rich (keyframe-v13-2.jpg, 14.1s, 352f) === + queued 06d0645a-0465-485b-87c8-5635d3bf2e69 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch53/b53-04-not-rich.mp4 (4.11 MB, ~720s) + +[inst1] === b53-05-the-hour-worth (keyframe-v13-3.jpg, 13.6s, 341f) === + queued b0661074-09a4-4435-a8f0-7eed53c03c84 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch53/b53-05-the-hour-worth.mp4 (6.05 MB, ~705s) + +[inst1] === b53-06-photos-prove (keyframe-v13-3.jpg, 16.0s, 400f) === + queued dce78336-627f-42b4-ba2f-e7306783a39f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch53/b53-06-photos-prove.mp4 (7.04 MB, ~840s) + +[inst1] === b53-07-do-it-young (keyframe-v13-4.jpg, 14.2s, 356f) === + queued 8de4595c-f379-4ad7-9132-d726ecf4d4c5 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch53/b53-07-do-it-young.mp4 (5.33 MB, ~715s) + +[inst1] === b53-08-kids-saw (keyframe-v13-4.jpg, 15.5s, 387f) === + queued 494ed5c3-1d21-49fa-aa2f-812ab61fb700 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + [840s] running... + ✓ saved public/videos/ugc/batch53/b53-08-kids-saw.mp4 (5.81 MB, ~845s) + +[inst1] === b53-09-what-else (keyframe-v13-5.jpg, 13.0s, 325f) === + queued 911c555f-afa8-46cb-8e5e-8abe1dc8a30d + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch53/b53-09-what-else.mp4 (4.00 MB, ~720s) + +[inst1] === b53-10-cta-cross-off (keyframe-v13-5.jpg, 14.9s, 373f) === + queued 52444f3f-7996-483d-b647-37edc984c5f3 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch53/b53-10-cta-cross-off.mp4 (4.46 MB, ~840s) + +[inst1] BATCH COMPLETE +[2026-06-07 14:17:40] batch53 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH53 FINALIZED dom 07 jun 2026 14:17:58 EDT +-rw-rw-r-- 1 na na 7721811 jun 7 11:55 public/videos/ugc/batch53/b53-01-bucket-list.mp4 +-rw-rw-r-- 1 na na 7931513 jun 7 12:13 public/videos/ugc/batch53/b53-02-on-the-list.mp4 +-rw-rw-r-- 1 na na 4437497 jun 7 12:27 public/videos/ugc/batch53/b53-03-postcard.mp4 +-rw-rw-r-- 1 na na 4307464 jun 7 12:42 public/videos/ugc/batch53/b53-04-not-rich.mp4 +-rw-rw-r-- 1 na na 6346345 jun 7 12:57 public/videos/ugc/batch53/b53-05-the-hour-worth.mp4 +-rw-rw-r-- 1 na na 7376760 jun 7 13:14 public/videos/ugc/batch53/b53-06-photos-prove.mp4 +-rw-rw-r-- 1 na na 5591182 jun 7 13:28 public/videos/ugc/batch53/b53-07-do-it-young.mp4 +-rw-rw-r-- 1 na na 6094768 jun 7 13:45 public/videos/ugc/batch53/b53-08-kids-saw.mp4 +-rw-rw-r-- 1 na na 4195981 jun 7 14:00 public/videos/ugc/batch53/b53-09-what-else.mp4 +-rw-rw-r-- 1 na na 4678736 jun 7 14:17 public/videos/ugc/batch53/b53-10-cta-cross-off.mp4 +[2026-06-07 14:17:58] batch53 DONE + deployed to gw +[2026-06-07 14:18:01] === rendering batch54 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b54-01-last-minute (keyframe-v13-1.jpg, 15.4s, 386f) === + queued 645fd789-76f3-42a9-9cd5-9e4cdf1d6db8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch54/b54-01-last-minute.mp4 (7.41 MB, ~840s) + +[inst1] === b54-02-needed-out (keyframe-v13-1.jpg, 14.9s, 372f) === + queued a6b2ce35-1d04-45d4-a4b9-0fbaafc3c69d + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch54/b54-02-needed-out.mp4 (7.27 MB, ~840s) + +[inst1] === b54-03-cheap-enough (keyframe-v13-2.jpg, 13.6s, 341f) === + queued a2838209-6305-49b9-95ea-67b4f92fe271 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch54/b54-03-cheap-enough.mp4 (4.16 MB, ~715s) + +[inst1] === b54-04-no-overthink (keyframe-v13-2.jpg, 13.6s, 340f) === + queued 8b50f673-cd68-4f88-a432-3a6910412250 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch54/b54-04-no-overthink.mp4 (4.09 MB, ~710s) + +[inst1] === b54-05-the-hour-fast (keyframe-v13-3.jpg, 13.3s, 332f) === + queued a6054764-d2eb-4c25-8138-6fafb2d40cf4 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch54/b54-05-the-hour-fast.mp4 (6.02 MB, ~715s) + +[inst1] === b54-06-dates-flexible (keyframe-v13-3.jpg, 14.7s, 366f) === + queued 443931f5-8e80-4e44-a839-8c2a78b30598 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch54/b54-06-dates-flexible.mp4 (6.71 MB, ~835s) + +[inst1] === b54-07-mental-health (keyframe-v13-4.jpg, 12.2s, 306f) === + queued 99ea403b-d977-4eea-8a9a-91d7b119f457 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch54/b54-07-mental-health.mp4 (4.55 MB, ~595s) + +[inst1] === b54-08-told-no-one (keyframe-v13-4.jpg, 15.0s, 376f) === + queued 1e9c11dd-d348-4e4b-8a15-2a2da8a35e6f + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch54/b54-08-told-no-one.mp4 (5.87 MB, ~835s) + +[inst1] === b54-09-back-recharged (keyframe-v13-5.jpg, 13.5s, 337f) === + queued 3bfd18be-b361-4ac2-b667-e67c339ac4e6 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch54/b54-09-back-recharged.mp4 (3.97 MB, ~715s) + +[inst1] === b54-10-cta-need-break (keyframe-v13-5.jpg, 14.1s, 353f) === + queued 7d2562f8-85dc-4d88-987f-ff73ab0e8b71 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch54/b54-10-cta-need-break.mp4 (4.00 MB, ~710s) + +[inst1] BATCH COMPLETE +[2026-06-07 16:52:11] batch54 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH54 FINALIZED dom 07 jun 2026 16:52:37 EDT +-rw-rw-r-- 1 na na 7772806 jun 7 14:35 public/videos/ugc/batch54/b54-01-last-minute.mp4 +-rw-rw-r-- 1 na na 7619524 jun 7 14:52 public/videos/ugc/batch54/b54-02-needed-out.mp4 +-rw-rw-r-- 1 na na 4357808 jun 7 15:07 public/videos/ugc/batch54/b54-03-cheap-enough.mp4 +-rw-rw-r-- 1 na na 4287178 jun 7 15:21 public/videos/ugc/batch54/b54-04-no-overthink.mp4 +-rw-rw-r-- 1 na na 6312347 jun 7 15:36 public/videos/ugc/batch54/b54-05-the-hour-fast.mp4 +-rw-rw-r-- 1 na na 7038785 jun 7 15:53 public/videos/ugc/batch54/b54-06-dates-flexible.mp4 +-rw-rw-r-- 1 na na 4767999 jun 7 16:05 public/videos/ugc/batch54/b54-07-mental-health.mp4 +-rw-rw-r-- 1 na na 6151882 jun 7 16:22 public/videos/ugc/batch54/b54-08-told-no-one.mp4 +-rw-rw-r-- 1 na na 4161290 jun 7 16:37 public/videos/ugc/batch54/b54-09-back-recharged.mp4 +-rw-rw-r-- 1 na na 4189755 jun 7 16:52 public/videos/ugc/batch54/b54-10-cta-need-break.mp4 +[2026-06-07 16:52:37] batch54 DONE + deployed to gw +[2026-06-07 16:52:40] === rendering batch55 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b55-01-honeymoon (keyframe-v13-1.jpg, 13.8s, 346f) === + queued 700f796c-06e9-4102-883d-c3c1e696a079 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-01-honeymoon.mp4 (6.74 MB, ~720s) + +[inst1] === b55-02-wedding-broke (keyframe-v13-1.jpg, 13.0s, 325f) === + queued 754dd15a-ba4e-4a53-883d-97ec11d5f5de + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-02-wedding-broke.mp4 (6.63 MB, ~720s) + +[inst1] === b55-03-still-romantic (keyframe-v13-2.jpg, 14.4s, 359f) === + queued 46688a3f-855c-47d4-b30c-5cde6bf44187 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-03-still-romantic.mp4 (4.18 MB, ~720s) + +[inst1] === b55-04-all-included (keyframe-v13-2.jpg, 13.5s, 337f) === + queued 437a621e-0792-406f-a4ad-cf40de6742ab + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-04-all-included.mp4 (4.05 MB, ~720s) + +[inst1] === b55-05-the-hour-newly (keyframe-v13-3.jpg, 15.8s, 394f) === + queued 8638e15b-47a8-412c-b82d-9ebd7f0821b6 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch55/b55-05-the-hour-newly.mp4 (6.91 MB, ~835s) + +[inst1] === b55-06-engaged-tip (keyframe-v13-3.jpg, 12.8s, 321f) === + queued 5ede8ac7-982a-4eed-ae3b-d5197e997956 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-06-engaged-tip.mp4 (6.09 MB, ~720s) + +[inst1] === b55-07-more-budget (keyframe-v13-4.jpg, 14.3s, 357f) === + queued 7eacd1cc-328b-4828-be3d-2ab5b7e252bd + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-07-more-budget.mp4 (5.10 MB, ~720s) + +[inst1] === b55-08-photos-newly (keyframe-v13-4.jpg, 13.9s, 349f) === + queued 12efab8e-227e-46ff-abcf-370007465536 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-08-photos-newly.mp4 (5.11 MB, ~720s) + +[inst1] === b55-09-do-anniversary (keyframe-v13-5.jpg, 12.5s, 312f) === + queued 8605957c-5e44-453f-a764-94e5d0d35b02 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-09-do-anniversary.mp4 (3.87 MB, ~720s) + +[inst1] === b55-10-cta-newlyweds (keyframe-v13-5.jpg, 13.8s, 346f) === + queued 857d4ac7-b525-4fe9-96db-5898e9e89bb8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch55/b55-10-cta-newlyweds.mp4 (4.03 MB, ~715s) + +[inst1] BATCH COMPLETE +[2026-06-07 19:21:51] batch55 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH55 FINALIZED dom 07 jun 2026 19:22:12 EDT +-rw-rw-r-- 1 na na 7064802 jun 7 17:07 public/videos/ugc/batch55/b55-01-honeymoon.mp4 +-rw-rw-r-- 1 na na 6956624 jun 7 17:22 public/videos/ugc/batch55/b55-02-wedding-broke.mp4 +-rw-rw-r-- 1 na na 4383465 jun 7 17:36 public/videos/ugc/batch55/b55-03-still-romantic.mp4 +-rw-rw-r-- 1 na na 4244746 jun 7 17:51 public/videos/ugc/batch55/b55-04-all-included.mp4 +-rw-rw-r-- 1 na na 7240620 jun 7 18:08 public/videos/ugc/batch55/b55-05-the-hour-newly.mp4 +-rw-rw-r-- 1 na na 6382588 jun 7 18:23 public/videos/ugc/batch55/b55-06-engaged-tip.mp4 +-rw-rw-r-- 1 na na 5345318 jun 7 18:38 public/videos/ugc/batch55/b55-07-more-budget.mp4 +-rw-rw-r-- 1 na na 5356861 jun 7 18:52 public/videos/ugc/batch55/b55-08-photos-newly.mp4 +-rw-rw-r-- 1 na na 4062868 jun 7 19:07 public/videos/ugc/batch55/b55-09-do-anniversary.mp4 +-rw-rw-r-- 1 na na 4229253 jun 7 19:21 public/videos/ugc/batch55/b55-10-cta-newlyweds.mp4 +[2026-06-07 19:22:12] batch55 DONE + deployed to gw +[2026-06-07 19:22:15] === rendering batch56 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b56-01-retire-hack (keyframe-v13-1.jpg, 15.8s, 394f) === + queued e43870c5-55c3-4f6b-bcc8-7e115ca87b7b + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch56/b56-01-retire-hack.mp4 (7.43 MB, ~835s) + +[inst1] === b56-02-fixed-income (keyframe-v13-1.jpg, 13.2s, 329f) === + queued 1e08d934-8a6a-4420-a239-fe83653ebc31 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch56/b56-02-fixed-income.mp4 (6.54 MB, ~710s) + +[inst1] === b56-03-time-now (keyframe-v13-2.jpg, 13.8s, 345f) === + queued 28d3d533-376f-4803-8c59-0b3264a868d1 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch56/b56-03-time-now.mp4 (4.08 MB, ~715s) + +[inst1] === b56-04-the-hour-us (keyframe-v13-2.jpg, 13.9s, 349f) === + queued c06590af-5e81-4562-a9c6-5a48c1a7e383 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch56/b56-04-the-hour-us.mp4 (4.11 MB, ~715s) + +[inst1] === b56-05-no-membership (keyframe-v13-3.jpg, 13.2s, 330f) === + queued 3cf6d21c-847a-4fe1-aa5a-31d58f798c2c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch56/b56-05-no-membership.mp4 (6.02 MB, ~715s) + +[inst1] === b56-06-grandkids (keyframe-v13-3.jpg, 14.0s, 350f) === + queued 69dfd049-f7cb-4f67-a0f1-6788a021a85c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch56/b56-06-grandkids.mp4 (6.00 MB, ~715s) + +[inst1] === b56-07-bucket-now (keyframe-v13-4.jpg, 11.2s, 280f) === + queued 7f522b10-7617-4ae8-a869-da8cd8f4c7d7 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + ✓ saved public/videos/ugc/batch56/b56-07-bucket-now.mp4 (4.63 MB, ~595s) + +[inst1] === b56-08-easy-trip (keyframe-v13-4.jpg, 15.4s, 386f) === + queued 53ed992f-b18d-4a7a-aacb-7ab960449c54 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch56/b56-08-easy-trip.mp4 (5.88 MB, ~835s) + +[inst1] === b56-09-tell-friends (keyframe-v13-5.jpg, 12.6s, 314f) === + queued d5f82b9f-471d-4eae-91fb-2f71f8f854d8 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch56/b56-09-tell-friends.mp4 (4.03 MB, ~715s) + +[inst1] === b56-10-cta-retirees (keyframe-v13-5.jpg, 16.4s, 410f) === + queued 72819856-4bcd-4b94-b805-694c87d3d4f5 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... + ✓ saved public/videos/ugc/batch56/b56-10-cta-retirees.mp4 (4.53 MB, ~835s) + +[inst1] BATCH COMPLETE +[2026-06-07 21:53:39] batch56 render pass finished — 10/10 mp4 +waiting for 10 rendered videos... +all 10 rendered. deploying (uncaptioned)... +BATCH56 FINALIZED dom 07 jun 2026 21:53:56 EDT +-rw-rw-r-- 1 na na 7795443 jun 7 19:39 public/videos/ugc/batch56/b56-01-retire-hack.mp4 +-rw-rw-r-- 1 na na 6855844 jun 7 19:54 public/videos/ugc/batch56/b56-02-fixed-income.mp4 +-rw-rw-r-- 1 na na 4276868 jun 7 20:08 public/videos/ugc/batch56/b56-03-time-now.mp4 +-rw-rw-r-- 1 na na 4304802 jun 7 20:23 public/videos/ugc/batch56/b56-04-the-hour-us.mp4 +-rw-rw-r-- 1 na na 6312362 jun 7 20:38 public/videos/ugc/batch56/b56-05-no-membership.mp4 +-rw-rw-r-- 1 na na 6295142 jun 7 20:52 public/videos/ugc/batch56/b56-06-grandkids.mp4 +-rw-rw-r-- 1 na na 4854489 jun 7 21:05 public/videos/ugc/batch56/b56-07-bucket-now.mp4 +-rw-rw-r-- 1 na na 6169493 jun 7 21:22 public/videos/ugc/batch56/b56-08-easy-trip.mp4 +-rw-rw-r-- 1 na na 4224666 jun 7 21:36 public/videos/ugc/batch56/b56-09-tell-friends.mp4 +-rw-rw-r-- 1 na na 4748570 jun 7 21:53 public/videos/ugc/batch56/b56-10-cta-retirees.mp4 +[2026-06-07 21:53:56] batch56 DONE + deployed to gw +[2026-06-07 21:53:59] === rendering batch57 on Inst 2 === +[inst1] uploading inputs... +[inst1] inputs uploaded + +[inst1] === b57-01-cheap-husband (keyframe-v13-1.jpg, 12.6s, 316f) === + queued 57b55f77-2778-44e6-b253-245260c4d694 + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch57/b57-01-cheap-husband.mp4 (6.52 MB, ~715s) + +[inst1] === b57-02-the-number (keyframe-v13-1.jpg, 14.0s, 350f) === + queued ae78da5c-4204-4f07-a3fd-18b7edb495eb + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + ✓ saved public/videos/ugc/batch57/b57-02-the-number.mp4 (6.54 MB, ~710s) + +[inst1] === b57-03-he-checked (keyframe-v13-2.jpg, 15.1s, 377f) === + queued 28a1ae20-0c02-418a-bec2-34866774a08c + [0s] running... + [60s] running... + [120s] running... + [180s] running... + [240s] running... + [300s] running... + [360s] running... + [420s] running... + [480s] running... + [540s] running... + [600s] running... + [660s] running... + [720s] running... + [780s] running... diff --git a/scripts/orchestrator.sh b/scripts/orchestrator.sh new file mode 100755 index 0000000..d77039f --- /dev/null +++ b/scripts/orchestrator.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# ── Render orchestrator ────────────────────────────────────────────── +# Keeps the Inst 2 GPU (root@51.83.197.242:43312, ComfyUI on 18188) busy +# non-stop. Reads scripts/render-queue.txt (one batch number per line, +# FIFO), renders each via its batchNN-render.ts (config key `inst1`, +# already retargeted to Inst 2), then finalizes + deploys to gw +# (/var/www/html/batchNN/). Loops forever so newly-appended batches are +# picked up. Skips batches already in render-done.txt. +set -u +cd /home/na/ai-management-dashboard +QUEUE=scripts/render-queue.txt +DONE=scripts/render-done.txt +LOG=scripts/orchestrator.log +touch "$QUEUE" "$DONE" +SSH="ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 -p 43312 root@51.83.197.242" + +log(){ echo "[$(date '+%F %T')] $*" >> "$LOG"; } + +log "orchestrator started (pid $$)" +while true; do + # next queued batch not yet marked done + next="" + while read -r b; do + b="${b//[[:space:]]/}"; [ -z "$b" ] && continue + grep -qxF "$b" "$DONE" && continue + next="$b"; break + done < "$QUEUE" + + if [ -z "$next" ]; then + log "queue drained — idle, recheck in 120s" + sleep 120; continue + fi + b="$next" + + # already complete locally? just deploy + mark done + have=$(ls "public/videos/ugc/batch$b"/b$b-*.mp4 2>/dev/null | grep -vc captioned) + if [ "${have:-0}" -ge 10 ]; then + log "batch$b already $have/10 rendered — deploying only" + [ -f "scripts/finalize-batch$b.sh" ] && bash "scripts/finalize-batch$b.sh" >> "$LOG" 2>&1 + echo "$b" >> "$DONE"; continue + fi + + # health-check Inst 2 + if ! $SSH 'echo ok' >/dev/null 2>&1; then + log "Inst 2 unreachable — wait 120s"; sleep 120; continue + fi + + # clear any stale tunnel to Inst 2 ComfyUI + pkill -f "localhost:18188 root@51.83.197.242" 2>/dev/null || true + + log "=== rendering batch$b on Inst 2 ===" + npx tsx "scripts/batch$b-render.ts" inst1 >> "$LOG" 2>&1 + have=$(ls "public/videos/ugc/batch$b"/b$b-*.mp4 2>/dev/null | grep -vc captioned) + log "batch$b render pass finished — $have/10 mp4" + + if [ "${have:-0}" -ge 10 ]; then + [ -f "scripts/finalize-batch$b.sh" ] && bash "scripts/finalize-batch$b.sh" >> "$LOG" 2>&1 + echo "$b" >> "$DONE" + log "batch$b DONE + deployed to gw" + else + log "batch$b incomplete ($have/10) — retry next loop"; sleep 30 + fi +done diff --git a/scripts/render-done.txt b/scripts/render-done.txt new file mode 100644 index 0000000..1679cdb --- /dev/null +++ b/scripts/render-done.txt @@ -0,0 +1,19 @@ +20 +22 +23 +25 +27 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 diff --git a/scripts/render-queue.txt b/scripts/render-queue.txt new file mode 100644 index 0000000..2a48944 --- /dev/null +++ b/scripts/render-queue.txt @@ -0,0 +1,22 @@ +20 +22 +23 +25 +27 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 diff --git a/scripts/scrape-tiktok.ts b/scripts/scrape-tiktok.ts new file mode 100644 index 0000000..f22bd21 --- /dev/null +++ b/scripts/scrape-tiktok.ts @@ -0,0 +1,92 @@ +import puppeteer from 'puppeteer-extra' +import StealthPlugin from 'puppeteer-extra-plugin-stealth' + +puppeteer.use(StealthPlugin()) + +const USERNAME = 'travel.to.mexico8' +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) + +async function scrape() { + console.log('Launching stealth browser...') + + const browser = await puppeteer.launch({ + headless: true, + executablePath: '/usr/bin/google-chrome-stable', + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }) + + try { + const page = await browser.newPage() + await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') + await page.setViewport({ width: 1920, height: 1080 }) + + const profileUrl = `https://www.tiktok.com/@${USERNAME}` + console.log(`Navigating to ${profileUrl}...`) + await page.goto(profileUrl, { waitUntil: 'networkidle0', timeout: 45000 }) + await sleep(5000) + + // Scroll aggressively to trigger lazy loading + console.log('Scrolling to load all videos...') + for (let i = 0; i < 15; i++) { + await page.evaluate(() => window.scrollBy(0, 2000)) + await sleep(1000) + } + + // Scroll back up and down again + await page.evaluate(() => window.scrollTo(0, 0)) + await sleep(1000) + for (let i = 0; i < 10; i++) { + await page.evaluate(() => window.scrollBy(0, 1500)) + await sleep(800) + } + + // Extract from page HTML source - TikTok embeds video IDs in the SSR data + const html = await page.content() + + // Method 1: Find video IDs in href attributes + const hrefMatches = html.match(/\/@[^/]+\/video\/(\d+)/g) || [] + const ids = new Set() + hrefMatches.forEach(m => { + const id = m.match(/\/video\/(\d+)/)?.[1] + if (id) ids.add(id) + }) + + // Method 2: Find in JSON data / script tags + const jsonMatches = html.match(/"video\/(\d{15,25})"/g) || [] + jsonMatches.forEach(m => { + const id = m.match(/(\d{15,25})/)?.[1] + if (id) ids.add(id) + }) + + // Method 3: Check __UNIVERSAL_DATA_FOR_REHYDRATION__ or SIGI_STATE + const dataMatches = html.match(/videoId['":\s]+['"](\d{15,25})['"]/g) || [] + dataMatches.forEach(m => { + const id = m.match(/(\d{15,25})/)?.[1] + if (id) ids.add(id) + }) + + // Method 4: Look for video data in script tags + const scriptMatches = html.match(/"id"\s*:\s*"(\d{18,20})"/g) || [] + scriptMatches.forEach(m => { + const id = m.match(/(\d{18,20})/)?.[1] + if (id) ids.add(id) + }) + + const videoIds = Array.from(ids) + console.log(`\nFound ${videoIds.length} video IDs for @${USERNAME}:`) + videoIds.forEach((id, i) => { + console.log(` ${i + 1}. ${id} → https://www.tiktok.com/@${USERNAME}/video/${id}`) + }) + + console.log(`\n--- ARRAY FOR TikTokCarousel.tsx ---`) + console.log(`const TIKTOK_VIDEOS = ${JSON.stringify(videoIds, null, 2)}`) + console.log(`const TIKTOK_USERNAME = '${USERNAME}'`) + + } catch (error) { + console.error('Scrape error:', error) + } finally { + await browser.close() + } +} + +scrape() diff --git a/scripts/segment_couple.py b/scripts/segment_couple.py new file mode 100644 index 0000000..9d03a61 --- /dev/null +++ b/scripts/segment_couple.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Segment a couple keyframe into per-person masks for MultiTalk multi-speaker. + + python3 scripts/segment_couple.py + +Outputs (all 480x832, matching the InfiniteTalk render canvas): + -frame.jpg resized keyframe + -mask-w.png woman (foreground selfie-taker) — white on black + -mask-m.png man (the partner) — white on black + +The woman is identified as the foreground person: the mask whose centroid +is lowest / whose area is largest (she holds the phone, closest to camera). +""" +import sys +import numpy as np +from PIL import Image +from ultralytics import YOLO + +KEYFRAME = sys.argv[1] +OUT_DIR = sys.argv[2] +STEM = sys.argv[3] +W, H = 480, 832 + +img = Image.open(KEYFRAME).convert('RGB').resize((W, H), Image.LANCZOS) +img.save(f'{OUT_DIR}/{STEM}-frame.jpg', quality=95) + +model = YOLO('yolo11x-seg.pt') +res = model.predict(np.array(img), classes=[0], conf=0.25, verbose=False)[0] + +if res.masks is None or len(res.masks) < 2: + print(f'ERROR: found {0 if res.masks is None else len(res.masks)} people, need 2') + sys.exit(1) + +# collect (area, centroid_y, mask_array) for each detected person +people = [] +for m in res.masks.data: + arr = m.cpu().numpy() + arr = np.array(Image.fromarray((arr * 255).astype(np.uint8)).resize((W, H), Image.NEAREST)) + ys, xs = np.where(arr > 127) + if len(ys) == 0: + continue + area = len(ys) + cy = ys.mean() + people.append((area, cy, arr)) + +# keep the two largest +people.sort(key=lambda p: -p[0]) +people = people[:2] + +# woman = foreground = larger area AND lower centroid (closer to camera/bottom) +# score: bigger + lower wins +people.sort(key=lambda p: -(p[0] + p[1] * 200)) +woman = people[0][2] +man = people[1][2] + +Image.fromarray(woman).save(f'{OUT_DIR}/{STEM}-mask-w.png') +Image.fromarray(man).save(f'{OUT_DIR}/{STEM}-mask-m.png') + +# background = everything that is neither person (ref_target_masks wants +# shape [num_speakers + 1 background, H, W]) +bg = np.full((H, W), 255, dtype=np.uint8) +bg[(woman > 127) | (man > 127)] = 0 +Image.fromarray(bg).save(f'{OUT_DIR}/{STEM}-mask-bg.png') +print(f'OK {STEM}: woman area={int((woman>127).sum())} man area={int((man>127).sum())} bg area={int((bg>127).sum())}') diff --git a/scripts/travel-quotes.json b/scripts/travel-quotes.json new file mode 100644 index 0000000..c481cb4 --- /dev/null +++ b/scripts/travel-quotes.json @@ -0,0 +1,200 @@ +{ + "_note": "hi2b.com travel-motivation quote library for TikTok quote-card videos (ocean/sunset background + bold text overlay). 240 original lines tagged by theme/audience. 'fuck_it_closers' is the priority set to produce first.", + "_format": "9:16 vertical. Bold all-caps overlay (Anton/Bebas/Montserrat ExtraBold), white + soft shadow, top third over open sky.", + "_offer": "hi2b — $29/mo or $249 one-time, 5 days/4 nights all-inclusive, 2 adults + kids free, 4 Mexico destinations.", + "fuck_it_closers": [ + "Fuck it. Just travel.", + "Fuck the 'someday' account. Book the trip your future self is begging for.", + "Fuck the savings goal — your kids are growing up RIGHT NOW.", + "Make the memory now, dammit. They're growing as you read this.", + "Fuck the excuses, man. Make her feel chosen again.", + "Fuck it — it's $29. Make her year. Book the trip.", + "Quit overthinking it. Book the trip, kiss your wife, you're welcome.", + "Fuck it, girl — make him take you. It's $29. He'll survive.", + "Fuck it, girls — load up the kids and GO. It's free for them, $29 for you.", + "Group chat, listen up: kids free, $29/month, five days. We're GOING.", + "Stop overthinking it. Pack the damn bag.", + "Life's short as hell. Go see the ocean.", + "Quit pinching pennies on the woman who puts up with you. Book it.", + "Die with stories, not savings.", + "Stop saving the good life for 'later.' Later isn't promised." + ], + "travel_now": [ + "You'll never be younger than you are today. Go.", + "The trip you keep postponing is the one you'll regret most.", + "Someday is not a day of the week. Book it.", + "You can't take the money with you. Spend it on memories.", + "Your bucket list has an expiration date.", + "One day you'll be too old to do this. That day isn't today.", + "Stop saving the good life for 'later.' Later isn't promised.", + "The beach won't wait forever. Neither will your knees.", + "Tomorrow is a coin flip. The ocean is a sure thing.", + "Time is the one currency you can't earn back." + ], + "blunt_cursey": [ + "Fuck it. Just travel.", + "You're not broke, you're scared. Book the trip.", + "Stop overthinking it. Pack the damn bag.", + "Life's short as hell. Go see the ocean.", + "Nobody's coming to give you permission. Just go.", + "Work will still be there. Your one wild life won't.", + "Quit waiting for the 'right time.' It's a myth. Go.", + "You'll spend $300 on nothing this month. Spend it on this instead.", + "Burnout isn't a flex. The beach is the cure.", + "Stop scrolling other people's trips. Take your own." + ], + "money_reframe": [ + "What good is a full bank account and an empty life?", + "The richest people I know have the most stamps in their passport.", + "You won't remember the overtime. You'll remember the sunset.", + "$249 now or 'I wish I had' forever. Choose.", + "Money grows back. This summer doesn't.", + "You can always make more money. You can't make more time.", + "The most expensive thing you'll ever buy is the trip you didn't take.", + "Spend on the memory, not the regret.", + "A cheap trip you took beats an expensive one you dreamed about.", + "Your future self is begging you to book it." + ], + "recharge": [ + "Rest is not a reward. It's a requirement. Go get it.", + "You deserve a week where nothing is your problem.", + "Recharge before you break, not after.", + "The ocean fixes things your phone can't.", + "Trade the group chat for the salt air.", + "Five days of doing nothing beats a year of meaning to.", + "Go somewhere your worries can't find you.", + "Sunsets don't care about your inbox. Neither should you.", + "Peace isn't a place you'll find. It's a place you'll book.", + "Your soul needs a vacation more than your savings need the money." + ], + "die_with_zero": [ + "A trip ends in a week. The memory pays you back for life.", + "You're not spending $249 — you're buying a story you'll tell for 40 years.", + "Experiences compound. Bank balances just sit there.", + "The best return on your money isn't in the market. It's on a beach.", + "Buy the memory now and collect the dividends for the rest of your life.", + "You'll replay this sunset in your head a thousand times. Worth it.", + "A dollar at 35 buys more joy than a dollar at 85.", + "Money you spend at 80 can't buy back the body you had at 40.", + "Every year you wait, the same trip is worth less to you.", + "The point of money is to trade it for life. So trade it.", + "A huge bank account on your deathbed is a math error, not a win.", + "Don't let your best years fund your last, exhausted ones.", + "There's a version of this trip you can only take now. Take it.", + "Some experiences expire. The beach-with-your-kids one is closing fast.", + "Your 30s won't wait for your 50s to feel ready.", + "Aim to die with great stories and zero regrets — not a fat unused account.", + "Unused money is unlived life. Go live it.", + "The goal isn't the most money. It's the most life.", + "You can't hug a savings account. Go hug the ocean.", + "Health is wealth that doesn't compound — it only declines. Spend it traveling.", + "Net worth means nothing if your net memories are zero.", + "Die with stories, not savings." + ], + "kids_family": [ + "Blink and they're packing for college. Make the trip now.", + "They're only THIS little once. Go before it's gone.", + "The little hand that wants to hold yours won't want to forever. Go now.", + "Every summer with them is one you'll never get back. Spend it well.", + "One day 'Dad, watch this!' stops. Be at the beach while it lasts.", + "They won't remember the toys. They'll remember the trip.", + "You've got about 18 summers with them. How many are left?", + "The years are short even when the days feel long. Go make memories.", + "No wifi, no work, no excuses — just you and the kids and the ocean.", + "The best gift you can give your kids isn't stuff. It's you, fully present.", + "Your kids don't want more money. They want more of you.", + "Kids stay free. Your excuse just left the building.", + "The trip costs $249. Their faces at the beach? Priceless. Go.", + "Fuck the savings goal — your kids are growing up RIGHT NOW.", + "Stop promising them a trip 'someday.' Someday, they're grown.", + "Don't be the parent who was always 'too busy.' Be the one who went.", + "Give them a childhood worth remembering. Book the trip." + ], + "husband_buys": [ + "When's the last time you took her somewhere just because? Fix that.", + "She married you for a life, not a to-do list. Give her the trip.", + "Flowers wilt. A trip to Mexico she'll talk about for years.", + "She holds it all together. Give her five days where she holds nothing.", + "'Busy' isn't a love language. A beach in Cancun is.", + "She doesn't want a bigger TV. She wants a sunset with you.", + "Be the husband she brags about, not the one she defends.", + "Give her one week where she's the girlfriend again, not just the mom.", + "Surprise her tonight. Watch her whole week change.", + "$249 to be her hero. The cheapest grand gesture you'll ever make.", + "Stop saying 'someday, babe.' Someday is breaking her heart. Book it.", + "Fuck the excuses, man. Make her feel chosen again.", + "Be the reason she smiles, not the reason she sighs. Book the trip." + ], + "man_to_man": [ + "Listen man, happy wife, easy life. It's $29. You know what to do.", + "Bro, the trips you take with her are the only stuff you'll remember.", + "Real talk: she's not asking for much. She's asking for YOU. Book it.", + "Trust me, brother — 'I should've taken her' is a bad place to end up.", + "Man to man: the new toys don't fix it. Time with her does.", + "I waited too long once. Don't make my mistake, man. Go now.", + "Be the husband other husbands get compared to. Start with this trip.", + "Man, it's $29 a month. That's not a decision, that's a layup. Book it.", + "Quit overthinking it, man. Book the trip, kiss your wife, you're welcome.", + "Look me in the eye, brother — book the trip. That's the advice.", + "You'll be a legend at home for $29. That's the move, brother." + ], + "wife_to_wife_husband": [ + "Girl, stop waiting for him to plan it. Send him the link and say book it.", + "He will never surprise you if you keep waiting. Plant the idea, take the trip.", + "Tell your man: $29 a month or you are going without him. Watch how fast he books.", + "You do not beg for the trip, babe. You hand him the certificate and smile.", + "Hun, he would spend $29 on wings. Make him spend it on YOU.", + "Stop hinting. Send him this and let the price do the talking.", + "Girl, a happy you is a happy house. Make him understand that is $29.", + "Remember when you two were fun? Five days, no kids, get that back.", + "Be his girlfriend again for a week, not just his teammate in chaos.", + "You need one trip where you are not mom, you are his date.", + "Babe, you deserve to be chased again. Go where he has nothing to do but you.", + "Trade the laundry pile for a beach with your man. You have earned it.", + "Get him off the couch and onto a beach. You will remember why you said yes.", + "You hold that whole house together. Let HIM hold your bag to the pool.", + "Girl, you have given everyone everything. Take five days back.", + "Stop being the only one who never gets a break. Book it for both of you.", + "You are allowed to want a vacation with your husband. Say it. Then go.", + "Fuck it, girl, make him take you. It is $29. He will survive.", + "Quit waiting on him to find the time. Find it for him. Book it.", + "Tell him it is $29 and non negotiable. Pack his bag. Go." + ], + "wife_to_wife_kids": [ + "Girl, the kids are growing too fast. Make the memory before it is gone.", + "Take them to the ocean while they still want to hold your hand.", + "You are tired because you never stop. A family trip is the reset, kids stay free.", + "Five days where dinner is handled and you actually enjoy your own kids.", + "Stop dreaming of the family vacation. It is $29 a month. Just book it, hun.", + "You will not remember the messy house. You will remember their faces at the beach.", + "All inclusive means YOU get a break too, mama. Order the drink. Sit down.", + "A vacation where you are not also the cook, maid, and planner? Book it.", + "The resort keeps the kids busy. You keep the cocktail. Everybody wins.", + "You deserve a week where the only thing you carry is sunscreen.", + "Mom guilt? Nah. Giving your kids the ocean is the opposite of guilt. Go.", + "Girl, blink and they are teenagers who do not want to come. Go NOW.", + "The little kid beach phase is short. Do not waste it on someday.", + "One day they are grown and gone. Make the summers count while they are little.", + "You have a handful of summers left with them this small. Spend one in paradise.", + "Fuck it, girls, load up the kids and GO. Free for them, $29 for you.", + "Stop overthinking the family trip. Pack the snacks, book the beach. Done.", + "Quit waiting for the perfect time to bond with your kids. There is not one. Go.", + "Make the memory now, mama. They are growing as you scroll.", + "Group chat, listen up: kids free, $29 a month, five days. We are GOING." + ], + "price_29_month": [ + "$29 a month for a trip she'll remember the rest of her life. Do the math, Scrooge.", + "You spend $29 on stuff you forget by Friday. Spend it on something she'll never forget.", + "Less than a dollar a day to be the husband she brags about forever.", + "$29 a month. That's the price of being unforgettable.", + "Your coffee habit costs more than this. And she won't remember your latte.", + "$29/month buys her a sunset she'll still talk about at 80.", + "You won't even feel the $29. She'll feel the trip for a lifetime.", + "Don't be the guy who saved $29 and lost the moment.", + "Scrooge math: $29/month now, priceless memory forever.", + "No big lump sum. Just $29/month — and a memory that lasts forever.", + "$29 a month is the new 'I love you.' Set it and forget it — she never will.", + "Fuck it — it's $29. Make her year. Book the trip.", + "It's twenty-nine bucks, man. Give her the memory she'll never forget." + ] +} \ No newline at end of file diff --git a/scripts/wan-s2v-chain.ts b/scripts/wan-s2v-chain.ts new file mode 100644 index 0000000..b551f86 --- /dev/null +++ b/scripts/wan-s2v-chain.ts @@ -0,0 +1,188 @@ +/** + * Chain-render chunks 1..10 on Vast Inst 1. + * + * Each chunk N seeds from the LAST FRAME of chunk N-1's video, instead of + * restarting from the same still keyframe. This is the standard fix for + * inter-chunk flicker/identity drift in chunked diffusion lip-sync. + * + * npx tsx scripts/wan-s2v-chain.ts + * + * Chunk 0 already exists (rendered from keyframe-husband.jpg). This script + * extracts last frame of each chunk locally, scp's the PNG to Inst 1, + * submits the workflow, polls, downloads, repeat. + * + * Final chunk (10) uses a "smile + relaxed close-out" prompt so the ad + * ends on a smile beat. + */ +import { execSync } from 'child_process' +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const HOST = process.env.VAST_INST1_URL || 'http://120.238.149.205:30339' +const TOKEN = process.env.VAST_INST1_TOKEN || '985953dfaf5180b1e93e268f051bfbcfc7336df03dd765a0ad642f9b0586d89c' +const AUTH = 'Basic ' + Buffer.from(`vastai:${TOKEN}`).toString('base64') + +const CHUNKS_DIR = '/home/na/ai-management-dashboard/public/videos/ugc/chunks' +const FRAMES_DIR = '/home/na/ai-management-dashboard/public/videos/ugc/chain-frames' +if (!existsSync(FRAMES_DIR)) mkdirSync(FRAMES_DIR, { recursive: true }) + +const BASE_PROMPT = 'A 34-year-old woman with sun-kissed freckled skin and messy beach hair sits on a wicker chair at a luxury Mexican resort. Golden hour sunlight. Soft natural smile. She talks casually to the camera in a conspiratorial way, as if sharing a secret. Subtle head movements, natural lip-sync, slight eye glances. Caribbean ocean and palm trees softly blurred in background.' +const FINAL_PROMPT = 'A 34-year-old woman with sun-kissed freckled skin and messy beach hair sits on a wicker chair at a luxury Mexican resort. Golden hour sunlight. She finishes speaking, breaks into a warm, genuine, relaxed smile, eyes softening, tiny nod. Caribbean ocean and palm trees softly blurred in background.' +const NEG_PROMPT = 'low quality, blurry, distorted face, deformed mouth, weird lip-sync, plastic skin, oversaturated, watermark, text, logo, ugly, 6 fingers, extra limbs' + +function buildWorkflow(refImage: string, audio: string, prompt: string, filenamePrefix: string) { + return { + '1': { class_type: 'UnetLoaderGGUF', inputs: { unet_name: 'Wan2.2-S2V-14B-Q8_0.gguf' } }, + '2': { class_type: 'CLIPLoader', inputs: { clip_name: 'umt5_xxl_fp8_e4m3fn_scaled.safetensors', type: 'wan' } }, + '3': { class_type: 'VAELoader', inputs: { vae_name: 'Wan2_1_VAE_fp32.safetensors' } }, + '4': { class_type: 'AudioEncoderLoader', inputs: { audio_encoder_name: 'wav2vec2-large-xlsr-53-english/pytorch_model.bin' } }, + '5': { class_type: 'LoadImage', inputs: { image: refImage } }, + '6': { class_type: 'LoadAudio', inputs: { audio } }, + '7': { class_type: 'AudioEncoderEncode', inputs: { audio_encoder: ['4', 0], audio: ['6', 0] } }, + '8': { class_type: 'CLIPTextEncode', inputs: { clip: ['2', 0], text: prompt } }, + '9': { class_type: 'CLIPTextEncode', inputs: { clip: ['2', 0], text: NEG_PROMPT } }, + '10': { class_type: 'WanSoundImageToVideo', inputs: { + positive: ['8', 0], negative: ['9', 0], vae: ['3', 0], + width: 480, height: 832, length: 81, batch_size: 1, + audio_encoder_output: ['7', 0], + ref_image: ['5', 0], + } }, + '11': { class_type: 'KSampler', inputs: { + model: ['1', 0], + seed: 42, steps: 15, cfg: 6.0, + sampler_name: 'uni_pc', scheduler: 'simple', + positive: ['10', 0], negative: ['10', 1], + latent_image: ['10', 2], denoise: 1.0, + } }, + '12': { class_type: 'VAEDecode', inputs: { samples: ['11', 0], vae: ['3', 0] } }, + '13': { class_type: 'VHS_VideoCombine', inputs: { + images: ['12', 0], audio: ['6', 0], + frame_rate: 24, loop_count: 0, + filename_prefix: filenamePrefix, + format: 'video/h264-mp4', + pix_fmt: 'yuv420p', crf: 19, + save_metadata: true, pingpong: false, save_output: true, + } }, + } +} + +async function extractLastFrame(chunkIdx: number): Promise { + const src = `${CHUNKS_DIR}/chunk_${chunkIdx}_00001-audio.mp4` + const out = `${FRAMES_DIR}/chain-last-${chunkIdx}.png` + // -sseof -0.05 grabs last frame + execSync(`ffmpeg -y -sseof -0.1 -i "${src}" -vsync 0 -q:v 2 -update 1 -frames:v 1 "${out}" 2>/dev/null`) + if (!existsSync(out)) throw new Error(`failed to extract last frame of chunk ${chunkIdx}`) + console.log(` extracted last frame → ${out}`) + return out +} + +async function uploadRef(localPath: string, remoteName: string) { + // Use ComfyUI /upload/image endpoint via multipart + const form = new FormData() + const fileBuf = await import('fs').then(m => m.readFileSync(localPath)) + const blob = new Blob([fileBuf], { type: 'image/png' }) + form.append('image', blob, remoteName) + form.append('overwrite', 'true') + const res = await fetch(`${HOST}/upload/image`, { method: 'POST', headers: { Authorization: AUTH }, body: form as any }) + if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`) + console.log(` uploaded ${remoteName}`) +} + +async function safeFetch(url: string, init?: any, retries = 5): Promise { + let lastErr: any + for (let attempt = 0; attempt < retries; attempt++) { + try { return await fetch(url, init) } + catch (e) { lastErr = e; await new Promise(r => setTimeout(r, 5000 * (attempt + 1))) } + } + throw lastErr +} + +async function submitAndWait(workflow: any, label: string): Promise<{ filename: string; subfolder?: string; type?: string }> { + const clientId = `hi2b-chain-${Date.now()}` + const submitRes = await safeFetch(`${HOST}/prompt`, { + method: 'POST', + headers: { Authorization: AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: workflow, client_id: clientId }), + }) + if (!submitRes.ok) throw new Error(`submit ${label}: HTTP ${submitRes.status}: ${await submitRes.text()}`) + const submitJson = await submitRes.json() as any + const promptId = submitJson.prompt_id + console.log(` ${label} queued: ${promptId}`) + + let attempts = 0 + while (attempts < 360) { // 30 min max per chunk + attempts++ + await new Promise(r => setTimeout(r, 5000)) + let histRes: Response + try { histRes = await safeFetch(`${HOST}/history/${promptId}`, { headers: { Authorization: AUTH } }) } + catch { continue } + if (!histRes.ok) continue + const hist = await histRes.json() as any + const entry = hist[promptId] + if (!entry) { + if (attempts % 6 === 0) console.log(` [${attempts * 5}s] still queued/running...`) + continue + } + const status = entry.status || {} + if (status.status_str === 'success' || (entry.outputs && Object.keys(entry.outputs).length > 0)) { + const node13 = entry.outputs?.['13'] + const files = (node13?.gifs || node13?.videos || node13?.images || []) as any[] + // Prefer the -audio.mp4 variant + const audioFile = files.find(f => f.filename?.includes('-audio')) || files[0] + if (!audioFile) throw new Error(`${label}: no output files`) + console.log(` ${label} complete in ~${attempts * 5}s → ${audioFile.filename}`) + return audioFile + } + if (status.status_str === 'error') { + throw new Error(`${label} errored: ${JSON.stringify(status).slice(0, 400)}`) + } + } + throw new Error(`${label} timed out after 30 min`) +} + +async function downloadOutput(file: { filename: string; subfolder?: string; type?: string }, destPath: string) { + const url = `${HOST}/view?filename=${encodeURIComponent(file.filename)}&subfolder=${encodeURIComponent(file.subfolder || '')}&type=${file.type || 'output'}` + const res = await fetch(url, { headers: { Authorization: AUTH } }) + if (!res.ok) throw new Error(`download ${file.filename}: HTTP ${res.status}`) + const buf = Buffer.from(await res.arrayBuffer()) + writeFileSync(destPath, buf) + console.log(` saved → ${destPath} (${(buf.length / 1024 / 1024).toFixed(2)} MB)`) +} + +async function main() { + const START = parseInt(process.env.START_IDX || '1', 10) + const END = parseInt(process.env.END_IDX || '10', 10) + console.log(`chain range: ${START}..${END}`) + for (let i = START; i <= END; i++) { + console.log(`\n=== chunk ${i} (seeded from chunk ${i - 1} last frame) ===`) + + const localFrame = await extractLastFrame(i - 1) + const remoteImage = `chain-seed-${i}.png` + await uploadRef(localFrame, remoteImage) + + const prompt = i === 10 ? FINAL_PROMPT : BASE_PROMPT + const wf = buildWorkflow(remoteImage, `chunk-${i}.mp3`, prompt, `chain_${i}`) + const file = await submitAndWait(wf, `chunk-${i}`) + + // Always download the -audio variant + const audioName = file.filename.includes('-audio') + ? file.filename + : file.filename.replace(/\.mp4$/, '-audio.mp4') + const dest = `${CHUNKS_DIR}/chain_${i}-audio.mp4` + await downloadOutput({ filename: audioName, subfolder: file.subfolder, type: file.type }, dest) + } + + // Copy chunk 0 into the chain set (it's already correct) + execSync(`cp "${CHUNKS_DIR}/chunk_0_00001-audio.mp4" "${CHUNKS_DIR}/chain_0-audio.mp4"`) + + // Stitch + console.log('\n=== stitching ===') + const listPath = `${CHUNKS_DIR}/chain-list.txt` + let list = '' + for (let i = 0; i <= 10; i++) list += `file 'chain_${i}-audio.mp4'\n` + writeFileSync(listPath, list) + const finalOut = '/home/na/ai-management-dashboard/public/videos/ugc/wan-s2v-FULL-chained.mp4' + execSync(`ffmpeg -y -f concat -safe 0 -i "${listPath}" -c copy "${finalOut}"`, { stdio: 'inherit' }) + console.log(`\n✓ done → ${finalOut}`) +} + +main().catch(e => { console.error('FATAL:', e); process.exit(1) }) diff --git a/scripts/wan-s2v-extend-test.ts b/scripts/wan-s2v-extend-test.ts new file mode 100644 index 0000000..2a94eec --- /dev/null +++ b/scripts/wan-s2v-extend-test.ts @@ -0,0 +1,151 @@ +/** + * Wan2.2-S2V Extend-chain test on Inst 1 — the CORRECT multi-chunk architecture. + * + * Unlike the earlier flickery approach (11 separate prompts, each restarting + * from a still ref image), this uses ONE workflow: + * WanSoundImageToVideo → KSampler → latent_0 + * WanSoundImageToVideoExtend(video_latent=latent_0) → KSampler → latent_1 + * WanSoundImageToVideoExtend(video_latent=latent_1) → KSampler → latent_2 + * Each Extend carries the prior segment's full latent forward, so motion is + * continuous at the latent level. 3 segments ≈ 10s, 2 seams to inspect. + * + * npx tsx scripts/wan-s2v-extend-test.ts + */ +import { writeFileSync, existsSync, mkdirSync } from 'fs' + +const HOST = process.env.INST1_URL || 'http://localhost:18890' +const JSON_HEADERS = { 'Content-Type': 'application/json' } + +const REF_IMAGE = 'keyframe-v2.jpg' +const AUDIO = 'test10s.mp3' +const WIDTH = 480 +const HEIGHT = 832 +const SEG_LEN = 81 // frames per segment (~3.375s @ 24fps) +const FPS = 24 + +const PROMPT = + 'A 34-year-old woman with sun-kissed freckled skin and messy beach hair sits on a wicker chair on the terrace of a luxury Mexican resort. Golden hour sunlight. She talks casually and warmly to the camera, as if telling a friend a secret. Subtle natural head movements, natural lip-sync, slight eye glances. Turquoise Caribbean ocean and palm trees softly blurred in the background.' +const NEG_PROMPT = + 'low quality, blurry, distorted face, deformed mouth, weird lip-sync, plastic skin, oversaturated, watermark, text, logo, ugly, 6 fingers, extra limbs' + +// shared loader + conditioning nodes +const WORKFLOW: Record = { + '1': { class_type: 'UnetLoaderGGUF', inputs: { unet_name: 'Wan2.2-S2V-14B-Q8_0.gguf' } }, + '2': { class_type: 'CLIPLoader', inputs: { clip_name: 'umt5_xxl_fp8_e4m3fn_scaled.safetensors', type: 'wan' } }, + '3': { class_type: 'VAELoader', inputs: { vae_name: 'Wan2_1_VAE_fp32.safetensors' } }, + '4': { class_type: 'AudioEncoderLoader', inputs: { audio_encoder_name: 'wav2vec2-large-xlsr-53-english/pytorch_model.bin' } }, + '5': { class_type: 'LoadImage', inputs: { image: REF_IMAGE } }, + '6': { class_type: 'LoadAudio', inputs: { audio: AUDIO } }, + '7': { class_type: 'AudioEncoderEncode', inputs: { audio_encoder: ['4', 0], audio: ['6', 0] } }, + '8': { class_type: 'CLIPTextEncode', inputs: { clip: ['2', 0], text: PROMPT } }, + '9': { class_type: 'CLIPTextEncode', inputs: { clip: ['2', 0], text: NEG_PROMPT } }, + + // ---- segment 0: WanSoundImageToVideo ---- + '10': { class_type: 'WanSoundImageToVideo', inputs: { + positive: ['8', 0], negative: ['9', 0], vae: ['3', 0], + width: WIDTH, height: HEIGHT, length: SEG_LEN, batch_size: 1, + audio_encoder_output: ['7', 0], ref_image: ['5', 0], + } }, + '11': { class_type: 'KSampler', inputs: { + model: ['1', 0], seed: 42, steps: 15, cfg: 6.0, + sampler_name: 'uni_pc', scheduler: 'simple', + positive: ['10', 0], negative: ['10', 1], latent_image: ['10', 2], denoise: 1.0, + } }, + + // ---- segment 1: WanSoundImageToVideoExtend (carries latent_0) ---- + '12': { class_type: 'WanSoundImageToVideoExtend', inputs: { + positive: ['8', 0], negative: ['9', 0], vae: ['3', 0], + length: SEG_LEN, video_latent: ['11', 0], + audio_encoder_output: ['7', 0], ref_image: ['5', 0], + } }, + '13': { class_type: 'KSampler', inputs: { + model: ['1', 0], seed: 42, steps: 15, cfg: 6.0, + sampler_name: 'uni_pc', scheduler: 'simple', + positive: ['12', 0], negative: ['12', 1], latent_image: ['12', 2], denoise: 1.0, + } }, + + // ---- segment 2: WanSoundImageToVideoExtend (carries latent_1) ---- + '14': { class_type: 'WanSoundImageToVideoExtend', inputs: { + positive: ['8', 0], negative: ['9', 0], vae: ['3', 0], + length: SEG_LEN, video_latent: ['13', 0], + audio_encoder_output: ['7', 0], ref_image: ['5', 0], + } }, + '15': { class_type: 'KSampler', inputs: { + model: ['1', 0], seed: 42, steps: 15, cfg: 6.0, + sampler_name: 'uni_pc', scheduler: 'simple', + positive: ['14', 0], negative: ['14', 1], latent_image: ['14', 2], denoise: 1.0, + } }, + + // ---- decode all three, join, combine ---- + '16': { class_type: 'VAEDecode', inputs: { samples: ['11', 0], vae: ['3', 0] } }, + '17': { class_type: 'VAEDecode', inputs: { samples: ['13', 0], vae: ['3', 0] } }, + '18': { class_type: 'VAEDecode', inputs: { samples: ['15', 0], vae: ['3', 0] } }, + '19': { class_type: 'ImageBatchMulti', inputs: { + inputcount: 3, image_1: ['16', 0], image_2: ['17', 0], image_3: ['18', 0], + } }, + '20': { class_type: 'VHS_VideoCombine', inputs: { + images: ['19', 0], audio: ['6', 0], + frame_rate: FPS, loop_count: 0, + filename_prefix: 'hi2b_s2v_extend', + format: 'video/h264-mp4', pix_fmt: 'yuv420p', crf: 19, + save_metadata: true, pingpong: false, save_output: true, + } }, +} + +async function main() { + const clientId = `hi2b-s2vext-${Date.now()}` + console.log(`Submitting S2V Extend-chain test (3 segments, client=${clientId})`) + const submitRes = await fetch(`${HOST}/prompt`, { + method: 'POST', headers: JSON_HEADERS, + body: JSON.stringify({ prompt: WORKFLOW, client_id: clientId }), + }) + if (!submitRes.ok) { console.error(`HTTP ${submitRes.status}: ${await submitRes.text()}`); process.exit(1) } + const submitJson = (await submitRes.json()) as any + const promptId = submitJson.prompt_id + console.log(`✓ queued: ${promptId}`) + if (submitJson.node_errors && Object.keys(submitJson.node_errors).length > 0) { + console.error('NODE ERRORS:', JSON.stringify(submitJson.node_errors, null, 2)) + process.exit(1) + } + + let attempts = 0 + while (attempts < 480) { // 40 min ceiling + attempts++ + await new Promise(r => setTimeout(r, 5000)) + let hist: any + try { + const r = await fetch(`${HOST}/history/${promptId}`) + if (!r.ok) continue + hist = await r.json() + } catch { continue } + const entry = hist[promptId] + if (!entry) { + if (attempts % 6 === 0) console.log(` [${attempts * 5}s] running...`) + continue + } + const status = entry.status || {} + if (status.status_str === 'success' || Object.keys(entry.outputs || {}).length > 0) { + const node20 = entry.outputs?.['20'] + const files = (node20?.gifs || node20?.videos || node20?.images || []) as any[] + const file = files.find(f => f.filename?.includes('-audio')) || files[0] + if (!file) { console.error('no outputs:', JSON.stringify(entry.outputs).slice(0, 600)); process.exit(1) } + console.log(`✓ completed in ~${attempts * 5}s → ${file.filename}`) + const url = `${HOST}/view?filename=${encodeURIComponent(file.filename)}&subfolder=${encodeURIComponent(file.subfolder || '')}&type=${file.type || 'output'}` + const buf = Buffer.from(new Uint8Array(await fetch(url).then(r => r.arrayBuffer()))) + const outDir = 'public/videos/ugc' + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) + const dest = `${outDir}/s2v-extend-test.mp4` + writeFileSync(dest, buf) + console.log(`✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB)`) + return + } + if (status.status_str === 'error') { + console.error('EXECUTION ERROR:', JSON.stringify(status, null, 2).slice(0, 2000)) + process.exit(1) + } + } + console.error('Timed out') + process.exit(1) +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/wan-s2v-render.ts b/scripts/wan-s2v-render.ts new file mode 100644 index 0000000..1b5538d --- /dev/null +++ b/scripts/wan-s2v-render.ts @@ -0,0 +1,142 @@ +/** + * Render a UGC talking-head clip on Vast Inst 1 via Wan2.2-S2V (ComfyUI native). + * + * Submits a workflow that takes: + * - one reference image (keyframe-husband.jpg) + * - one audio file (miranda-v2-husband.mp3) + * and outputs an MP4 with synced lip movement. + * + * npx tsx scripts/wan-s2v-render.ts [--start-sec 0] [--end-sec 5] + * + * Currently fixed to first ~3.4s clip (length=81 @ 24fps) for the first test. + */ +import { writeFileSync, existsSync, mkdirSync } from 'fs' +import { execSync } from 'child_process' + +const HOST = process.env.VAST_INST1_URL || 'http://120.238.149.205:30339' +const TOKEN = process.env.VAST_INST1_TOKEN || '985953dfaf5180b1e93e268f051bfbcfc7336df03dd765a0ad642f9b0586d89c' +const AUTH = 'Basic ' + Buffer.from(`vastai:${TOKEN}`).toString('base64') + +const PROMPT = 'A 34-year-old woman with sun-kissed freckled skin and messy beach hair sits on a wicker chair at a luxury Mexican resort. Golden hour sunlight. Soft natural smile. She talks casually to the camera in a conspiratorial way, as if sharing a secret. Subtle head movements, natural lip-sync, slight eye glances. Caribbean ocean and palm trees softly blurred in background.' +const NEG_PROMPT = 'low quality, blurry, distorted face, deformed mouth, weird lip-sync, plastic skin, oversaturated, watermark, text, logo, ugly, 6 fingers, extra limbs' + +const WORKFLOW = { + // 1) Model + '1': { class_type: 'UnetLoaderGGUF', inputs: { unet_name: 'Wan2.2-S2V-14B-Q8_0.gguf' } }, + // 2) CLIP (umt5 for Wan) + '2': { class_type: 'CLIPLoader', inputs: { clip_name: 'umt5_xxl_fp8_e4m3fn_scaled.safetensors', type: 'wan' } }, + // 3) VAE + '3': { class_type: 'VAELoader', inputs: { vae_name: 'Wan2_1_VAE_fp32.safetensors' } }, + // 4) Audio encoder (wav2vec2) + '4': { class_type: 'AudioEncoderLoader', inputs: { audio_encoder_name: 'wav2vec2-large-xlsr-53-english/pytorch_model.bin' } }, + // 5) Reference image + '5': { class_type: 'LoadImage', inputs: { image: 'keyframe-husband.jpg' } }, + // 6) Audio file + '6': { class_type: 'LoadAudio', inputs: { audio: 'miranda-v2-husband.mp3' } }, + // 7) Audio embeds + '7': { class_type: 'AudioEncoderEncode', inputs: { audio_encoder: ['4', 0], audio: ['6', 0] } }, + // 8) Positive prompt + '8': { class_type: 'CLIPTextEncode', inputs: { clip: ['2', 0], text: PROMPT } }, + // 9) Negative prompt + '9': { class_type: 'CLIPTextEncode', inputs: { clip: ['2', 0], text: NEG_PROMPT } }, + // 10) Wan S2V conditioning + latent (9:16 vertical, ~3.4 sec at 24fps) + '10': { class_type: 'WanSoundImageToVideo', inputs: { + positive: ['8', 0], negative: ['9', 0], vae: ['3', 0], + width: 480, height: 832, length: 81, batch_size: 1, + audio_encoder_output: ['7', 0], + ref_image: ['5', 0], + } }, + // 11) KSampler + '11': { class_type: 'KSampler', inputs: { + model: ['1', 0], + seed: 42, steps: 25, cfg: 6.0, + sampler_name: 'uni_pc', scheduler: 'simple', + positive: ['10', 0], negative: ['10', 1], + latent_image: ['10', 2], denoise: 1.0, + } }, + // 12) Decode + '12': { class_type: 'VAEDecode', inputs: { samples: ['11', 0], vae: ['3', 0] } }, + // 13) Save as MP4 with audio + '13': { class_type: 'VHS_VideoCombine', inputs: { + images: ['12', 0], + audio: ['6', 0], + frame_rate: 24, + loop_count: 0, + filename_prefix: 'hi2b_ugc', + format: 'video/h264-mp4', + pix_fmt: 'yuv420p', + crf: 19, + save_metadata: true, + pingpong: false, + save_output: true, + } }, +} + +async function main() { + const clientId = `hi2b-${Date.now()}` + console.log(`Submitting workflow (client_id=${clientId})...`) + + const submitRes = await fetch(`${HOST}/prompt`, { + method: 'POST', + headers: { Authorization: AUTH, 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: WORKFLOW, client_id: clientId }), + }) + if (!submitRes.ok) { + console.error(`HTTP ${submitRes.status}: ${await submitRes.text()}`) + process.exit(1) + } + const submitJson = await submitRes.json() as any + const promptId = submitJson.prompt_id + console.log(`✓ queued: ${promptId}`) + + // Poll history + let attempts = 0 + while (attempts < 360) { // up to 30 min + attempts++ + await new Promise(r => setTimeout(r, 5000)) + const histRes = await fetch(`${HOST}/history/${promptId}`, { headers: { Authorization: AUTH } }) + if (!histRes.ok) continue + const hist = await histRes.json() as any + const entry = hist[promptId] + if (!entry) { + // still in queue; check queue status periodically + if (attempts % 6 === 0) { + const qRes = await fetch(`${HOST}/queue`, { headers: { Authorization: AUTH } }) + const q = await qRes.json() as any + const running = q?.queue_running?.length || 0 + const pending = q?.queue_pending?.length || 0 + console.log(` [${attempts*5}s] still in queue (running=${running} pending=${pending})`) + } + continue + } + const status = entry.status || {} + if (status.status_str === 'success' || (entry.outputs && Object.keys(entry.outputs).length > 0)) { + console.log(`✓ completed in ~${attempts * 5}s`) + const outputs = entry.outputs || {} + console.log('Outputs:', JSON.stringify(outputs, null, 2).slice(0, 500)) + // Find any video file in node 13's output + const node13 = outputs['13'] + const files = (node13?.gifs || node13?.videos || node13?.images || []) as any[] + if (files.length === 0) { console.error('No output files'); process.exit(1) } + const file = files[0] + const url = `${HOST}/view?filename=${encodeURIComponent(file.filename)}&subfolder=${encodeURIComponent(file.subfolder || '')}&type=${file.type || 'output'}` + console.log(`Downloading ${url}`) + const vidRes = await fetch(url, { headers: { Authorization: AUTH } }) + const buf = Buffer.from(await vidRes.arrayBuffer()) + const outDir = 'public/videos/ugc' + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) + const dest = `${outDir}/wan-s2v-${promptId.slice(0, 8)}.mp4` + writeFileSync(dest, buf) + console.log(`✓ saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB)`) + return + } + if (status.status_str === 'error' || status.completed === false && entry.outputs === undefined) { + // error + console.error('Error in execution:', JSON.stringify(status, null, 2).slice(0, 600)) + process.exit(1) + } + } + console.error('Timed out after 30 min') +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/scripts/zai-test-video.ts b/scripts/zai-test-video.ts new file mode 100644 index 0000000..f372c24 --- /dev/null +++ b/scripts/zai-test-video.ts @@ -0,0 +1,96 @@ +/** + * One-shot z.ai (CogVideoX-3) test video. + * + * GEMINI_API_KEY irrelevant here; uses Z_AI_API_KEY from env. + * + * npx tsx scripts/zai-test-video.ts + * + * Creates an async generation task, polls every 5s until SUCCESS or FAIL, + * downloads the resulting mp4 to public/videos/zai/.mp4. + */ +import { writeFileSync, mkdirSync, existsSync } from 'fs' +import { join } from 'path' + +const KEY = process.env.Z_AI_API_KEY +if (!KEY) { console.error('Z_AI_API_KEY missing from env'); process.exit(1) } + +const OUT_DIR = join(__dirname, '..', 'public', 'videos', 'zai') +if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }) + +const PROMPT = + 'Aerial drone shot at golden hour over a luxury Mexican beachfront resort. ' + + 'Crystal-clear turquoise Caribbean water meeting a long crescent of white sand beach. ' + + 'A horizon-edge infinity pool reflects the warm orange sky. Palm trees sway in the breeze, ' + + 'casting long shadows. Modern white curved-architecture suites and thatched palapa cabanas. ' + + 'Camera slowly pulls back revealing the full coastline. Cinematic, hyper-realistic, ' + + '8K detail, Condé Nast Traveler photography style. No people, no logos, no text.' + +const BODY = { + model: 'cogvideox-3', + prompt: PROMPT, + quality: 'quality', + with_audio: true, + size: '1920x1080', + fps: 30, +} + +async function main() { + console.log('1) Creating generation task on z.ai (cogvideox-3, 1920x1080, audio on)...') + const createRes = await fetch('https://api.z.ai/api/paas/v4/videos/generations', { + method: 'POST', + headers: { + Authorization: `Bearer ${KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(BODY), + }) + if (!createRes.ok) { + console.error(` HTTP ${createRes.status}:`, await createRes.text()) + process.exit(1) + } + const createJson = await createRes.json() as any + const id = createJson.id || createJson.request_id || createJson.task_id + if (!id) { + console.error(' No task id in response:', JSON.stringify(createJson).slice(0, 400)) + process.exit(1) + } + console.log(` Task created. id=${id}`) + + console.log('2) Polling result every 5s...') + const POLL_URL = `https://api.z.ai/api/paas/v4/async-result/${id}` + let attempts = 0 + let videoUrl: string | null = null + while (attempts < 120) { // up to 10 min + attempts++ + await new Promise(r => setTimeout(r, 5000)) + const pollRes = await fetch(POLL_URL, { headers: { Authorization: `Bearer ${KEY}` } }) + if (!pollRes.ok) { + console.warn(` [poll ${attempts}] HTTP ${pollRes.status}`) + continue + } + const data = await pollRes.json() as any + const status = data.task_status || data.status + process.stdout.write(` [poll ${attempts}] status=${status}\n`) + if (status === 'SUCCESS') { + const vr = data.video_result || data.videoResult + videoUrl = Array.isArray(vr) ? (vr[0]?.url) : (vr?.url || vr?.[0]?.url) + if (!videoUrl) { console.error(' SUCCESS but no video url:', JSON.stringify(data).slice(0, 400)); process.exit(1) } + break + } + if (status === 'FAIL' || status === 'FAILED') { + console.error(' Task failed:', JSON.stringify(data).slice(0, 400)); process.exit(1) + } + } + if (!videoUrl) { console.error(' Timed out after 10 min'); process.exit(1) } + + console.log(`3) Downloading mp4 from ${videoUrl.slice(0, 80)}...`) + const dlRes = await fetch(videoUrl) + if (!dlRes.ok) { console.error(' Download failed:', dlRes.status); process.exit(1) } + const buf = Buffer.from(await dlRes.arrayBuffer()) + const dest = join(OUT_DIR, `${id}.mp4`) + writeFileSync(dest, buf) + console.log(` Saved ${dest} (${(buf.length / 1024 / 1024).toFixed(2)} MB)`) + console.log('\nDone.') +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/scripts/zai-ugc-ad.ts b/scripts/zai-ugc-ad.ts new file mode 100644 index 0000000..2196dda --- /dev/null +++ b/scripts/zai-ugc-ad.ts @@ -0,0 +1,275 @@ +/** + * 20-second UGC-style ad via chained Vidu2-Start-End on z.ai. + * + * Pipeline: + * 1. Generate 5 keyframes (Gemini image) with iPhone-UGC aesthetic prompts + * 2. For each consecutive pair (F1→F2, F2→F3, F3→F4, F4→F5), call + * vidu2-start-end for a 5-sec interpolation + * 3. Concatenate the 4 clips with ffmpeg = 20-sec ad + * 4. Save final to public/videos/zai/ugc-ad-.mp4 + * + * GEMINI_API_KEY + Z_AI_API_KEY required. + * ffmpeg must be on PATH. + */ +import { writeFileSync, mkdirSync, existsSync } from 'fs' +import { join } from 'path' +import { execSync } from 'child_process' + +const GEMINI_KEY = process.env.GEMINI_API_KEY +const Z_KEY = process.env.Z_AI_API_KEY +if (!GEMINI_KEY) { console.error('GEMINI_API_KEY missing'); process.exit(1) } +if (!Z_KEY) { console.error('Z_AI_API_KEY missing'); process.exit(1) } + +const STAMP = Date.now() +const CONCEPT = process.env.CONCEPT || 'ugc-v1' // stable concept folder so keyframes are cached +const OUT_DIR = join(__dirname, '..', 'public', 'videos', 'zai') +const KF_DIR = join(OUT_DIR, CONCEPT, 'keyframes') +const CLIP_DIR = join(OUT_DIR, CONCEPT, `clips-${STAMP}`) +mkdirSync(KF_DIR, { recursive: true }) +mkdirSync(CLIP_DIR, { recursive: true }) + +// ─── 5 keyframes telling a 20-sec POV story ──────────────────────────── +// Note: prompts deliberately mention "shot on iPhone", "vertical 9:16", +// "handheld", "harsh natural light", "amateur", "candid". Avoid words like +// "cinematic", "8K", "professional photography", "Hasselblad" — those push +// the model toward the AI-perfect aesthetic we're trying to avoid. + +interface Beat { + slug: string + prompt: string + bridgePrompt: string // for the vidu2-start-end transition AFTER this frame +} + +const BEATS: Beat[] = [ + { + slug: 'f1-couch-discovery', + prompt: + 'Vertical 9:16 iPhone selfie photo. A 32-year-old woman in a grey oversized sweatshirt is sitting cross-legged on a brown couch in her living room. She holds up her iPhone toward the camera, showing the screen which displays a travel booking website with a big orange "$29/month — Mexico All-Inclusive" headline. Her facial expression is wide-eyed disbelief, mouth slightly open. Natural messy hair in a low bun. Warm late-afternoon window light coming from the left, slightly harsh on her face. Imperfect framing — her head is a bit off-center, slightly low. Slight motion blur on the phone screen. iPhone front-camera quality, not professional, raw and candid. No filters. Sweatshirt has a small coffee stain. TikTok aesthetic, looks like a real reaction video. No text overlay, no logo, no watermark.', + bridgePrompt: + 'The woman zooms in on the phone screen, leaning forward slightly. The phone tilts toward camera revealing the website details. Subtle iPhone handheld motion. 4 seconds.', + }, + { + slug: 'f2-phone-closeup', + prompt: + 'Vertical 9:16 iPhone close-up photo of a woman\'s hand holding an iPhone. The phone screen clearly shows a confirmation page reading "Payment confirmed!" with a smaller "Certificate: MPV-2026-7K9XQ4". Background is blurred out — you can see her grey sweatshirt and a corner of brown couch behind. Her thumb is hovering over the screen. Natural window light, slight reflection on the glass screen. Hand has a small simple gold ring. Imperfect focus on the phone, slight haze. Raw iPhone POV, not staged, not edited. No text overlay added in post.', + bridgePrompt: + 'The phone tilts slightly. The screen brightness changes as the user moves it. Soft handheld motion. Background blur stays consistent. 4 seconds.', + }, + { + slug: 'f3-airport-window', + prompt: + 'Vertical 9:16 iPhone POV photo from an airport gate window seat. Through the glass: a parked passenger airplane on the tarmac, "PUERTO VALLARTA" partially visible on a flight info screen reflected in the glass. The reflection of the woman\'s face is faintly visible in the window — same woman in the grey sweatshirt, but now wearing pink sunglasses pushed up on her head. Her iced coffee cup sits on the windowsill, slightly out of focus in the foreground. Harsh morning sun glare creating a slight rainbow lens flare in the upper right. Aircraft engine slightly overexposed. Phone-camera quality, not edited, slight chromatic aberration on the bright spots. Raw and candid, no filter applied.', + bridgePrompt: + 'The plane starts moving slowly on the tarmac. Light shifts subtly through the window. The iced coffee cup remains in foreground. Gentle handheld iPhone wobble. 4 seconds.', + }, + { + slug: 'f3b-hotel-arrival', + prompt: + 'Vertical 9:16 iPhone POV photo, the same 32-year-old woman with damp wind-tousled hair from the flight, walking through the open-air lobby of a luxury Mexican resort. She is dragging a small black carry-on suitcase, wearing the pink sunglasses now on her face, her grey sweatshirt swapped for a white linen shirt. Behind her: traditional Mexican tile floors, a wooden front desk with a "Bienvenidos" wooden sign, palm trees just visible through an open archway with bright blue sky beyond. Bellhop in white shirt slightly out of focus in background. Late afternoon golden light streaming in through the archway, casting a long warm rectangle on the tile floor. Slightly tilted handheld phone framing. Her free hand is up holding the phone, you can see her thumb in the lower corner of the frame. iPhone Live Photo aesthetic, raw and candid, no filter.', + bridgePrompt: + 'Slight forward motion as she walks. Soft light bouncing off the tile floor. Background bellhop turns slightly. Subtle iPhone handheld bob. 4 seconds.', + }, + { + slug: 'f4-pool-legs', + prompt: + 'Vertical 9:16 iPhone POV photo looking down at sun-tanned legs and a slightly worn-out turquoise swimsuit, lying on a white pool lounger. Beyond the legs: the edge of an infinity pool meeting the bright turquoise Caribbean ocean horizon. Two palm trees on the right edge of the frame. Pool deck slightly wet, small puddle reflecting the sky. A half-drunk piña colada in a plastic cup with a wilting paper umbrella sits to the right. Harsh midday tropical sunlight, slight overexposure on the legs and pool deck — classic iPhone "burned out" highlights when shooting toward bright water. Some sunscreen smudges visible on one knee. Authentic vacation POV photo, the kind people text to a friend. No filter, no editing.', + bridgePrompt: + 'Light wind ripples the pool surface. The paper umbrella in the cocktail flutters slightly. Faint shadow movement from palm fronds. iPhone resting on towel, near-static frame. 4 seconds.', + }, + { + slug: 'f5-sunset-cocktail', + prompt: + 'Vertical 9:16 iPhone photo. The same woman, now slightly sunburned and wearing a flowy white linen shirt over the swimsuit, sits at an open-air beachfront bar at sunset. She holds a margarita up toward the camera in a "cheers" pose with a small content smile. Behind her, the Mexican Pacific ocean is painted in vivid pink, coral, and gold. A wooden bar with hanging string lights barely turned on. Sand visible under bare feet. Her hair is now down and wind-tousled, slightly damp at the ends. The cocktail glass has slight condensation. The lighting is golden-hour warm but slightly underexposed on her face (classic iPhone "the sky stole all my exposure" problem). Slight grain. Looks like a real friend\'s vacation photo, not an ad. No filter overlay, no text, no logos.', + bridgePrompt: '', // last frame, no bridge needed + }, +] + +const PUBLIC_BASE = 'https://gw.724care.com/ugc-stage' // we'll upload here + +async function genKeyframe(beat: Beat): Promise { + const dest = join(KF_DIR, `${beat.slug}.jpg`) + const resized = join(KF_DIR, `${beat.slug}-720.jpg`) + if (existsSync(resized)) { + console.log(` ✓ ${beat.slug} (cached 720, skipping gen)`) + return resized + } + if (existsSync(dest)) { + console.log(` ✓ ${beat.slug} (cached raw, resizing to 720x1280)`) + execSync(`convert "${dest}" -resize 1280x720^ -gravity center -extent 1280x720 -quality 92 "${resized}"`) + return resized + } + // Try preview-3.x first, fall back + const MODELS = [ + 'gemini-3.1-flash-image-preview', + 'gemini-3-pro-image-preview', + 'gemini-2.5-flash-image-preview', + ] + for (const model of MODELS) { + try { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${GEMINI_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: beat.prompt }] }], + generationConfig: { responseModalities: ['IMAGE'] }, + }), + signal: AbortSignal.timeout(120000), + } + ) + if (!res.ok) { console.warn(` [${model}] HTTP ${res.status}`); continue } + const data = await res.json() as any + const parts = data?.candidates?.[0]?.content?.parts || [] + for (const p of parts) { + const inline = p.inlineData || p.inline_data + if (inline?.data) { + const buf = Buffer.from(inline.data, 'base64') + writeFileSync(dest, buf) + execSync(`convert "${dest}" -resize 1280x720^ -gravity center -extent 1280x720 -quality 92 "${resized}"`) + console.log(` ✓ ${beat.slug} (${(buf.length / 1024).toFixed(0)}kb via ${model}, resized)`) + return resized + } + } + } catch (e: any) { console.warn(` [${model}] error: ${e?.message}`) } + } + throw new Error(`All Gemini models failed for ${beat.slug}`) +} + +async function vidu2StartEnd(startUrl: string, endUrl: string, prompt: string, outPath: string): Promise { + console.log(` → vidu2-start-end: ${startUrl.slice(-30)} → ${endUrl.slice(-30)}`) + const createRes = await fetch('https://api.z.ai/api/paas/v4/videos/generations', { + method: 'POST', + headers: { Authorization: `Bearer ${Z_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'vidu2-start-end', + image_url: [startUrl, endUrl], + prompt, + duration: 4, + size: '1280x720', // vidu2 only supports landscape per docs + movement_amplitude: 'auto', + }), + }) + if (!createRes.ok) { + throw new Error(`Create failed: HTTP ${createRes.status} ${await createRes.text()}`) + } + const j = await createRes.json() as any + const id = j.id || j.request_id || j.task_id + if (!id) throw new Error('No id in create response: ' + JSON.stringify(j).slice(0, 300)) + + const videoUrl = await pollAndGetUrl(id) + await downloadVideo(videoUrl, outPath) +} + +async function vidu2StartEndFallback(startUrl: string, endUrl: string, prompt: string, outPath: string): Promise { + const createRes = await fetch('https://api.z.ai/api/paas/v4/videos/generations', { + method: 'POST', + headers: { Authorization: `Bearer ${Z_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'vidu2-start-end', + image_url: [startUrl, endUrl], + prompt, duration: 5, + size: '1280x720', + movement_amplitude: 'auto', + }), + }) + if (!createRes.ok) throw new Error(`Fallback failed: ${await createRes.text()}`) + const j = await createRes.json() as any + const id = j.id || j.request_id || j.task_id + const videoUrl = await pollAndGetUrl(id) + await downloadVideo(videoUrl, outPath) +} + +async function pollAndGetUrl(id: string): Promise { + for (let i = 0; i < 120; i++) { + await new Promise(r => setTimeout(r, 5000)) + const r = await fetch(`https://api.z.ai/api/paas/v4/async-result/${id}`, { + headers: { Authorization: `Bearer ${Z_KEY}` }, + }) + if (!r.ok) continue + const d = await r.json() as any + const status = d.task_status || d.status + if (i % 4 === 0) process.stdout.write(` [${i}] ${status}\n`) + if (status === 'SUCCESS') { + const vr = d.video_result || d.videoResult + const url = Array.isArray(vr) ? vr[0]?.url : (vr?.url || vr?.[0]?.url) + if (!url) throw new Error('SUCCESS but no url') + return url + } + if (status === 'FAIL' || status === 'FAILED') throw new Error('Task FAILED: ' + JSON.stringify(d).slice(0, 300)) + } + throw new Error('Poll timeout (10 min)') +} + +async function downloadVideo(url: string, dest: string): Promise { + // z.ai CDN cert is expired — use curl -k as workaround + execSync(`curl -sS -o "${dest}" -k "${url}"`) + const stat = require('fs').statSync(dest) + console.log(` saved ${dest} (${(stat.size / 1024 / 1024).toFixed(2)}mb)`) +} + +async function uploadToGw(localPath: string, remoteName: string): Promise { + // Upload keyframe to gw.724care.com so z.ai can fetch it + execSync( + `rsync -avz -e "ssh -o HostKeyAlias=gw.724care.com" "${localPath}" ` + + `root@69.30.227.98:/var/www/html/ugc-stage/${remoteName} >/dev/null 2>&1` + ) + return `${PUBLIC_BASE}/${remoteName}` +} + +async function main() { + // Ensure remote staging dir + console.log('0) Creating remote staging dir on gw...') + execSync(`ssh -o HostKeyAlias=gw.724care.com root@69.30.227.98 'mkdir -p /var/www/html/ugc-stage' >/dev/null 2>&1`) + + // 1) Generate keyframes + console.log('\n1) Generating 5 iPhone-UGC keyframes (Gemini)...') + const keyframes: string[] = [] + for (const beat of BEATS) { + const path = await genKeyframe(beat) + keyframes.push(path) + } + + // 2) Upload keyframes + console.log('\n2) Uploading keyframes to gw for z.ai fetch...') + const publicUrls: string[] = [] + for (let i = 0; i < keyframes.length; i++) { + const url = await uploadToGw(keyframes[i], `kf-${STAMP}-${i}.jpg`) + publicUrls.push(url) + console.log(` ${url}`) + } + + // 3) Run vidu2-start-end between each consecutive pair + console.log(`\n3) Running ${publicUrls.length - 1} vidu2-start-end interpolations ($${((publicUrls.length - 1) * 0.2).toFixed(2)} total)...`) + const clipPaths: string[] = [] + for (let i = 0; i < publicUrls.length - 1; i++) { + const out = join(CLIP_DIR, `clip-${i}.mp4`) + console.log(`\n Clip ${i + 1}/4: ${BEATS[i].slug} → ${BEATS[i + 1].slug}`) + await vidu2StartEnd(publicUrls[i], publicUrls[i + 1], BEATS[i].bridgePrompt, out) + clipPaths.push(out) + } + + // 4) ffmpeg concat + console.log('\n4) Concatenating with ffmpeg...') + const listFile = join(CLIP_DIR, 'list.txt') + writeFileSync(listFile, clipPaths.map(p => `file '${p}'`).join('\n')) + const finalOut = join(OUT_DIR, `ugc-ad-${STAMP}.mp4`) + execSync( + `ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${finalOut}" 2>&1 | tail -3`, + { stdio: 'inherit' } + ) + + // 5) Upload final + console.log('\n5) Uploading final to gw...') + const finalRemote = `ugc-ad-${STAMP}.mp4` + execSync( + `rsync -avz -e "ssh -o HostKeyAlias=gw.724care.com" "${finalOut}" ` + + `root@69.30.227.98:/var/www/html/${finalRemote}`, + { stdio: 'inherit' } + ) + + console.log(`\n✅ DONE — https://gw.724care.com/${finalRemote}`) + console.log(` Keyframes also at /ugc-stage/kf-${STAMP}-{0..4}.jpg`) +} + +main().catch(err => { console.error('\nFATAL:', err.message || err); process.exit(1) }) diff --git a/server.ts b/server.ts index 0865ae9..6c2eb0c 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { Server } from 'socket.io'; import next from 'next'; const dev = process.env.NODE_ENV !== 'production'; -const currentPort = 3000; +const currentPort = parseInt(process.env.PORT || '3000', 10); const hostname = '127.0.0.1'; // Custom server with Socket.IO integration diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..d0c8b64 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,96 @@ +import { Plane, Heart, Shield, Sparkles } from 'lucide-react' +import Link from 'next/link' +import type { Metadata } from 'next' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +export const metadata: Metadata = { + title: 'About Us — Mexico Paradise Vacations', + description: 'How Mexico Paradise Vacations works, who we are, and why our all-inclusive Mexico certificates cost less than direct booking.', +} + +export default function AboutPage() { + return ( +
+
+
+ +
+ +
+ Mexico Paradise Vacations + + Back to Home +
+
+ +
+

About Mexico Paradise Vacations

+

Helping families take the Mexico trip they keep putting off.

+ +
+
+

What we do

+

Mexico Paradise Vacations sells pre-paid all-inclusive vacation certificates to luxury beachfront resorts in Cancun, Cabo, Riviera Maya, and Puerto Vallarta. Each certificate covers 5 days and 4 nights for two adults, with kids under 12 staying free. Once you pay, the certificate is yours to redeem for any available dates in the next 18 months.

+
+ +
+

Why it's so much cheaper than booking direct

+

This is the question almost everyone asks. The honest answer:

+

Resorts don't make their money on the room — they make it on the food, drinks, spa, excursions, and bar tabs you run while you're there. An empty room earns them nothing. So they partner with companies like ours to fill rooms in advance at deeply discounted certificate prices, knowing the rest of your spend will more than cover the gap. It's the same logic behind off-season cruise pricing and last-minute hotel apps — just structured as a pre-paid certificate so the resort can plan inventory.

+

It's real, it's legal, and the resorts you stay at are the same five-star properties listed on every travel site at full price.

+
+ +
+

What you actually pay

+
+

Two payment options:

+
    +
  • ${PAYMENT_CONFIG.monthlyPrice}/month for {PAYMENT_CONFIG.totalMonths} months (total ${PAYMENT_CONFIG.totalPrice})
  • +
  • ${PAYMENT_CONFIG.oneTimePrice} one-time (save ${PAYMENT_CONFIG.totalPrice - PAYMENT_CONFIG.oneTimePrice} vs the monthly plan)
  • +
+

Both options include the full 5-day / 4-night all-inclusive stay for two adults plus kids under 12.

+
+

You can book your travel dates immediately after the first payment. You don't have to wait to finish the payment plan.

+
+ +
+

The money-back guarantee

+

If for any reason you're not satisfied within 30 days of purchase, we'll refund you in full — no questions asked. We can offer this because, frankly, almost nobody asks for it. The math just works.

+
+ +
+

Who we are

+

We're a small US-based team that's been in the travel-certificate business for years. We partner directly with resort groups and pass the savings to families who don't want to overpay for a vacation they deserve. We're not a marketplace, not a points scheme, and not a timeshare — just a pre-paid certificate for a real trip.

+
+ +
+

Where to go from here

+
    +
  • +
    FAQ
    +
    What's included, how to book, refund details.
    +
  • +
  • +
    Real reviews
    +
    What real travelers say about their trips.
    +
  • +
  • +
    Claim a certificate
    +
    5 days, 4 nights, all-inclusive — from ${PAYMENT_CONFIG.monthlyPrice}/mo.
    +
  • +
  • +
    Privacy & Terms
    +
    Privacy Policy · Terms of Service
    +
  • +
+
+ +
+

Contact

+

Questions? Reach out at support@hi2b.com or call 888-602-2424.

+
+
+
+
+ ) +} diff --git a/src/app/admin/affiliates/page.tsx b/src/app/admin/affiliates/page.tsx new file mode 100644 index 0000000..2015178 --- /dev/null +++ b/src/app/admin/affiliates/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Loader2 } from "lucide-react"; + +interface Affiliate { + id: string | number; + name: string; + email: string; + code: string; + commission_rate: number; + status: string; + sales_count: number; + total_earned: number; + total_paid: number; +} + +function affiliateStatusBadge(status: string) { + const s = status?.toLowerCase(); + const styles: Record = { + active: "bg-green-100 text-green-800 border-green-200", + pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + suspended: "bg-red-100 text-red-800 border-red-200", + }; + return ( + + {status} + + ); +} + +export default function AffiliatesPage() { + const [affiliates, setAffiliates] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + + async function fetchAffiliates() { + setLoading(true); + try { + const res = await fetch("/api/admin/affiliates"); + if (res.ok) { + const json = await res.json(); + setAffiliates(json.affiliates || []); + } + } catch (err) { + console.error("Failed to fetch affiliates:", err); + } finally { + setLoading(false); + } + } + + useEffect(() => { + fetchAffiliates(); + }, []); + + async function updateStatus(id: string | number, status: string) { + setActionLoading(id); + try { + const res = await fetch("/api/admin/affiliates", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, status }), + }); + if (res.ok) { + await fetchAffiliates(); + } + } catch (err) { + console.error("Failed to update affiliate:", err); + } finally { + setActionLoading(null); + } + } + + function getActions(affiliate: Affiliate) { + const s = affiliate.status?.toLowerCase(); + const isLoading = actionLoading === affiliate.id; + + if (s === "pending") { + return ( + + ); + } + if (s === "active") { + return ( + + ); + } + if (s === "suspended") { + return ( + + ); + } + return null; + } + + return ( +
+ + + Affiliates + + +
+ + + + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 9 }).map((_, j) => ( + + ))} + + )) + ) : affiliates.length > 0 ? ( + affiliates.map((aff) => ( + + + + + + + + + + + + )) + ) : ( + + + + )} + +
NameEmailCodeCommissionStatusSalesTotal EarnedTotal PaidActions
+
+
{aff.name}{aff.email}{aff.code}{(aff.commission_rate * 100).toFixed(0)}%{affiliateStatusBadge(aff.status)}{aff.sales_count} + ${Number(aff.total_earned).toLocaleString("en-US", { minimumFractionDigits: 2 })} + + ${Number(aff.total_paid).toLocaleString("en-US", { minimumFractionDigits: 2 })} + {getActions(aff)}
+ No affiliates found +
+
+
+
+
+ ); +} diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx new file mode 100644 index 0000000..66630a4 --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + PieChart, + Pie, + Cell, +} from "recharts"; + +interface AnalyticsData { + salesByLP: { name: string; count: number }[]; + salesBySource: { name: string; count: number }[]; + salesByAffiliate: { name: string; count: number }[]; + revenueOverTime: { date: string; revenue: number }[]; + funnel: { + pageViews: number; + ebookDownloads: number; + signups: number; + paidCustomers: number; + }; +} + +const PIE_COLORS = ["#ea580c", "#f97316", "#fb923c", "#fdba74", "#fed7aa", "#9a3412", "#c2410c"]; + +const dateRanges = [ + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, + { label: "All", days: 0 }, +]; + +export default function AnalyticsPage() { + const [data, setData] = useState(null); + const [days, setDays] = useState(30); + const [loading, setLoading] = useState(true); + + const fetchAnalytics = useCallback(async () => { + setLoading(true); + try { + const params = days > 0 ? `?days=${days}` : ""; + const res = await fetch(`/api/admin/analytics${params}`); + if (res.ok) setData(await res.json()); + } catch (err) { + console.error("Failed to fetch analytics:", err); + } finally { + setLoading(false); + } + }, [days]); + + useEffect(() => { + fetchAnalytics(); + }, [fetchAnalytics]); + + const funnelSteps = data?.funnel + ? [ + { label: "Page Views", value: data.funnel.pageViews }, + { label: "Ebook Downloads", value: data.funnel.ebookDownloads }, + { label: "Signups", value: data.funnel.signups }, + { label: "Paid", value: data.funnel.paidCustomers }, + ] + : []; + + const maxFunnel = funnelSteps.length > 0 ? Math.max(funnelSteps[0].value, 1) : 1; + + return ( +
+ {/* Date range selector */} +
+ Period: + {dateRanges.map((range) => ( + + ))} +
+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+ + + ))} +
+ ) : ( +
+ {/* Revenue Over Time */} + + + Revenue Over Time + + +
+ {data?.revenueOverTime?.length ? ( + + + + + + [`$${value.toLocaleString()}`, "Revenue"]} + /> + + + + ) : ( +
+ No data available +
+ )} +
+
+
+ + {/* Sales by Landing Page - Horizontal Bar */} + + + Sales by Landing Page (Top 10) + + +
+ {data?.salesByLP?.length ? ( + + + + + + + + + + ) : ( +
+ No data available +
+ )} +
+
+
+ + {/* Sales by Source - Pie Chart */} + + + Sales by Source + + +
+ {data?.salesBySource?.length ? ( + + + + `${name} (${(percent * 100).toFixed(0)}%)` + } + > + {data.salesBySource.map((_, index) => ( + + ))} + + + + + ) : ( +
+ No data available +
+ )} +
+
+
+ + {/* Conversion Funnel */} + + + Conversion Funnel + + +
+ {funnelSteps.length > 0 ? ( + funnelSteps.map((step, i) => { + const pct = maxFunnel > 0 ? (step.value / maxFunnel) * 100 : 0; + const dropoff = + i > 0 && funnelSteps[i - 1].value > 0 + ? ((step.value / funnelSteps[i - 1].value) * 100).toFixed(1) + : null; + return ( +
+
+ {step.label} +
+ + {step.value.toLocaleString()} + + {dropoff && ( + + ({dropoff}%) + + )} +
+
+
+
+
+
+ ); + }) + ) : ( +
+ No funnel data available +
+ )} +
+ + +
+ )} +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..e353160 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import Link from "next/link"; +import { + LayoutDashboard, + ShoppingCart, + Users, + BarChart3, + UserCheck, + Wallet, + LogOut, + Menu, + X, + Plane, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { href: "/admin", label: "Dashboard", icon: LayoutDashboard }, + { href: "/admin/sales", label: "Sales", icon: ShoppingCart }, + { href: "/admin/leads", label: "Leads", icon: Users }, + { href: "/admin/analytics", label: "Analytics", icon: BarChart3 }, + { href: "/admin/affiliates", label: "Affiliates", icon: UserCheck }, + { href: "/admin/payouts", label: "Payouts", icon: Wallet }, +]; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [authed, setAuthed] = useState(false); + const [checking, setChecking] = useState(true); + const [sidebarOpen, setSidebarOpen] = useState(false); + + // Skip auth check for login page + const isLoginPage = pathname === "/admin/login"; + + useEffect(() => { + if (isLoginPage) { + setChecking(false); + setAuthed(true); + return; + } + + async function checkAuth() { + try { + const res = await fetch("/api/admin/auth/me"); + if (!res.ok) throw new Error("Unauthorized"); + setAuthed(true); + } catch { + router.replace("/admin/login"); + } finally { + setChecking(false); + } + } + checkAuth(); + }, [isLoginPage, router]); + + async function handleLogout() { + document.cookie = "admin_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + router.replace("/admin/login"); + } + + if (isLoginPage) { + return <>{children}; + } + + if (checking) { + return ( +
+
+
+

Verifying access...

+
+
+ ); + } + + if (!authed) return null; + + return ( +
+ {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+ +

+ {navItems.find((n) => + n.href === "/admin" + ? pathname === "/admin" + : pathname.startsWith(n.href) + )?.label || "Admin"} +

+
+ +
{children}
+
+
+ ); +} diff --git a/src/app/admin/leads/page.tsx b/src/app/admin/leads/page.tsx new file mode 100644 index 0000000..53e8457 --- /dev/null +++ b/src/app/admin/leads/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +interface Lead { + id: string | number; + email: string; + name: string; + source_lp: string; + utm_source: string; + created_at: string; +} + +interface LeadsResponse { + data: Lead[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export default function LeadsPage() { + const [data, setData] = useState(null); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [loading, setLoading] = useState(true); + const limit = 25; + + const fetchLeads = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (search) params.set("search", search); + const res = await fetch(`/api/admin/leads?${params}`); + if (res.ok) setData(await res.json()); + } catch (err) { + console.error("Failed to fetch leads:", err); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { + fetchLeads(); + }, [fetchLeads]); + + function handleSearch(e: React.FormEvent) { + e.preventDefault(); + setPage(1); + setSearch(searchInput); + } + + return ( +
+ + +
+ Ebook Leads +
+
+ + setSearchInput(e.target.value)} + className="pl-9 w-64" + /> +
+ +
+
+
+ +
+ + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + ) : data?.data?.length ? ( + data.data.map((lead) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
IDEmailNameSource LPUTM SourceDate
+
+
{lead.id}{lead.email}{lead.name || "-"}{lead.source_lp || "-"}{lead.utm_source || "-"} + {lead.created_at ? new Date(lead.created_at).toLocaleDateString() : "-"} +
+ No leads found +
+
+ + {data && data.totalPages > 1 && ( +
+

+ Showing {(data.page - 1) * data.limit + 1} to{" "} + {Math.min(data.page * data.limit, data.total)} of {data.total} results +

+
+ + + Page {data.page} of {data.totalPages} + + +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 0000000..52bf234 --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Plane, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await fetch("/api/admin/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Invalid credentials"); + } + + router.push("/admin"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + } + + return ( +
+ + +
+ +
+ Mexico Paradise Vacations + Admin Portal +
+ +
+ {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+
+
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..fce8bf0 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + DollarSign, + ShoppingCart, + TrendingUp, + Users, + Eye, + Percent, +} from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, +} from "recharts"; + +interface Stats { + totalSales: number; + totalRevenue: number; + mrr: number; + totalLeads: number; + totalViews: number; + conversionRate: number; +} + +interface AnalyticsData { + revenueOverTime: { date: string; revenue: number }[]; + salesByLP: { name: string; count: number }[]; +} + +const kpiConfig = [ + { key: "totalSales" as const, label: "Total Sales", icon: ShoppingCart, format: "number" }, + { key: "totalRevenue" as const, label: "Total Revenue", icon: DollarSign, format: "currency" }, + { key: "mrr" as const, label: "MRR", icon: TrendingUp, format: "currency" }, + { key: "totalLeads" as const, label: "Total Leads", icon: Users, format: "number" }, + { key: "totalViews" as const, label: "Page Views", icon: Eye, format: "number" }, + { key: "conversionRate" as const, label: "Conversion Rate", icon: Percent, format: "percent" }, +]; + +function formatValue(value: number, format: string): string { + switch (format) { + case "currency": + return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + case "percent": + return `${value.toFixed(1)}%`; + default: + return value.toLocaleString(); + } +} + +export default function AdminDashboardPage() { + const [stats, setStats] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchData() { + try { + const [statsRes, analyticsRes] = await Promise.all([ + fetch("/api/admin/stats"), + fetch("/api/admin/analytics"), + ]); + if (statsRes.ok) setStats(await statsRes.json()); + if (analyticsRes.ok) setAnalytics(await analyticsRes.json()); + } catch (err) { + console.error("Failed to fetch dashboard data:", err); + } finally { + setLoading(false); + } + } + fetchData(); + }, []); + + if (loading) { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+ + + ))} +
+
+ ); + } + + return ( +
+ {/* KPI Cards */} +
+ {kpiConfig.map((kpi) => ( + + +
+
+

{kpi.label}

+

+ {stats ? formatValue(stats[kpi.key], kpi.format) : "--"} +

+
+
+ +
+
+
+
+ ))} +
+ + {/* Charts */} +
+ + + Revenue Over Time + + +
+ {analytics?.revenueOverTime?.length ? ( + + + + + + [`$${value.toLocaleString()}`, "Revenue"]} + /> + + + + ) : ( +
+ No data available +
+ )} +
+
+
+ + + + Sales by Landing Page + + +
+ {analytics?.salesByLP?.length ? ( + + + + + + + + + + ) : ( +
+ No data available +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/admin/payouts/page.tsx b/src/app/admin/payouts/page.tsx new file mode 100644 index 0000000..41cd886 --- /dev/null +++ b/src/app/admin/payouts/page.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Loader2, Plus } from "lucide-react"; + +interface Payout { + id: string | number; + affiliate_name: string; + amount: number; + method: string; + reference: string; + status: string; + created_at: string; +} + +interface Affiliate { + id: string | number; + name: string; + email: string; +} + +const METHODS = [ + { value: "paypal", label: "PayPal" }, + { value: "bank", label: "Bank Transfer" }, + { value: "check", label: "Check" }, + { value: "zelle", label: "Zelle" }, +]; + +function payoutStatusBadge(status: string) { + const s = status?.toLowerCase(); + const styles: Record = { + completed: "bg-green-100 text-green-800 border-green-200", + pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + failed: "bg-red-100 text-red-800 border-red-200", + processing: "bg-blue-100 text-blue-800 border-blue-200", + }; + return ( + + {status} + + ); +} + +export default function PayoutsPage() { + const [payouts, setPayouts] = useState([]); + const [affiliates, setAffiliates] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [showForm, setShowForm] = useState(false); + + // Form state + const [affiliateId, setAffiliateId] = useState(""); + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState(""); + const [reference, setReference] = useState(""); + const [formError, setFormError] = useState(""); + const [formSuccess, setFormSuccess] = useState(""); + + async function fetchData() { + setLoading(true); + try { + const [payoutsRes, affiliatesRes] = await Promise.all([ + fetch("/api/admin/payouts"), + fetch("/api/admin/affiliates"), + ]); + if (payoutsRes.ok) { + const json = await payoutsRes.json(); + setPayouts(json.payouts || json.data || []); + } + if (affiliatesRes.ok) { + const json = await affiliatesRes.json(); + setAffiliates(json.affiliates || []); + } + } catch (err) { + console.error("Failed to fetch payouts:", err); + } finally { + setLoading(false); + } + } + + useEffect(() => { + fetchData(); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setFormError(""); + setFormSuccess(""); + + if (!affiliateId || !amount || !method) { + setFormError("Please fill in all required fields."); + return; + } + + setSubmitting(true); + try { + const res = await fetch("/api/admin/payouts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + affiliate_id: affiliateId, + amount: parseFloat(amount), + method, + reference, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to create payout"); + } + + setFormSuccess("Payout created successfully."); + setAffiliateId(""); + setAmount(""); + setMethod(""); + setReference(""); + setShowForm(false); + await fetchData(); + } catch (err: unknown) { + setFormError(err instanceof Error ? err.message : "Failed to create payout"); + } finally { + setSubmitting(false); + } + } + + return ( +
+ {/* Create Payout */} + + +
+ Create Payout + +
+
+ {showForm && ( + +
+ {formError && ( +
+ {formError} +
+ )} + {formSuccess && ( +
+ {formSuccess} +
+ )} +
+
+ + +
+
+ + setAmount(e.target.value)} + required + /> +
+
+ + +
+
+ + setReference(e.target.value)} + /> +
+
+ +
+
+ )} +
+ + {/* Payouts Table */} + + + Payout History + + +
+ + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + + )) + ) : payouts.length > 0 ? ( + payouts.map((payout) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
IDAffiliateAmountMethodReferenceStatusDate
+
+
{payout.id}{payout.affiliate_name} + ${Number(payout.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })} + {payout.method}{payout.reference || "-"}{payoutStatusBadge(payout.status)} + {payout.created_at ? new Date(payout.created_at).toLocaleDateString() : "-"} +
+ No payouts found +
+
+
+
+
+ ); +} diff --git a/src/app/admin/sales/page.tsx b/src/app/admin/sales/page.tsx new file mode 100644 index 0000000..1b90881 --- /dev/null +++ b/src/app/admin/sales/page.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +interface Sale { + id: string | number; + full_name: string; + email: string; + amount: number; + payment_status: string; + source_lp: string; + utm_source: string; + affiliate_name: string | null; + created_at: string; + certificate_number: string | null; + certificate_expires: string | null; + payment_plan_months: number | null; + payments_made: number | null; +} + +function validityCell(expires: string | null) { + if (!expires) return -; + const exp = new Date(expires); + const days = Math.ceil((exp.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + const date = exp.toLocaleDateString(); + let cls = "text-green-700"; + let label = `${days}d left`; + if (days < 0) { cls = "text-red-700 font-medium"; label = `Expired ${Math.abs(days)}d ago`; } + else if (days <= 30) { cls = "text-red-600"; } + else if (days <= 90) { cls = "text-amber-600"; } + return ( +
+ {date} + {label} +
+ ); +} + +function paymentsCell(made: number | null, plan: number | null) { + const m = Number(made ?? 0); + const p = Number(plan ?? 0); + if (!p) return {m}; + const complete = m >= p; + return ( + + {m}/{p} + + ); +} + +interface SalesResponse { + data: Sale[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +function statusBadge(status: string) { + const s = status?.toLowerCase(); + const styles: Record = { + active: "bg-green-100 text-green-800 border-green-200", + pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + failed: "bg-red-100 text-red-800 border-red-200", + completed: "bg-blue-100 text-blue-800 border-blue-200", + }; + return ( + + {status} + + ); +} + +export default function SalesPage() { + const [data, setData] = useState(null); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [loading, setLoading] = useState(true); + const limit = 25; + + const fetchSales = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (search) params.set("search", search); + const res = await fetch(`/api/admin/sales?${params}`); + if (res.ok) setData(await res.json()); + } catch (err) { + console.error("Failed to fetch sales:", err); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { + fetchSales(); + }, [fetchSales]); + + function handleSearch(e: React.FormEvent) { + e.preventDefault(); + setPage(1); + setSearch(searchInput); + } + + return ( +
+ + +
+ Sales +
+
+ + setSearchInput(e.target.value)} + className="pl-9 w-64" + /> +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 12 }).map((_, j) => ( + + ))} + + )) + ) : data?.data?.length ? ( + data.data.map((sale) => ( + + + + + + + + + + + + + + + )) + ) : ( + + + + )} + +
IDNameEmailAmountStatusCertificate #Valid UntilPaymentsSource LPUTM SourceAffiliateDate
+
+
{sale.id}{sale.full_name || "-"}{sale.email} + ${Number(sale.amount).toLocaleString("en-US", { minimumFractionDigits: 2 })} + {statusBadge(sale.payment_status)} + {sale.certificate_number ? ( + + {sale.certificate_number} + + ) : ( + - + )} + {validityCell(sale.certificate_expires)}{paymentsCell(sale.payments_made, sale.payment_plan_months)}{sale.source_lp || "-"}{sale.utm_source || "-"}{sale.affiliate_name || "-"} + {sale.created_at ? new Date(sale.created_at).toLocaleDateString() : "-"} +
+ No sales found +
+
+ + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+

+ Showing {(data.page - 1) * data.limit + 1} to{" "} + {Math.min(data.page * data.limit, data.total)} of {data.total} results +

+
+ + + Page {data.page} of {data.totalPages} + + +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/affiliate/layout.tsx b/src/app/affiliate/layout.tsx new file mode 100644 index 0000000..daa1a3f --- /dev/null +++ b/src/app/affiliate/layout.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import Link from "next/link"; +import { LayoutDashboard, Users, Wallet, LogOut, Copy, CheckCircle, Menu, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface AffiliateData { + name: string; + email: string; + referral_code: string; + commission_rate: number; + status: string; + total_earned: number; + total_paid: number; +} + +const AUTH_FREE_PATHS = ["/affiliate/login", "/affiliate/register"]; + +export default function AffiliateLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [affiliate, setAffiliate] = useState(null); + const [loading, setLoading] = useState(true); + const [copiedCode, setCopiedCode] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const isAuthPage = AUTH_FREE_PATHS.includes(pathname); + + useEffect(() => { + if (isAuthPage) { + setLoading(false); + return; + } + + async function checkAuth() { + try { + const res = await fetch("/api/affiliate/auth/me"); + if (!res.ok) { + router.push("/affiliate/login"); + return; + } + const data = await res.json(); + setAffiliate(data.affiliate); + } catch { + router.push("/affiliate/login"); + } finally { + setLoading(false); + } + } + + checkAuth(); + }, [isAuthPage, router]); + + function handleLogout() { + document.cookie = "affiliate_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + router.push("/affiliate/login"); + } + + function handleCopyCode() { + if (affiliate?.referral_code) { + navigator.clipboard.writeText(affiliate.referral_code); + setCopiedCode(true); + setTimeout(() => setCopiedCode(false), 2000); + } + } + + if (isAuthPage) { + return <>{children}; + } + + if (loading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!affiliate) { + return null; + } + + const navLinks = [ + { href: "/affiliate", label: "Dashboard", icon: LayoutDashboard }, + { href: "/affiliate/referrals", label: "Referrals", icon: Users }, + { href: "/affiliate/payouts", label: "Payouts", icon: Wallet }, + ]; + + return ( +
+ + +
+ {children} +
+
+ ); +} diff --git a/src/app/affiliate/login/page.tsx b/src/app/affiliate/login/page.tsx new file mode 100644 index 0000000..7600600 --- /dev/null +++ b/src/app/affiliate/login/page.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Users, Mail, Lock, Loader2 } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function AffiliateLoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await fetch("/api/affiliate/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Login failed. Please try again."); + return; + } + + router.push("/affiliate"); + } catch { + setError("An unexpected error occurred. Please try again."); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
+ +
+

Affiliate Portal

+

Mexico Paradise Vacations

+
+ + + + Sign In + Enter your credentials to access your affiliate dashboard + + +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + required + /> +
+
+ + +
+ +
+ Don't have an account?{" "} + + Register as an affiliate + +
+
+
+
+
+ ); +} diff --git a/src/app/affiliate/page.tsx b/src/app/affiliate/page.tsx new file mode 100644 index 0000000..8301c57 --- /dev/null +++ b/src/app/affiliate/page.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { MousePointerClick, ArrowRightLeft, Percent, DollarSign, Clock, CheckCircle, Copy, Loader2, Lightbulb } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +interface AffiliateData { + name: string; + email: string; + referral_code: string; + short_code: string | null; + commission_rate: number; + status: string; + total_earned: number; + total_paid: number; +} + +interface StatsData { + clicks: number; + conversions: number; + conversionRate: number; + totalEarned: number; + pendingAmount: number; + paidAmount: number; +} + +export default function AffiliateDashboardPage() { + const [affiliate, setAffiliate] = useState(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + + useEffect(() => { + async function fetchData() { + try { + const [meRes, statsRes] = await Promise.all([ + fetch("/api/affiliate/auth/me"), + fetch("/api/affiliate/stats"), + ]); + + if (meRes.ok) { + const meData = await meRes.json(); + setAffiliate(meData.affiliate); + } + + if (statsRes.ok) { + const statsData = await statsRes.json(); + setStats(statsData); + } + } catch { + // Errors handled by layout auth check + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + const [copiedKey, setCopiedKey] = useState(null); + function copy(text: string, key: string) { + navigator.clipboard.writeText(text); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 2000); + } + function handleCopyLink() { + if (affiliate?.referral_code) { + copy(`https://hi2b.com/lp/golden-hour?ref=${affiliate.referral_code}`, 'long'); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + const referralLink = affiliate + ? `https://hi2b.com/lp/golden-hour?ref=${affiliate.referral_code}` + : ""; + const shortLink = affiliate?.short_code + ? `https://hi2b.com/pay/${affiliate.short_code}` + : ""; + + const kpis = [ + { + label: "Clicks", + value: stats?.clicks ?? 0, + format: "number", + icon: MousePointerClick, + color: "text-blue-600", + bg: "bg-blue-50", + }, + { + label: "Conversions", + value: stats?.conversions ?? 0, + format: "number", + icon: ArrowRightLeft, + color: "text-emerald-600", + bg: "bg-emerald-50", + }, + { + label: "Conversion Rate", + value: stats?.conversionRate ?? 0, + format: "percent", + icon: Percent, + color: "text-purple-600", + bg: "bg-purple-50", + }, + { + label: "Total Earned", + value: stats?.totalEarned ?? 0, + format: "currency", + icon: DollarSign, + color: "text-teal-600", + bg: "bg-teal-50", + }, + { + label: "Pending", + value: stats?.pendingAmount ?? 0, + format: "currency", + icon: Clock, + color: "text-amber-600", + bg: "bg-amber-50", + }, + { + label: "Paid", + value: stats?.paidAmount ?? 0, + format: "currency", + icon: CheckCircle, + color: "text-green-600", + bg: "bg-green-50", + }, + ]; + + function formatValue(value: number | string | null | undefined, format: string): string { + const n = typeof value === "number" ? value : Number(value) || 0; + switch (format) { + case "currency": + return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + case "percent": + return `${n.toFixed(1)}%`; + default: + return n.toLocaleString(); + } + } + + return ( +
+ {/* Welcome Header */} +
+

+ Welcome back, {affiliate?.name ?? "Affiliate"} +

+

Here's an overview of your affiliate performance.

+
+ + {/* Short link — easy-to-text, easy-to-remember */} + {shortLink && ( + + +
+
+

+ 📲 Your Short Link — perfect for texts, IG bio, business cards +

+ + {shortLink} + +
+ +
+
+
+ )} + + {/* Long referral link box */} + + +
+
+

Full Referral Link — landing page version with tracking

+ + {referralLink} + +
+ +
+
+
+ + {/* KPI Cards */} +
+ {kpis.map((kpi) => ( + + + {kpi.label} +
+ +
+
+ +

+ {formatValue(kpi.value, kpi.format)} +

+
+
+ ))} +
+ + {/* Quick Tip */} + + +
+
+ +
+
+

Quick Tip

+

+ Share your referral link on social media, email, or your website. You earn{" "} + {affiliate?.commission_rate ?? 0}% commission on every sale! +

+
+
+
+
+
+ ); +} diff --git a/src/app/affiliate/payouts/page.tsx b/src/app/affiliate/payouts/page.tsx new file mode 100644 index 0000000..e121ca2 --- /dev/null +++ b/src/app/affiliate/payouts/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, Wallet, Inbox } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface Payout { + id: string; + amount: number; + method: string; + reference: string; + status: string; + created_at: string; +} + +const STATUS_STYLES: Record = { + pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + processing: "bg-blue-100 text-blue-800 border-blue-200", + completed: "bg-green-100 text-green-800 border-green-200", + failed: "bg-red-100 text-red-800 border-red-200", +}; + +export default function AffiliatePayoutsPage() { + const [payouts, setPayouts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchPayouts() { + try { + const res = await fetch("/api/affiliate/payouts"); + if (res.ok) { + const data = await res.json(); + setPayouts(data.payouts || []); + } + } catch { + // Error handled silently + } finally { + setLoading(false); + } + } + + fetchPayouts(); + }, []); + + function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + function formatCurrency(amount: number): string { + return `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Payouts

+

View your payout history and payment details.

+
+ + + +
+ + Payout History +
+ + {payouts.length} total payout{payouts.length !== 1 ? "s" : ""} + +
+ + {payouts.length === 0 ? ( +
+
+ +
+

No payouts yet

+

+ Keep sharing your referral link! Payouts will appear here once processed. +

+
+ ) : ( +
+ + + + Date + Amount + Method + Reference + Status + + + + {payouts.map((payout) => ( + + + {formatDate(payout.created_at)} + + + {formatCurrency(payout.amount)} + + + {payout.method} + + + {payout.reference || "—"} + + + + {payout.status.charAt(0).toUpperCase() + payout.status.slice(1)} + + + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/affiliate/referrals/page.tsx b/src/app/affiliate/referrals/page.tsx new file mode 100644 index 0000000..3a67d11 --- /dev/null +++ b/src/app/affiliate/referrals/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, Users, Inbox } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface Referral { + id: string; + commission_amount: number; + status: string; + created_at: string; + paid_at: string | null; + customer_email: string; + sale_amount: number; +} + +const STATUS_STYLES: Record = { + pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + approved: "bg-blue-100 text-blue-800 border-blue-200", + paid: "bg-green-100 text-green-800 border-green-200", + rejected: "bg-red-100 text-red-800 border-red-200", +}; + +export default function AffiliateReferralsPage() { + const [referrals, setReferrals] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchReferrals() { + try { + const res = await fetch("/api/affiliate/referrals"); + if (res.ok) { + const data = await res.json(); + setReferrals(data.referrals || []); + } + } catch { + // Error handled silently + } finally { + setLoading(false); + } + } + + fetchReferrals(); + }, []); + + function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + function formatCurrency(amount: number): string { + return `$${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Referrals

+

Track all your referral sales and commissions.

+
+ + + +
+ + Referral History +
+ + {referrals.length} total referral{referrals.length !== 1 ? "s" : ""} + +
+ + {referrals.length === 0 ? ( +
+
+ +
+

No referrals yet

+

+ Share your referral link to start earning commissions. +

+
+ ) : ( +
+ + + + Date + Customer + Sale Amount + Commission + Status + + + + {referrals.map((referral) => ( + + + {formatDate(referral.created_at)} + + + {referral.customer_email} + + + {formatCurrency(referral.sale_amount)} + + + {formatCurrency(referral.commission_amount)} + + + + {referral.status.charAt(0).toUpperCase() + referral.status.slice(1)} + + + + ))} + +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/affiliate/register/page.tsx b/src/app/affiliate/register/page.tsx new file mode 100644 index 0000000..b982b13 --- /dev/null +++ b/src/app/affiliate/register/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Users, Mail, Lock, User, Loader2, CheckCircle, Copy } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function AffiliateRegisterPage() { + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [referralCode, setReferralCode] = useState(""); + const [copied, setCopied] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + + if (password.length < 6) { + setError("Password must be at least 6 characters."); + return; + } + + setLoading(true); + + try { + const res = await fetch("/api/affiliate/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Registration failed. Please try again."); + return; + } + + setReferralCode(data.referral_code || data.affiliate?.referral_code || ""); + setSuccess(true); + } catch { + setError("An unexpected error occurred. Please try again."); + } finally { + setLoading(false); + } + } + + function handleCopyCode() { + navigator.clipboard.writeText(referralCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (success) { + return ( +
+
+ + +
+ +
+

Registration Successful!

+

+ Welcome to the Mexico Paradise Vacations affiliate program. Your referral code is: +

+ {referralCode && ( +
+ + {referralCode} + + +
+ )} + +
+
+
+
+ ); + } + + return ( +
+
+
+
+ +
+

Join Our Affiliate Program

+

Mexico Paradise Vacations

+
+ + + + Create Account + Register to start earning commissions on referrals + + +
+ {error && ( +
+ {error} +
+ )} + +
+ +
+ + setName(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10" + required + /> +
+
+ + +
+ +
+ Already have an account?{" "} + + Sign in + +
+
+
+
+
+ ); +} diff --git a/src/app/api/admin/affiliates/route.ts b/src/app/api/admin/affiliates/route.ts new file mode 100644 index 0000000..933c510 --- /dev/null +++ b/src/app/api/admin/affiliates/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' +import { getAffiliates, updateAffiliateStatus } from '@/lib/db-admin' + +export async function GET() { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const affiliates = await getAffiliates() + return NextResponse.json({ affiliates }) +} + +export async function PUT(request: NextRequest) { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { id, status } = await request.json() + if (!id || !status) return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) + + await updateAffiliateStatus(id, status) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/admin/analytics/route.ts b/src/app/api/admin/analytics/route.ts new file mode 100644 index 0000000..15cdf57 --- /dev/null +++ b/src/app/api/admin/analytics/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' +import { getSalesByLP, getSalesBySource, getSalesByAffiliate, getRevenueOverTime, getFunnelData } from '@/lib/db-admin' + +export async function GET(request: NextRequest) { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const url = new URL(request.url) + const days = parseInt(url.searchParams.get('days') || '30') + + const [salesByLP, salesBySource, salesByAffiliate, revenueOverTime, funnel] = await Promise.all([ + getSalesByLP(), + getSalesBySource(), + getSalesByAffiliate(), + getRevenueOverTime(days), + getFunnelData(days), + ]) + + return NextResponse.json({ salesByLP, salesBySource, salesByAffiliate, revenueOverTime, funnel }) +} diff --git a/src/app/api/admin/auth/login/route.ts b/src/app/api/admin/auth/login/route.ts new file mode 100644 index 0000000..9864c3c --- /dev/null +++ b/src/app/api/admin/auth/login/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { verifyPassword, createAdminToken } from '@/lib/admin-auth' + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json() + if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) + + const [rows] = await pool.execute('SELECT * FROM admin_users WHERE email = ? AND status = ?', [email, 'active']) as any[] + if (rows.length === 0) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + + const admin = rows[0] + const valid = await verifyPassword(password, admin.password_hash) + if (!valid) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + + const token = createAdminToken({ id: admin.id, email: admin.email, role: admin.role }) + const response = NextResponse.json({ success: true, user: { id: admin.id, email: admin.email, fullName: admin.full_name, role: admin.role } }) + response.cookies.set('admin_token', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 24 * 60 * 60, path: '/' }) + return response + } catch (error) { + console.error('Admin login error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/admin/auth/me/route.ts b/src/app/api/admin/auth/me/route.ts new file mode 100644 index 0000000..e0d037c --- /dev/null +++ b/src/app/api/admin/auth/me/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' + +export async function GET() { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + return NextResponse.json({ user: session }) +} diff --git a/src/app/api/admin/leads/route.ts b/src/app/api/admin/leads/route.ts new file mode 100644 index 0000000..44d2d88 --- /dev/null +++ b/src/app/api/admin/leads/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' +import { getLeads } from '@/lib/db-admin' + +export async function GET(request: NextRequest) { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const url = new URL(request.url) + const result = await getLeads({ + page: parseInt(url.searchParams.get('page') || '1'), + limit: parseInt(url.searchParams.get('limit') || '25'), + search: url.searchParams.get('search') || undefined, + }) + + return NextResponse.json(result) +} diff --git a/src/app/api/admin/payouts/route.ts b/src/app/api/admin/payouts/route.ts new file mode 100644 index 0000000..a786d46 --- /dev/null +++ b/src/app/api/admin/payouts/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' +import { getPayouts, createPayout } from '@/lib/db-admin' + +export async function GET() { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payouts = await getPayouts() + return NextResponse.json({ payouts }) +} + +export async function POST(request: NextRequest) { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { affiliate_id, amount, method, reference } = await request.json() + if (!affiliate_id || !amount) return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) + + const id = await createPayout(affiliate_id, amount, method || 'paypal', reference || '') + return NextResponse.json({ success: true, id }) +} diff --git a/src/app/api/admin/refund/route.ts b/src/app/api/admin/refund/route.ts deleted file mode 100644 index adacc5d..0000000 --- a/src/app/api/admin/refund/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { supabase } from '@/lib/supabase' -import { maverickAPI } from '@/lib/maverick' - -export async function POST(request: NextRequest) { - try { - const { paymentId, amount, reason } = await request.json() - - if (!paymentId || !amount) { - return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } - ) - } - - // Process refund with Maverick API - const refundResult = await maverickAPI.createRefund({ - paymentId, - amount: Math.round(amount * 100), // Convert to cents - reason: reason || 'Admin refund request', - }) - - if (!refundResult.success) { - console.error('Refund creation failed:', refundResult.error) - return NextResponse.json( - { error: refundResult.error || 'Refund creation failed' }, - { status: 500 } - ) - } - - // Update payment record - const { error: paymentUpdateError } = await supabase - .from('payments') - .update({ - status: 'refunded', - refund_id: refundResult.refundId, - updated_at: new Date().toISOString() - }) - .eq('transaction_id', paymentId) - - if (paymentUpdateError) { - console.error('Payment update error:', paymentUpdateError) - return NextResponse.json( - { error: 'Failed to update payment record' }, - { status: 500 } - ) - } - - // Update signup status - const { error: signupUpdateError } = await supabase - .from('signups') - .update({ - payment_status: 'refunded', - updated_at: new Date().toISOString() - }) - .eq('payment_id', paymentId) - - if (signupUpdateError) { - console.error('Signup update error:', signupUpdateError) - return NextResponse.json( - { error: 'Failed to update signup record' }, - { status: 500 } - ) - } - - return NextResponse.json({ - success: true, - refundId: refundResult.refundId, - message: 'Refund processed successfully' - }) - - } catch (error) { - console.error('Refund processing error:', error) - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/src/app/api/admin/sales/route.ts b/src/app/api/admin/sales/route.ts new file mode 100644 index 0000000..45905fa --- /dev/null +++ b/src/app/api/admin/sales/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' +import { getSales } from '@/lib/db-admin' + +export async function GET(request: NextRequest) { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const url = new URL(request.url) + const result = await getSales({ + page: parseInt(url.searchParams.get('page') || '1'), + limit: parseInt(url.searchParams.get('limit') || '25'), + status: url.searchParams.get('status') || undefined, + source_lp: url.searchParams.get('source_lp') || undefined, + utm_source: url.searchParams.get('utm_source') || undefined, + search: url.searchParams.get('search') || undefined, + }) + + return NextResponse.json(result) +} diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..27092e1 --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server' +import { getAdminSession } from '@/lib/admin-auth' +import { getKPIStats } from '@/lib/db-admin' + +export async function GET() { + const session = await getAdminSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const stats = await getKPIStats() + return NextResponse.json(stats) +} diff --git a/src/app/api/affiliate/auth/login/route.ts b/src/app/api/affiliate/auth/login/route.ts new file mode 100644 index 0000000..89c8e10 --- /dev/null +++ b/src/app/api/affiliate/auth/login/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { verifyPassword, createAffiliateToken } from '@/lib/admin-auth' + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json() + if (!email || !password) return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) + + const [rows] = await pool.execute('SELECT * FROM affiliates WHERE email = ?', [email]) as any[] + if (rows.length === 0) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + + const aff = rows[0] + if (aff.status === 'suspended') return NextResponse.json({ error: 'Account suspended' }, { status: 403 }) + + const valid = await verifyPassword(password, aff.password_hash) + if (!valid) return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + + const token = createAffiliateToken({ id: aff.id, email: aff.email, referral_code: aff.referral_code }) + const response = NextResponse.json({ success: true, referralCode: aff.referral_code }) + response.cookies.set('affiliate_token', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' }) + return response + } catch (error) { + console.error('Affiliate login error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/affiliate/auth/me/route.ts b/src/app/api/affiliate/auth/me/route.ts new file mode 100644 index 0000000..6b834ff --- /dev/null +++ b/src/app/api/affiliate/auth/me/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import pool, { ensureAffiliateShortCode } from '@/lib/db-mysql' +import { getAffiliateSession } from '@/lib/admin-auth' + +export async function GET() { + const session = await getAffiliateSession() + if (!session) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + + // Ensure this affiliate has a 2-char short_code for the /pay/XX link + let short_code: string | null = null + try { short_code = await ensureAffiliateShortCode(session.id) } catch (err) { console.error('short_code provisioning:', err) } + + const [rows] = await pool.execute( + 'SELECT id, name, email, referral_code, short_code, commission_rate, status, total_earned, total_paid, created_at FROM affiliates WHERE id = ?', + [session.id] + ) as any[] + + if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ affiliate: { ...rows[0], short_code: rows[0].short_code || short_code } }) +} diff --git a/src/app/api/affiliate/auth/register/route.ts b/src/app/api/affiliate/auth/register/route.ts new file mode 100644 index 0000000..8f324fe --- /dev/null +++ b/src/app/api/affiliate/auth/register/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { hashPassword, createAffiliateToken, generateReferralCode } from '@/lib/admin-auth' +import { sendAffiliateWelcomeEmail } from '@/lib/email' + +export async function POST(request: NextRequest) { + try { + const { name, email, password } = await request.json() + if (!name || !email || !password) return NextResponse.json({ error: 'All fields required' }, { status: 400 }) + if (password.length < 6) return NextResponse.json({ error: 'Password must be at least 6 characters' }, { status: 400 }) + + const [existing] = await pool.execute('SELECT id FROM affiliates WHERE email = ?', [email]) as any[] + if (existing.length > 0) return NextResponse.json({ error: 'Email already registered' }, { status: 409 }) + + const hash = await hashPassword(password) + let referralCode = generateReferralCode(name) + + // Ensure uniqueness + for (let i = 0; i < 10; i++) { + const [dup] = await pool.execute('SELECT id FROM affiliates WHERE referral_code = ?', [referralCode]) as any[] + if (dup.length === 0) break + referralCode = generateReferralCode(name) + } + + const [result] = await pool.execute( + `INSERT INTO affiliates (name, email, password_hash, referral_code, status) VALUES (?, ?, ?, ?, 'active')`, + [name, email, hash, referralCode] + ) as any[] + + const id = (result as any).insertId + const token = createAffiliateToken({ id, email, referral_code: referralCode }) + + try { await sendAffiliateWelcomeEmail(email, name, referralCode) } catch (e) { console.error('Affiliate email failed:', e) } + + const response = NextResponse.json({ success: true, referralCode }) + response.cookies.set('affiliate_token', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60, path: '/' }) + return response + } catch (error) { + console.error('Affiliate register error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/affiliate/payouts/route.ts b/src/app/api/affiliate/payouts/route.ts new file mode 100644 index 0000000..9112faa --- /dev/null +++ b/src/app/api/affiliate/payouts/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { getAffiliateSession } from '@/lib/admin-auth' + +export async function GET() { + const session = await getAffiliateSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const [rows] = await pool.execute( + 'SELECT * FROM affiliate_payouts WHERE affiliate_id = ? ORDER BY created_at DESC', + [session.id] + ) as any[] + + return NextResponse.json({ payouts: rows }) +} diff --git a/src/app/api/affiliate/referrals/route.ts b/src/app/api/affiliate/referrals/route.ts new file mode 100644 index 0000000..98d16d4 --- /dev/null +++ b/src/app/api/affiliate/referrals/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { getAffiliateSession } from '@/lib/admin-auth' + +export async function GET() { + const session = await getAffiliateSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const [rows] = await pool.execute( + `SELECT ar.id, ar.commission_amount, ar.status, ar.created_at, ar.paid_at, + CONCAT(LEFT(s.email, 3), '***@***') as customer_email, s.amount as sale_amount + FROM affiliate_referrals ar + JOIN signups s ON ar.signup_id = s.id + WHERE ar.affiliate_id = ? + ORDER BY ar.created_at DESC`, + [session.id] + ) as any[] + + return NextResponse.json({ referrals: rows }) +} diff --git a/src/app/api/affiliate/stats/route.ts b/src/app/api/affiliate/stats/route.ts new file mode 100644 index 0000000..8c3563f --- /dev/null +++ b/src/app/api/affiliate/stats/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { getAffiliateSession } from '@/lib/admin-auth' + +export async function GET() { + const session = await getAffiliateSession() + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const [clicks] = await pool.execute( + 'SELECT COUNT(*) as c FROM page_views WHERE referral_code = ?', [session.referral_code] + ) as any[] + + const [conversions] = await pool.execute( + 'SELECT COUNT(*) as c, COALESCE(SUM(commission_amount),0) as total FROM affiliate_referrals WHERE affiliate_id = ?', [session.id] + ) as any[] + + const [pending] = await pool.execute( + `SELECT COALESCE(SUM(commission_amount),0) as amount FROM affiliate_referrals WHERE affiliate_id = ? AND status = 'pending'`, [session.id] + ) as any[] + + const [paid] = await pool.execute( + `SELECT COALESCE(SUM(amount),0) as amount FROM affiliate_payouts WHERE affiliate_id = ? AND status = 'completed'`, [session.id] + ) as any[] + + const totalClicks = Number(clicks[0]?.c) || 0 + const totalConversions = Number(conversions[0]?.c) || 0 + + return NextResponse.json({ + clicks: totalClicks, + conversions: totalConversions, + conversionRate: totalClicks > 0 ? (totalConversions / totalClicks) * 100 : 0, + totalEarned: Number(conversions[0]?.total) || 0, + pendingAmount: Number(pending[0]?.amount) || 0, + paidAmount: Number(paid[0]?.amount) || 0, + }) +} diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..c678b07 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { generateResetToken } from '@/lib/auth-utils' +import { sendPasswordResetEmail } from '@/lib/email' + +export async function POST(request: NextRequest) { + try { + const { email } = await request.json() + if (!email) { + return NextResponse.json({ error: 'Email required' }, { status: 400 }) + } + + const [rows] = await pool.execute('SELECT id FROM signups WHERE email = ?', [email]) as any[] + + // Always return success to prevent email enumeration + if (rows.length === 0) { + return NextResponse.json({ success: true, message: 'If an account exists, a reset email has been sent.' }) + } + + const token = generateResetToken() + const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour + + await pool.execute( + 'UPDATE signups SET reset_token = ?, reset_token_expires = ? WHERE email = ?', + [token, expires.toISOString().slice(0, 19).replace('T', ' '), email] + ) + + try { + await sendPasswordResetEmail(email, token) + } catch (emailErr) { + console.error('Email send failed:', emailErr) + // Still return success — don't leak email delivery status + } + + return NextResponse.json({ success: true, message: 'If an account exists, a reset email has been sent.' }) + } catch (error) { + console.error('Forgot password error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..607224c --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { verifyPassword, createToken } from '@/lib/auth-utils' + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json() + if (!email || !password) { + return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) + } + + const [rows] = await pool.execute('SELECT * FROM signups WHERE email = ?', [email]) as any[] + if (rows.length === 0) { + return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 }) + } + + const user = rows[0] + if (!user.password_hash) { + return NextResponse.json({ error: 'Please set your password first. Check your email or use "Forgot Password".' }, { status: 401 }) + } + + const valid = await verifyPassword(password, user.password_hash) + if (!valid) { + return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 }) + } + + const token = createToken({ id: user.id, email: user.email }) + + const response = NextResponse.json({ + success: true, + user: { id: user.id, email: user.email, full_name: user.full_name }, + }) + + response.cookies.set('auth_token', token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, + path: '/', + }) + + return response + } catch (error) { + console.error('Login error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..3b32a84 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { getSessionUser } from '@/lib/auth-utils' + +export async function GET() { + try { + const session = await getSessionUser() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const [rows] = await pool.execute( + 'SELECT id, email, full_name, phone, destination, amount, monthly_payment, payment_plan_months, payment_status, certificate_number, certificate_expires, created_at FROM signups WHERE id = ?', + [session.id] + ) as any[] + + if (rows.length === 0) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const user = rows[0] + + // Get payments + const [payments] = await pool.execute( + 'SELECT id, amount, currency, payment_type, status, transaction_id, created_at FROM payments WHERE signup_id = ? ORDER BY created_at DESC', + [user.id] + ) as any[] + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + fullName: user.full_name, + phone: user.phone, + destination: user.destination, + totalAmount: user.amount, + monthlyPayment: user.monthly_payment, + paymentPlanMonths: user.payment_plan_months, + paymentStatus: user.payment_status, + certificateNumber: user.certificate_number, + certificateExpires: user.certificate_expires, + createdAt: user.created_at, + }, + payments, + }) + } catch (error) { + console.error('Me error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..e3e58fb --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { hashPassword, createToken, generateCertificateNumber } from '@/lib/auth-utils' + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json() + if (!email || !password) { + return NextResponse.json({ error: 'Email and password required' }, { status: 400 }) + } + if (password.length < 6) { + return NextResponse.json({ error: 'Password must be at least 6 characters' }, { status: 400 }) + } + + const [rows] = await pool.execute('SELECT * FROM signups WHERE email = ?', [email]) as any[] + if (rows.length === 0) { + return NextResponse.json({ error: 'No account found with this email. Please purchase a certificate first.' }, { status: 404 }) + } + + const user = rows[0] + if (user.password_hash) { + return NextResponse.json({ error: 'Password already set. Please log in.' }, { status: 409 }) + } + + const hash = await hashPassword(password) + const certNumber = user.certificate_number || generateCertificateNumber() + const certExpires = user.certificate_expires || new Date(Date.now() + 18 * 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + + await pool.execute( + 'UPDATE signups SET password_hash = ?, certificate_number = ?, certificate_expires = ? WHERE id = ?', + [hash, certNumber, certExpires, user.id] + ) + + const token = createToken({ id: user.id, email: user.email }) + + const response = NextResponse.json({ + success: true, + user: { id: user.id, email: user.email, full_name: user.full_name }, + }) + + response.cookies.set('auth_token', token, { + httpOnly: true, secure: true, sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, path: '/', + }) + + return response + } catch (error) { + console.error('Register error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..0605229 --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { hashPassword } from '@/lib/auth-utils' + +export async function POST(request: NextRequest) { + try { + const { token, password } = await request.json() + if (!token || !password) { + return NextResponse.json({ error: 'Token and password required' }, { status: 400 }) + } + if (password.length < 6) { + return NextResponse.json({ error: 'Password must be at least 6 characters' }, { status: 400 }) + } + + const [rows] = await pool.execute( + 'SELECT * FROM signups WHERE reset_token = ? AND reset_token_expires > NOW()', + [token] + ) as any[] + + if (rows.length === 0) { + return NextResponse.json({ error: 'Invalid or expired reset token' }, { status: 400 }) + } + + const hash = await hashPassword(password) + await pool.execute( + 'UPDATE signups SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?', + [hash, rows[0].id] + ) + + return NextResponse.json({ success: true, message: 'Password updated. You can now log in.' }) + } catch (error) { + console.error('Reset password error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/claim/route.ts b/src/app/api/claim/route.ts new file mode 100644 index 0000000..85581e2 --- /dev/null +++ b/src/app/api/claim/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool, { upsertEarlyLead, confirmEarlyLead, createEbookLead } from '@/lib/db-mysql' +import { sendEbookEmail } from '@/lib/email' + +/** + * Unified "Claim My Certificate" endpoint. Fires on every primary CTA click. + * + * 1. Persists email + phone + IP to early_leads (confirmed=1 — they clicked) + * 2. Creates an ebook_lead so /admin/leads shows them + * 3. Sends the PDF guide email (the same one the ebook form has always sent) + * 4. Attributes to affiliate if a referral_code is present + * + * Returns immediately even if the email send is in-flight, so the UI can + * keep moving (open the payment modal, etc.). + */ + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const PDF_URL = '/ebooks/budget-luxury-travel.pdf' + +function clientIp(req: NextRequest): string | null { + const fwd = req.headers.get('x-forwarded-for') + if (fwd) return fwd.split(',')[0].trim() + return req.headers.get('x-real-ip') || req.headers.get('cf-connecting-ip') || null +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const { + email: rawEmail, phone, name, source_lp, + referral_code, utm_source, utm_medium, utm_campaign, + } = body + + if (!rawEmail || typeof rawEmail !== 'string' || !EMAIL_RE.test(rawEmail)) { + return NextResponse.json({ success: false, error: 'Valid email required' }, { status: 400 }) + } + const email = rawEmail.trim().toLowerCase() + const ip = clientIp(request) + + // 1 + 2 — persist lead (run in parallel) + await Promise.all([ + upsertEarlyLead({ + email, + phone: phone || null, + name: name || null, + ip_address: ip, + source_lp: source_lp || null, + referral_code: referral_code || null, + utm_source: utm_source || null, + utm_medium: utm_medium || null, + utm_campaign: utm_campaign || null, + }), + createEbookLead({ + email, name: name || undefined, + source_lp: source_lp || 'claim-cta', + utm_source, utm_medium, utm_campaign, + }), + ]) + + // Mark early lead as confirmed (they explicitly clicked the CTA) + confirmEarlyLead(email).catch(err => console.error('confirmEarlyLead:', err)) + + // Attribute to affiliate if referral_code provided + if (referral_code) { + pool.execute( + 'UPDATE ebook_leads SET affiliate_id = (SELECT id FROM affiliates WHERE referral_code = ? AND status = ?) WHERE email = ?', + [referral_code, 'active', email] + ).catch(err => console.error('Affiliate attribution:', err)) + } + + // 3 — fire ebook PDF email (await so we can surface failures to the user) + let emailSent = true + try { + const result = await sendEbookEmail(email, name) + if (!result) emailSent = false + } catch (err) { + console.error('sendEbookEmail failed:', err) + emailSent = false + } + + return NextResponse.json({ + success: true, + emailSent, + pdfUrl: PDF_URL, + }) + } catch (error) { + console.error('claim error:', error) + return NextResponse.json({ success: false, error: 'Internal error' }, { status: 500 }) + } +} diff --git a/src/app/api/ebook/route.ts b/src/app/api/ebook/route.ts new file mode 100644 index 0000000..3ba04c2 --- /dev/null +++ b/src/app/api/ebook/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import { createEbookLead, confirmEarlyLead } from '@/lib/db-mysql' +import { sendEbookEmail } from '@/lib/email' + +export async function POST(request: NextRequest) { + try { + const { email, name, source_lp, referral_code, utm_source, utm_medium, utm_campaign } = await request.json() + if (!email) return NextResponse.json({ error: 'Email is required' }, { status: 400 }) + + // Look up affiliate by referral code + let affiliateId: number | null = null + if (referral_code) { + const [aff] = await pool.execute( + 'SELECT id FROM affiliates WHERE referral_code = ? AND status = ?', + [referral_code, 'active'] + ) as any[] + if (aff.length > 0) affiliateId = aff[0].id + } + + // Store lead with affiliate tracking + await createEbookLead({ email, name, source_lp, utm_source, utm_medium, utm_campaign }) + + // Update affiliate_id on the lead (createEbookLead doesn't have it yet) + if (affiliateId) { + await pool.execute( + 'UPDATE ebook_leads SET affiliate_id = ? WHERE email = ?', + [affiliateId, email] + ) + } + + try { await confirmEarlyLead(email.trim().toLowerCase()) } catch {} + + // Send ebook email + try { await sendEbookEmail(email, name) } catch (e) { console.error('Ebook email failed:', e) } + + return NextResponse.json({ success: true, downloadUrl: '/ebooks/budget-luxury-travel.pdf' }) + } catch (error) { + console.error('Ebook API error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/payment/confirm/route.ts b/src/app/api/payment/confirm/route.ts deleted file mode 100644 index fccadf4..0000000 --- a/src/app/api/payment/confirm/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { supabase } from '@/lib/supabase' -import { maverickAPI } from '@/lib/maverick' - -export async function POST(request: NextRequest) { - try { - const { paymentId, signupId } = await request.json() - - if (!paymentId || !signupId) { - return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } - ) - } - - // Check payment status with Maverick API - const paymentStatus = await maverickAPI.getPaymentStatus(paymentId) - - if (!paymentStatus.success) { - console.error('Failed to get payment status:', paymentStatus.error) - return NextResponse.json( - { error: 'Failed to verify payment status' }, - { status: 500 } - ) - } - - const status = paymentStatus.status - - // Update payment record - const { error: paymentUpdateError } = await supabase - .from('payments') - .update({ - status: status === 'completed' ? 'completed' : 'failed', - updated_at: new Date().toISOString() - }) - .eq('transaction_id', paymentId) - - if (paymentUpdateError) { - console.error('Payment update error:', paymentUpdateError) - return NextResponse.json( - { error: 'Failed to update payment record' }, - { status: 500 } - ) - } - - // Update signup status - const { error: signupUpdateError } = await supabase - .from('signups') - .update({ - payment_status: status === 'completed' ? 'completed' : 'failed', - updated_at: new Date().toISOString() - }) - .eq('id', signupId) - - if (signupUpdateError) { - console.error('Signup update error:', signupUpdateError) - return NextResponse.json( - { error: 'Failed to update signup record' }, - { status: 500 } - ) - } - - // If payment is completed, create user record and certificate - if (status === 'completed') { - // Get signup details - const { data: signup } = await supabase - .from('signups') - .select('*') - .eq('id', signupId) - .single() - - if (signup) { - // Create user record - const { data: user, error: userError } = await supabase - .from('users') - .insert({ - email: signup.email, - full_name: signup.full_name, - role: 'client', - }) - .select() - .single() - - if (!userError && user) { - // Create certificate record - await supabase - .from('certificates') - .insert({ - user_id: user.id, - certificate_url: `https://certificates.example.com/${user.id}`, - issued_at: new Date().toISOString(), - expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // 1 year - }) - } - } - } - - return NextResponse.json({ - success: true, - status, - message: status === 'completed' - ? 'Payment confirmed and certificate issued' - : 'Payment failed or was cancelled' - }) - - } catch (error) { - console.error('Payment confirmation error:', error) - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/src/app/api/payment/create/route.ts b/src/app/api/payment/create/route.ts index d20d46e..a64b3c8 100644 --- a/src/app/api/payment/create/route.ts +++ b/src/app/api/payment/create/route.ts @@ -1,86 +1,85 @@ import { NextRequest, NextResponse } from 'next/server' -import { supabase } from '@/lib/supabase' -import { maverickAPI } from '@/lib/maverick' +import { createPayment, updateSignup, createAffiliateReferral } from '@/lib/db-mysql' +import { processFullPurchase } from '@/lib/nmi' +import { generateCertificateNumber } from '@/lib/auth-utils' +import { sendWelcomeEmail } from '@/lib/email' export async function POST(request: NextRequest) { try { - const { email, fullName, amount, signupId } = await request.json() + const body = await request.json() + const { + firstName, lastName, email, phone, + cardNumber, cardExp, cardCvv, + paymentToken, + paymentType = 'monthly', + signupId, + } = body - if (!email || !fullName || !amount || !signupId) { - return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } - ) + if (!email || !firstName || !lastName) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) } - // Create payment with Maverick API - const paymentResult = await maverickAPI.createPayment({ - amount: Math.round(amount * 100), // Convert to cents - currency: 'USD', + if (!cardNumber && !paymentToken) { + return NextResponse.json({ error: 'Missing payment information' }, { status: 400 }) + } + + const result = await processFullPurchase({ + paymentToken, + cardNumber, + cardExp, + cardCvv, + firstName, + lastName, email, - fullName, - description: 'Professional Certification', - returnUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/payment/success`, - cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/payment/cancel`, + phone: phone || '', + paymentType, }) - if (!paymentResult.success) { - console.error('Payment creation failed:', paymentResult.error) - - // Update signup status to failed - await supabase - .from('signups') - .update({ payment_status: 'failed' }) - .eq('id', signupId) - - return NextResponse.json( - { error: paymentResult.error || 'Payment creation failed' }, - { status: 500 } - ) + if (!result.success) { + if (signupId) { + await updateSignup(signupId, { payment_status: 'failed' }) + } + return NextResponse.json({ error: result.error || 'Payment declined' }, { status: 400 }) } - // Create payment record - const { data: paymentData, error: paymentError } = await supabase - .from('payments') - .insert({ + // Record payment + const amount = paymentType === 'monthly' ? 29 : 249 + if (signupId) { + await createPayment({ signup_id: signupId, - payment_method: 'maverick', amount, - currency: 'USD', - status: 'pending', - transaction_id: paymentResult.paymentId, + payment_type: 'initial', + status: 'completed', + transaction_id: result.transactionId, }) - .select() - .single() - if (paymentError) { - console.error('Payment record creation failed:', paymentError) - return NextResponse.json( - { error: 'Failed to create payment record' }, - { status: 500 } - ) - } + const certNumber = generateCertificateNumber() + const certExpires = new Date(Date.now() + 18 * 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] - // Update signup with payment ID - await supabase - .from('signups') - .update({ - payment_id: paymentResult.paymentId, - payment_status: 'processing' + await updateSignup(signupId, { + payment_status: 'active', + certificate_number: certNumber, + certificate_expires: certExpires, + ...(result.subscriptionId && { subscription_id: result.subscriptionId }), }) - .eq('id', signupId) + + // Create affiliate referral if applicable + await createAffiliateReferral(signupId) + + // Send welcome email with certificate + try { + await sendWelcomeEmail(email, `${firstName} ${lastName}`, certNumber, paymentType, amount) + } catch (emailErr) { console.error('Welcome email failed:', emailErr) } + } return NextResponse.json({ success: true, - paymentId: paymentResult.paymentId, - checkoutUrl: paymentResult.checkoutUrl, + transactionId: result.transactionId, + subscriptionId: result.subscriptionId, + paymentType, }) - } catch (error) { - console.error('Payment creation error:', error) - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ) + console.error('Payment error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} \ No newline at end of file +} diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 302107b..6118632 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -1,70 +1,56 @@ import { NextRequest, NextResponse } from 'next/server' -import { supabase } from '@/lib/supabase' +import pool, { createSignup, confirmEarlyLead } from '@/lib/db-mysql' export async function POST(request: NextRequest) { try { - const { email, full_name, phone, amount, monthly_payment, payment_plan_months } = await request.json() + const { email, full_name, phone, amount, monthly_payment, payment_plan_months, + source_lp, referral_code, utm_source, utm_medium, utm_campaign } = await request.json() - if (!email || !full_name || !phone || !amount) { - return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } - ) + if (!email || !full_name || !amount) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) } - // Check if email already exists - const { data: existingSignup } = await supabase - .from('signups') - .select('*') - .eq('email', email) - .single() - - if (existingSignup) { - return NextResponse.json( - { error: 'Email already registered' }, - { status: 409 } - ) + // Do not overwrite an already-paid signup. If a row with this email exists + // and its payment_status is active or completed, short-circuit and return + // the existing row instead of letting createSignup reset it to pending. + const [existingRows] = await pool.execute( + 'SELECT id, email, full_name, phone, amount, payment_status FROM signups WHERE email = ?', + [email] + ) as any[] + + if (existingRows.length > 0) { + const status = existingRows[0].payment_status + if (status === 'active' || status === 'completed') { + return NextResponse.json({ + id: existingRows[0].id, + email: existingRows[0].email, + full_name: existingRows[0].full_name, + phone: existingRows[0].phone, + amount: existingRows[0].amount, + already_active: true, + }) + } + // status is 'pending', NULL, or some other non-finalized state — fall + // through to createSignup which will update the existing row. } - // Create signup record - const { data, error } = await supabase - .from('signups') - .insert({ - email, - full_name, - phone, - amount, - monthly_payment: monthly_payment || 39, - payment_plan_months: payment_plan_months || 18, - payment_status: 'pending', - }) - .select() - .single() + const { data, error } = await createSignup({ + email, full_name, phone: phone || '', amount, + monthly_payment: monthly_payment || 29, + payment_plan_months: payment_plan_months || 10, + source_lp, referral_code, utm_source, utm_medium, utm_campaign, + }) - if (error) { - console.error('Supabase error:', error) - return NextResponse.json( - { error: 'Failed to create signup' }, - { status: 500 } - ) - } + if (error) return NextResponse.json({ error }, { status: 409 }) - return NextResponse.json({ - id: data.id, - email: data.email, - full_name: data.full_name, - phone: data.phone, - amount: data.amount, - monthly_payment: data.monthly_payment, - payment_plan_months: data.payment_plan_months, - payment_status: data.payment_status - }) + try { await confirmEarlyLead(email.trim().toLowerCase()) } catch {} + return NextResponse.json({ + id: data.id, email: data.email, full_name: data.full_name, + phone: data.phone, amount: data.amount, + }) } catch (error) { console.error('Signup error:', error) - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} \ No newline at end of file +} diff --git a/src/app/api/track/lead/route.ts b/src/app/api/track/lead/route.ts new file mode 100644 index 0000000..2aae0a5 --- /dev/null +++ b/src/app/api/track/lead/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { upsertEarlyLead } from '@/lib/db-mysql' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function clientIp(request: NextRequest): string | null { + const fwd = request.headers.get('x-forwarded-for') + if (fwd) return fwd.split(',')[0].trim() + return request.headers.get('x-real-ip') || request.headers.get('cf-connecting-ip') || null +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const { email, phone, name, source_lp, referral_code, utm_source, utm_medium, utm_campaign } = body + + if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) { + return NextResponse.json({ captured: false, error: 'Invalid email' }, { status: 400 }) + } + + await upsertEarlyLead({ + email: email.trim().toLowerCase(), + phone: typeof phone === 'string' && phone.trim() ? phone.trim() : null, + name: typeof name === 'string' && name.trim() ? name.trim() : null, + ip_address: clientIp(request), + source_lp: source_lp || null, + referral_code: referral_code || null, + utm_source: utm_source || null, + utm_medium: utm_medium || null, + utm_campaign: utm_campaign || null, + }) + + return NextResponse.json({ captured: true }) + } catch (error) { + console.error('Lead capture error:', error) + return NextResponse.json({ captured: false, error: 'Internal error' }, { status: 500 }) + } +} diff --git a/src/app/api/track/pageview/route.ts b/src/app/api/track/pageview/route.ts new file mode 100644 index 0000000..9c8c67b --- /dev/null +++ b/src/app/api/track/pageview/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db-mysql' +import crypto from 'crypto' + +export async function POST(request: NextRequest) { + try { + const { page_slug, referral_code, utm_source, utm_medium, utm_campaign } = await request.json() + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown' + const ipHash = crypto.createHash('sha256').update(ip).digest('hex').substring(0, 16) + + await pool.execute( + `INSERT INTO page_views (page_slug, referral_code, utm_source, utm_medium, utm_campaign, ip_hash) VALUES (?, ?, ?, ?, ?, ?)`, + [page_slug || null, referral_code || null, utm_source || null, utm_medium || null, utm_campaign || null, ipHash] + ) + + return NextResponse.json({ ok: true }) + } catch { + return NextResponse.json({ ok: true }) // Never fail pageview tracking + } +} diff --git a/src/app/api/webhooks/nmi/route.ts b/src/app/api/webhooks/nmi/route.ts new file mode 100644 index 0000000..6a2abe1 --- /dev/null +++ b/src/app/api/webhooks/nmi/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createHmac, timingSafeEqual } from 'crypto' +import pool, { createPayment, updateSignup } from '@/lib/db-mysql' + +/** + * NMI Webhook Handler + * Configure in NMI/Maverick Dashboard: + * Settings → Webhooks → URL: https://hi2b.com/api/webhooks/nmi + * Events: recurring.subscription.add, recurring.subscription.update, recurring.subscription.delete + * Signing key → same value as NMI_WEBHOOK_SECRET env var on the server. + * + * Every request MUST carry one of: + * - X-Signature: sha256= + * - X-NMI-Signature: + * Unsigned requests are rejected with 401. + */ + +function verifySignature(rawBody: string, headerValue: string | null, secret: string): boolean { + if (!headerValue) return false + const provided = headerValue.replace(/^sha256=/i, '').trim().toLowerCase() + if (!/^[0-9a-f]+$/.test(provided)) return false + const expected = createHmac('sha256', secret).update(rawBody).digest('hex') + const a = Buffer.from(provided, 'hex') + const b = Buffer.from(expected, 'hex') + if (a.length !== b.length) return false + return timingSafeEqual(a, b) +} + +export async function POST(request: NextRequest) { + const secret = process.env.NMI_WEBHOOK_SECRET + if (!secret) { + console.error('[NMI Webhook] NMI_WEBHOOK_SECRET not configured; rejecting') + return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 }) + } + + const rawBody = await request.text() + const sigHeader = request.headers.get('x-signature') || request.headers.get('x-nmi-signature') + if (!verifySignature(rawBody, sigHeader, secret)) { + console.warn('[NMI Webhook] Invalid signature — rejecting') + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + let body: any + try { + body = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + try { + const { event_id, event_type, event_body } = body + console.log(`[NMI Webhook] ${event_type} | event_id: ${event_id}`) + + if (!event_type || !event_body) { + return NextResponse.json({ message: 'Invalid webhook payload' }, { status: 400 }) + } + + const subscriptionId = event_body.subscription_id + const completedPayments = parseInt(event_body.completed_payments || '0') + const remainingPayments = event_body.remaining_payments + const planAmount = parseFloat(event_body.plan?.amount || '29.00') + + let signupId: number | null = null + const [subMatch] = await pool.execute( + `SELECT id FROM signups WHERE subscription_id = ? LIMIT 1`, + [subscriptionId] + ) as any[] + if (subMatch.length > 0) signupId = subMatch[0].id + + switch (event_type) { + case 'recurring.subscription.add': { + console.log(`[NMI Webhook] Subscription created: ${subscriptionId}`) + break + } + + case 'recurring.subscription.update': { + console.log(`[NMI Webhook] Subscription updated: ${subscriptionId} | Completed: ${completedPayments} | Remaining: ${remainingPayments}`) + if (signupId) { + const [existing] = await pool.execute( + `SELECT id FROM payments WHERE signup_id = ? AND payment_type = 'monthly' AND amount = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)`, + [signupId, planAmount] + ) as any[] + + if (existing.length === 0) { + await createPayment({ + signup_id: signupId, + amount: planAmount, + payment_type: 'monthly', + status: 'completed', + transaction_id: `sub_${subscriptionId}_${completedPayments}`, + }) + console.log(`[NMI Webhook] Recorded monthly payment #${completedPayments} for signup ${signupId}`) + } + + if (remainingPayments === '0' || remainingPayments === 0) { + await updateSignup(signupId, { payment_status: 'completed' }) + console.log(`[NMI Webhook] Subscription completed for signup ${signupId}`) + } + } else { + console.warn(`[NMI Webhook] Could not match subscription ${subscriptionId} to a signup`) + console.warn(`[NMI Webhook] Orphan payment: sub=${subscriptionId}, amount=${planAmount}, completed=${completedPayments}`) + } + break + } + + case 'recurring.subscription.delete': { + console.log(`[NMI Webhook] Subscription deleted: ${subscriptionId}`) + if (signupId) { + await updateSignup(signupId, { payment_status: 'cancelled' }) + console.log(`[NMI Webhook] Marked signup ${signupId} as cancelled`) + } + break + } + + default: + console.log(`[NMI Webhook] Unhandled event type: ${event_type}`) + } + + return NextResponse.json({ message: 'Webhook received successfully' }) + } catch (error) { + console.error('[NMI Webhook] Error:', error) + return NextResponse.json({ message: 'Webhook received with errors' }) + } +} + +export async function GET() { + return NextResponse.json({ + status: 'ok', + endpoint: 'NMI Recurring Payment Webhook', + note: 'Signed requests only (HMAC-SHA256 of body, X-Signature header).', + events: [ + 'recurring.subscription.add', + 'recurring.subscription.update', + 'recurring.subscription.delete', + ], + }) +} diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..16086ca --- /dev/null +++ b/src/app/dashboard/billing/page.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Plane, ArrowLeft, CheckCircle, Clock, XCircle, CreditCard, DollarSign, Calendar } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +interface UserData { + id: number + fullName: string + totalAmount: number + monthlyPayment: number + paymentPlanMonths: number + paymentStatus: string +} + +interface PaymentData { + id: number + amount: string + currency: string + payment_type: string + status: string + transaction_id: string + created_at: string +} + +export default function BillingPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [payments, setPayments] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/auth/me') + .then(res => { + if (!res.ok) throw new Error() + return res.json() + }) + .then(data => { + setUser(data.user) + setPayments(data.payments || []) + }) + .catch(() => router.push('/dashboard/login')) + .finally(() => setLoading(false)) + }, [router]) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) return null + + const paidAmount = payments.filter(p => p.status === 'completed').reduce((sum, p) => sum + Number(p.amount), 0) + const paidMonths = payments.filter(p => p.status === 'completed').length + const remainingMonths = Math.max(0, user.paymentPlanMonths - paidMonths) + const remainingAmount = Number(user.totalAmount) - paidAmount + const progressPercent = (paidAmount / Number(user.totalAmount)) * 100 + + // Generate upcoming payments + const upcomingPayments: { date: string; amount: number }[] = [] + if (remainingMonths > 0) { + const lastPaymentDate = payments.length > 0 + ? new Date(payments[0].created_at) + : new Date() + for (let i = 1; i <= remainingMonths; i++) { + const d = new Date(lastPaymentDate) + d.setMonth(d.getMonth() + i) + upcomingPayments.push({ + date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }), + amount: Number(user.monthlyPayment), + }) + } + } + + const statusIcon = (status: string) => { + switch (status) { + case 'completed': return + case 'pending': case 'processing': return + default: return + } + } + + return ( +
+ + +
+ + Back to Dashboard + + +

Billing History

+ + {/* Payment Progress */} + + +
+

Payment Progress

+ {paidMonths} of {user.paymentPlanMonths} payments +
+
+
+
+
+
+

${paidAmount.toFixed(2)}

+

Paid

+
+
+

${remainingAmount.toFixed(2)}

+

Remaining

+
+
+

${Number(user.totalAmount).toFixed(2)}

+

Total

+
+
+ + + + {/* Payment History */} + + + + Payment History + + + + {payments.length === 0 ? ( +

No payments yet.

+ ) : ( +
+ {payments.map((p) => ( +
+
+ {statusIcon(p.status)} +
+

+ {p.payment_type === 'initial' ? 'Initial Payment' : p.payment_type === 'monthly' ? 'Monthly Payment' : 'Refund'} +

+

+ {new Date(p.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} +

+
+
+
+

+ {p.payment_type === 'refund' ? '-' : ''}${Number(p.amount).toFixed(2)} +

+

{p.transaction_id}

+
+
+ ))} +
+ )} +
+
+ + {/* Upcoming Payments */} + {upcomingPayments.length > 0 && ( + + + + Upcoming Payments + + + +
+ {upcomingPayments.map((p, i) => ( +
+
+ +

{p.date}

+
+

${p.amount.toFixed(2)}

+
+ ))} +
+
+
+ )} + + {/* Support */} +
+

Questions about billing? Call 888-602-2424

+
+
+
+ ) +} diff --git a/src/app/dashboard/certificate/page.tsx b/src/app/dashboard/certificate/page.tsx new file mode 100644 index 0000000..b05c81f --- /dev/null +++ b/src/app/dashboard/certificate/page.tsx @@ -0,0 +1,248 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Plane, Award, ArrowLeft, Download, MapPin, Calendar, Users, Star, Phone } from 'lucide-react' +import { Button } from '@/components/ui/button' + +interface UserData { + id: number + email: string + fullName: string + phone: string + destination: string | null + totalAmount: number + monthlyPayment: number + paymentPlanMonths: number + paymentStatus: string + certificateNumber: string | null + certificateExpires: string | null + createdAt: string +} + +export default function CertificatePage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/auth/me') + .then(res => { + if (!res.ok) throw new Error() + return res.json() + }) + .then(data => setUser(data.user)) + .catch(() => router.push('/dashboard/login')) + .finally(() => setLoading(false)) + }, [router]) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) return null + + const expiryDate = user.certificateExpires + ? new Date(user.certificateExpires).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + : 'TBD' + + const issueDate = new Date(user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + + return ( +
+ {/* Nav — hidden on print */} + + +
+ {/* Back link — hidden on print */} +
+ + Back to Dashboard + +
+ + {/* ═══ CERTIFICATE — this is the only thing that prints ═══ */} +
+ {/* Gold border effect */} +
+
+ + {/* Header band */} +
+
+
+ + + +
+

+ VACATION CERTIFICATE +

+

Mexico Paradise Vacations

+
+
+ + {/* Certificate body */} +
+ {/* Presented to */} +
+

This certificate is presented to

+

+ {user.fullName} +

+
+ + {/* Description */} +
+

+ This certifies the bearer is entitled to a 5-Day, 4-Night All-Inclusive Vacation for two guests at a participating luxury resort in Mexico, including all meals, beverages, and resort amenities. +

+
+ + {/* Certificate number and dates */} +
+
+ +

Certificate No.

+

{user.certificateNumber || 'PENDING'}

+
+
+ +

Issued

+

{issueDate}

+
+
+ +

Expires

+

{expiryDate}

+
+
+ + {/* Destinations */} +
+

Valid at participating resorts in

+
+ {['Cancun', 'Cabo San Lucas', 'Riviera Maya', 'Puerto Vallarta'].map(d => ( + + {d} + + ))} +
+
+ + {/* Includes */} +
+

Certificate Includes

+
+ {[ + '5 Days / 4 Nights', + 'All-Inclusive Resort', + 'Unlimited Meals & Drinks', + 'Resort Amenities', + 'Beach & Pool Access', + '2 Guest Capacity', + ].map(item => ( +
+
+ + + +
+ {item} +
+ ))} +
+
+ + {/* ═══ CALL TO BOOK — BIG TOLL FREE NUMBER ═══ */} +
+ +

To Book Your Vacation, Call

+ +

+ 888-602-2424 +

+
+

+ Toll-Free • Mon-Fri 9am-8pm • Sat 10am-4pm EST +

+

+ Have your certificate number ready: {user.certificateNumber || 'PENDING'} +

+
+ + {/* Status bar */} +
+
+
+ + {user.paymentStatus === 'active' ? 'CERTIFICATE ACTIVE' : (user.paymentStatus || 'PENDING').toString().toUpperCase()} + +
+
+ + 2 Guests +
+
+
+ + {/* Footer band */} +
+

+ hi2b.com • 724vacation.com • 888-602-2424 +

+
+
+ + {/* Actions — hidden on print */} +
+ + + + +
+
+ + {/* Print styles */} + +
+ ) +} diff --git a/src/app/dashboard/forgot-password/page.tsx b/src/app/dashboard/forgot-password/page.tsx new file mode 100644 index 0000000..b695fc4 --- /dev/null +++ b/src/app/dashboard/forgot-password/page.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Loader2, Plane, Mail, ArrowLeft } from 'lucide-react' +import Link from 'next/link' + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [sent, setSent] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + setSent(true) + setIsLoading(false) + } + + return ( +
+
+
+
+ +
+
+ + + + {sent ? 'Check Your Email' : 'Reset Password'} + + + {sent ? ( +
+ +

If an account with {email} exists, we've sent a password reset link.

+ + + +
+ ) : ( +
+
+ + setEmail(e.target.value)} className="h-11 bg-white" required /> +
+ +
+ Back to login +
+
+ )} +
+
+
+
+ ) +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..642e33c --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Dashboard — Mexico Paradise Vacations', + description: 'View your vacation certificate and billing history.', +} + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/src/app/dashboard/login/page.tsx b/src/app/dashboard/login/page.tsx new file mode 100644 index 0000000..271f54b --- /dev/null +++ b/src/app/dashboard/login/page.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Loader2, Plane, Eye, EyeOff } from 'lucide-react' +import Link from 'next/link' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [mode, setMode] = useState<'login' | 'register'>('login') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setIsLoading(true) + + try { + const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register' + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + + const data = await res.json() + if (!res.ok) { + setError(data.error || 'Something went wrong') + return + } + + window.location.href = '/dashboard' + } catch { + setError('Network error. Please try again.') + } finally { + setIsLoading(false) + } + } + + return ( +
+
+ {/* Logo */} +
+
+ +
+

Mexico Paradise Vacations

+

Client Dashboard

+
+ + + + + {mode === 'login' ? 'Welcome Back' : 'Set Up Your Account'} + + + {mode === 'login' + ? 'Log in to view your certificate and billing' + : 'Create a password for your account'} + + + + {/* Tab toggle */} +
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="h-11 bg-white" + required + /> +
+
+ +
+ setPassword(e.target.value)} + className="h-11 bg-white pr-10" + required + minLength={6} + /> + +
+
+ + +
+ + {mode === 'login' && ( +
+ + Forgot your password? + +
+ )} +
+
+ +

+ Back to Home +

+
+
+ ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..cba298e --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Plane, CreditCard, Award, LogOut, User, Calendar, DollarSign, Shield, MapPin } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' + +interface UserData { + id: number + email: string + fullName: string + phone: string + destination: string | null + totalAmount: number + monthlyPayment: number + paymentPlanMonths: number + paymentStatus: string + certificateNumber: string | null + certificateExpires: string | null + createdAt: string +} + +interface PaymentData { + id: number + amount: number + currency: string + payment_type: string + status: string + transaction_id: string + created_at: string +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [payments, setPayments] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/auth/me') + .then(res => { + if (!res.ok) throw new Error('Not authenticated') + return res.json() + }) + .then(data => { + setUser(data.user) + setPayments(data.payments || []) + }) + .catch(() => router.push('/dashboard/login')) + .finally(() => setLoading(false)) + }, [router]) + + const handleLogout = () => { + document.cookie = 'auth_token=; path=/; max-age=0' + router.push('/dashboard/login') + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) return null + + const paidAmount = payments.filter(p => p.status === 'completed').reduce((sum, p) => sum + Number(p.amount), 0) + const totalAmount = Number(user.totalAmount) || 0 + const remainingAmount = totalAmount - paidAmount + const paidMonths = payments.filter(p => p.status === 'completed').length + const remainingMonths = (user.paymentPlanMonths || 0) - paidMonths + const firstName = (user.fullName || user.email || 'Traveler').split(' ')[0] + const paymentStatus = (user.paymentStatus || 'pending').toString() + + return ( +
+ {/* Top Nav */} + + +
+ {/* Welcome */} +
+

Welcome back, {firstName}!

+

Here's your vacation certificate overview.

+
+ + {/* Status Cards */} +
+ + +
+
+ +
+
+

Status

+

+ {paymentStatus.toUpperCase()} +

+
+
+
+
+ + +
+
+ +
+
+

Paid

+

${paidAmount.toFixed(2)}

+
+
+
+
+ + +
+
+ +
+
+

Remaining

+

{remainingMonths} payments

+
+
+
+
+ + +
+
+ +
+
+

Certificate

+

{user.certificateNumber || 'Pending'}

+
+
+
+
+
+ + {/* Quick Links */} +
+ + + +
+ +
+
+

View Certificate

+

See your vacation certificate with details and expiration date

+
+
+
+ + + + +
+ +
+
+

Billing History

+

View all payments, upcoming charges, and invoices

+
+
+
+ +
+ + {/* Account Info */} + + +

Account Details

+
+
Name: {user.fullName || 'Not set'}
+
Email: {user.email}
+
Phone: {user.phone || 'Not set'}
+
Member since: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
+
+
+
+
+
+ ) +} diff --git a/src/app/dashboard/reset-password/page.tsx b/src/app/dashboard/reset-password/page.tsx new file mode 100644 index 0000000..fe0998e --- /dev/null +++ b/src/app/dashboard/reset-password/page.tsx @@ -0,0 +1,88 @@ +'use client' + +import { useState, Suspense } from 'react' +import { useSearchParams } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Loader2, Plane, CheckCircle } from 'lucide-react' +import Link from 'next/link' + +function ResetPasswordForm() { + const searchParams = useSearchParams() + const token = searchParams.get('token') || '' + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [done, setDone] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (password !== confirm) { setError('Passwords do not match'); return } + setError('') + setIsLoading(true) + + const res = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }) + const data = await res.json() + if (!res.ok) { setError(data.error); setIsLoading(false); return } + setDone(true) + setIsLoading(false) + } + + return ( + + + {done ? 'Password Updated!' : 'Create New Password'} + + + {done ? ( +
+ +

Your password has been updated.

+ + + +
+ ) : ( +
+ {error &&
{error}
} +
+ + setPassword(e.target.value)} className="h-11 bg-white" required minLength={6} /> +
+
+ + setConfirm(e.target.value)} className="h-11 bg-white" required minLength={6} /> +
+ +
+ )} +
+
+ ) +} + +export default function ResetPasswordPage() { + return ( +
+
+
+
+ +
+
+ Loading...
}> + + +
+
+ ) +} diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx new file mode 100644 index 0000000..ad2bf6d --- /dev/null +++ b/src/app/faq/page.tsx @@ -0,0 +1,88 @@ +import { Plane } from 'lucide-react' +import Link from 'next/link' +import type { Metadata } from 'next' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +export const metadata: Metadata = { + title: 'FAQ — Mexico Paradise Vacations', + description: 'Common questions about Mexico Paradise Vacations all-inclusive certificates: what is included, how to book, refunds, kids, destinations, payment.', +} + +interface QA { q: string; a: React.ReactNode } + +const FAQ: QA[] = [ + { q: 'What is a vacation certificate?', + a: <>A pre-paid voucher for a 5-day, 4-night all-inclusive stay at one of our partner beachfront resorts in Mexico. After purchase, you pick your travel dates and destination from available inventory; we send you the confirmation. }, + { q: 'What does “all-inclusive” actually include?', + a: <>All meals at the resort restaurants, all drinks (alcoholic and non-alcoholic), pool and beach access, daily activities, evening entertainment, in-room amenities, and 24-hour room service. You do not run a tab — you just enjoy the resort. }, + { q: 'Where can I go?', + a: <>Four destinations: Cancun, Cabo San Lucas, Riviera Maya, and Puerto Vallarta. You pick when you redeem the certificate. }, + { q: 'How much does it cost?', + a: <>${PAYMENT_CONFIG.monthlyPrice}/month for {PAYMENT_CONFIG.totalMonths} months (total ${PAYMENT_CONFIG.totalPrice}), or ${PAYMENT_CONFIG.oneTimePrice} as a single payment. Both include the full trip for two adults plus kids under 12. }, + { q: 'Who's covered? Can I bring my kids?', + a: <>The certificate covers two adults plus children under 12, free. Children 12 and older count as additional adults at the resort's prevailing rate. }, + { q: 'When can I travel? Are dates restricted?', + a: <>You can book any available date within 18 months of purchase. Like any resort, the most popular weeks (Christmas, spring break, July 4) book up fast and may carry a small high-season fee paid at the resort. We'll show available dates when you log into your portal. }, + { q: 'Do I have to attend a timeshare presentation?', + a: <>Our standard certificate may include a brief resort presentation as part of the discounted pricing — this is normal for vacation-certificate offers and we're upfront about it. The presentation is optional to act on; many guests simply attend, decline, and continue enjoying their stay. Details and any exemptions for your specific certificate are listed on your booking confirmation. }, + { q: 'Is the resort I stay at a real five-star resort?', + a: <>Yes — the same beachfront properties listed on Booking, Expedia, and resort sites at full price. We'll show you the resort name and link to its full reviews when you select your dates. }, + { q: 'Why is this so much cheaper than booking direct?', + a: <>Resorts make their margin on food, drinks, and excursions — not the room. Filling rooms in advance through partners like us is more profitable to them than leaving rooms empty, so they discount the room rate aggressively. More on how this works. }, + { q: 'Is there a money-back guarantee?', + a: <>Yes — full refund within 30 days of purchase, no questions asked. }, + { q: 'How do I book my trip after I buy?', + a: <>After your first payment you get login credentials to the client portal. Inside, you pick your destination, dates, and resort from available inventory. We confirm by email and you arrive at the resort with your reservation in your name. }, + { q: 'How do I pay?', + a: <>Any major credit or debit card (Visa, Mastercard, AmEx, Discover). Monthly plan: card is charged automatically each month for {PAYMENT_CONFIG.totalMonths} months. One-time plan: charged once at signup. Card information is encrypted and not stored on our servers. }, + { q: 'Can I gift a certificate?', + a: <>Yes. Buy a certificate in your name, then transfer it to the recipient through your client portal. Travel dates can be picked by either of you. }, + { q: 'What if I want to cancel the monthly payments after I've traveled?', + a: <>The monthly plan is a {PAYMENT_CONFIG.totalMonths}-payment commitment for the certificate (total ${PAYMENT_CONFIG.totalPrice}). To avoid the remaining payments, pick the one-time ${PAYMENT_CONFIG.oneTimePrice} option at signup. }, + { q: 'Where can I see real reviews?', + a: <>Our reviews page has testimonials from real travelers. We also share TikTok and Instagram videos of travelers at our resorts on the landing pages. }, + { q: 'Who do I contact with questions?', + a: <>Email support@hi2b.com or call 888-602-2424 during US business hours. }, +] + +export default function FAQPage() { + return ( +
+
+
+ +
+ +
+ Mexico Paradise Vacations + + Back to Home +
+
+ +
+

Frequently Asked Questions

+

Real answers to what people actually ask before booking.

+ +
+ {FAQ.map((item, i) => ( +
+ + + + + +
{item.a}
+
+ ))} +
+ +
+

Ready to claim your certificate?

+ + Get Started — From ${PAYMENT_CONFIG.monthlyPrice}/mo + +
+
+
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e9179cf..119ea99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Script from "next/script"; import "./globals.css"; import { Toaster } from "@/components/ui/toaster"; +import TikTokPixel from "@/components/TikTokPixel"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -14,24 +16,24 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Z.ai Code Scaffold - AI-Powered Development", - description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.", - keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"], - authors: [{ name: "Z.ai Team" }], + title: "Mexico Paradise Vacations — All-Inclusive Vacation Certificates", + description: "5 days, 4 nights all-inclusive Mexico vacation for just $29/month. Cancun, Cabo, Riviera Maya, Puerto Vallarta. 100% money-back guarantee.", + keywords: ["Mexico vacation", "all-inclusive", "Cancun", "Cabo", "vacation certificate", "budget travel"], + authors: [{ name: "Mexico Paradise Vacations" }], icons: { - icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg", + icon: "/favicon.svg", }, openGraph: { - title: "Z.ai Code Scaffold", - description: "AI-powered development with modern React stack", - url: "https://chat.z.ai", - siteName: "Z.ai", + title: "Mexico Paradise Vacations — $29/mo All-Inclusive", + description: "5 days, 4 nights all-inclusive Mexico vacation. Cancun, Cabo, Riviera Maya, Puerto Vallarta. Book after first payment.", + url: "https://hi2b.com", + siteName: "Mexico Paradise Vacations", type: "website", }, twitter: { card: "summary_large_image", - title: "Z.ai Code Scaffold", - description: "AI-powered development with modern React stack", + title: "Mexico Paradise Vacations — $29/mo All-Inclusive", + description: "5 days, 4 nights all-inclusive Mexico vacation. 100% money-back guarantee.", }, }; @@ -42,6 +44,21 @@ export default function RootLayout({ }>) { return ( + + + + diff --git a/src/app/lp/[slug]/page.tsx b/src/app/lp/[slug]/page.tsx new file mode 100644 index 0000000..509d317 --- /dev/null +++ b/src/app/lp/[slug]/page.tsx @@ -0,0 +1,83 @@ +import dynamic from 'next/dynamic' +import { notFound } from 'next/navigation' +import { getLPConfigBySlug, LP_CONFIGS } from '../_config/pages' + +// Dynamic imports for each LP component +const LP_COMPONENTS: Record> = { + LP01GoldenHour: dynamic(() => import('@/components/lp/pages/LP01GoldenHour')), + LP02MidnightTropical: dynamic(() => import('@/components/lp/pages/LP02MidnightTropical')), + LP03PassportStamp: dynamic(() => import('@/components/lp/pages/LP03PassportStamp')), + LP04CrystalClear: dynamic(() => import('@/components/lp/pages/LP04CrystalClear')), + LP05Fiesta: dynamic(() => import('@/components/lp/pages/LP05Fiesta')), + LP06TheCloser: dynamic(() => import('@/components/lp/pages/LP06TheCloser')), + LP07ResortPreview: dynamic(() => import('@/components/lp/pages/LP07ResortPreview')), + LP08SplitDecision: dynamic(() => import('@/components/lp/pages/LP08SplitDecision')), + LP09Calculator: dynamic(() => import('@/components/lp/pages/LP09Calculator')), + LP10Countdown: dynamic(() => import('@/components/lp/pages/LP10Countdown')), + LP11TheGuide: dynamic(() => import('@/components/lp/pages/LP11TheGuide')), + LP12Dreamboard: dynamic(() => import('@/components/lp/pages/LP12Dreamboard')), + LP13QuizFunnel: dynamic(() => import('@/components/lp/pages/LP13QuizFunnel')), + LP14SocialWall: dynamic(() => import('@/components/lp/pages/LP14SocialWall')), + LP15SavingsJournal: dynamic(() => import('@/components/lp/pages/LP15SavingsJournal')), + LP16CouplesRetreat: dynamic(() => import('@/components/lp/pages/LP16CouplesRetreat')), + LP17Postcards: dynamic(() => import('@/components/lp/pages/LP17Postcards')), + LP18StressRelief: dynamic(() => import('@/components/lp/pages/LP18StressRelief')), + LP19FoodieParadise: dynamic(() => import('@/components/lp/pages/LP19FoodieParadise')), + LP20FamilyEscape: dynamic(() => import('@/components/lp/pages/LP20FamilyEscape')), + // V2 Pages (21-40) + LP21LastChance: dynamic(() => import('@/components/lp/pages/LP21LastChance')), + LP22TheProof: dynamic(() => import('@/components/lp/pages/LP22TheProof')), + LP23VIPAccess: dynamic(() => import('@/components/lp/pages/LP23VIPAccess')), + LP24OneTap: dynamic(() => import('@/components/lp/pages/LP24OneTap')), + LP25FOMOFeed: dynamic(() => import('@/components/lp/pages/LP25FOMOFeed')), + LP26PriceLock: dynamic(() => import('@/components/lp/pages/LP26PriceLock')), + LP27BeforeAfter: dynamic(() => import('@/components/lp/pages/LP27BeforeAfter')), + LP28RiskFree: dynamic(() => import('@/components/lp/pages/LP28RiskFree')), + LP29SpeedDeal: dynamic(() => import('@/components/lp/pages/LP29SpeedDeal')), + LP30Influencer: dynamic(() => import('@/components/lp/pages/LP30Influencer')), + LP31BucketList: dynamic(() => import('@/components/lp/pages/LP31BucketList')), + LP32DealBreaker: dynamic(() => import('@/components/lp/pages/LP32DealBreaker')), + LP33EscapePlan: dynamic(() => import('@/components/lp/pages/LP33EscapePlan')), + LP34TikTokVibes: dynamic(() => import('@/components/lp/pages/LP34TikTokVibes')), + LP35NoBrainer: dynamic(() => import('@/components/lp/pages/LP35NoBrainer')), + LP36WeekendEscape: dynamic(() => import('@/components/lp/pages/LP36WeekendEscape')), + LP37TrustFall: dynamic(() => import('@/components/lp/pages/LP37TrustFall')), + LP38Sunrise: dynamic(() => import('@/components/lp/pages/LP38Sunrise')), + LP39Adrenaline: dynamic(() => import('@/components/lp/pages/LP39Adrenaline')), + LP40GoldenTicket: dynamic(() => import('@/components/lp/pages/LP40GoldenTicket')), + // V3 Pages (41-42) + LP41SeatReserved: dynamic(() => import('@/components/lp/pages/LP41SeatReserved')), + LP42VIPPass: dynamic(() => import('@/components/lp/pages/LP42VIPPass')), + // V4 (43) + LP43RealTraveler: dynamic(() => import('@/components/lp/pages/LP43RealTraveler')), +} + +export function generateStaticParams() { + const params: { slug: string }[] = [] + for (const lp of LP_CONFIGS) { + params.push({ slug: lp.slug }) + params.push({ slug: lp.id.toString() }) + } + return params +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const config = getLPConfigBySlug(slug) + if (!config) return {} + return { + title: config.metadata.title, + description: config.metadata.description, + } +} + +export default async function LPPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const config = getLPConfigBySlug(slug) + if (!config) notFound() + + const Component = LP_COMPONENTS[config.component] + if (!Component) notFound() + + return +} diff --git a/src/app/lp/_config/fonts.ts b/src/app/lp/_config/fonts.ts new file mode 100644 index 0000000..6ea0d8b --- /dev/null +++ b/src/app/lp/_config/fonts.ts @@ -0,0 +1,132 @@ +import { + Playfair_Display, + Source_Sans_3, + Outfit, + Inter, + Libre_Baskerville, + Lato, + Caveat, + DM_Sans, + Archivo_Black, + Nunito, + Oswald, + Roboto, + Cormorant_Garamond, + Raleway, + Space_Grotesk, + Work_Sans, + IBM_Plex_Sans, + Bebas_Neue, + Barlow, + Merriweather, + Open_Sans, + Poppins, + Quicksand, + Manrope, + Bitter, + Lora, + Karla, + Josefin_Sans, + Nunito_Sans, + Dancing_Script, + Crimson_Pro, + Jost, + Fira_Sans, + Lexend, +} from 'next/font/google' + +// LP1: Golden Hour +export const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair', display: 'swap' }) +export const sourceSans3 = Source_Sans_3({ subsets: ['latin'], variable: '--font-source-sans', display: 'swap' }) + +// LP2: Midnight Tropical +export const outfit = Outfit({ subsets: ['latin'], variable: '--font-outfit', display: 'swap' }) +export const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }) + +// LP3: Passport Stamp +export const libreBaskerville = Libre_Baskerville({ subsets: ['latin'], weight: ['400', '700'], variable: '--font-libre', display: 'swap' }) +export const lato = Lato({ subsets: ['latin'], weight: ['400', '700'], variable: '--font-lato', display: 'swap' }) +export const caveat = Caveat({ subsets: ['latin'], variable: '--font-caveat', display: 'swap' }) + +// LP4: Crystal Clear +export const dmSans = DM_Sans({ subsets: ['latin'], variable: '--font-dm-sans', display: 'swap' }) + +// LP5: Fiesta +export const archivoBlack = Archivo_Black({ subsets: ['latin'], weight: '400', variable: '--font-archivo', display: 'swap' }) +export const nunito = Nunito({ subsets: ['latin'], variable: '--font-nunito', display: 'swap' }) + +// LP6: The Closer +export const oswald = Oswald({ subsets: ['latin'], variable: '--font-oswald', display: 'swap' }) +export const roboto = Roboto({ subsets: ['latin'], variable: '--font-roboto', display: 'swap' }) + +// LP7: Resort Preview +export const cormorantGaramond = Cormorant_Garamond({ subsets: ['latin'], weight: ['400', '600', '700'], variable: '--font-cormorant', display: 'swap' }) +export const raleway = Raleway({ subsets: ['latin'], variable: '--font-raleway', display: 'swap' }) + +// LP8: Split Decision +export const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap' }) +export const workSans = Work_Sans({ subsets: ['latin'], variable: '--font-work-sans', display: 'swap' }) + +// LP9: Calculator +export const ibmPlexSans = IBM_Plex_Sans({ subsets: ['latin'], weight: ['400', '600', '700'], variable: '--font-ibm-plex', display: 'swap' }) + +// LP10: Countdown +export const bebasNeue = Bebas_Neue({ subsets: ['latin'], weight: '400', variable: '--font-bebas', display: 'swap' }) +export const barlow = Barlow({ subsets: ['latin'], weight: ['400', '600', '700'], variable: '--font-barlow', display: 'swap' }) + +// LP11: The Guide +export const merriweather = Merriweather({ subsets: ['latin'], weight: ['400', '700'], variable: '--font-merriweather', display: 'swap' }) +export const openSans = Open_Sans({ subsets: ['latin'], variable: '--font-open-sans', display: 'swap' }) + +// LP12: Dreamboard +export const poppins = Poppins({ subsets: ['latin'], weight: ['400', '600', '700'], variable: '--font-poppins', display: 'swap' }) +export const quicksand = Quicksand({ subsets: ['latin'], variable: '--font-quicksand', display: 'swap' }) + +// LP13: Quiz Funnel +export const manrope = Manrope({ subsets: ['latin'], variable: '--font-manrope', display: 'swap' }) + +// LP15: Savings Journal +export const bitter = Bitter({ subsets: ['latin'], variable: '--font-bitter', display: 'swap' }) + +// LP16: Couples Retreat +export const lora = Lora({ subsets: ['latin'], variable: '--font-lora', display: 'swap' }) +export const karla = Karla({ subsets: ['latin'], variable: '--font-karla', display: 'swap' }) + +// LP17: Postcards +export const josefinSans = Josefin_Sans({ subsets: ['latin'], variable: '--font-josefin', display: 'swap' }) +export const nunitoSans = Nunito_Sans({ subsets: ['latin'], variable: '--font-nunito-sans', display: 'swap' }) +export const dancingScript = Dancing_Script({ subsets: ['latin'], variable: '--font-dancing', display: 'swap' }) + +// LP18: Stress Relief +export const crimsonPro = Crimson_Pro({ subsets: ['latin'], variable: '--font-crimson', display: 'swap' }) +export const jost = Jost({ subsets: ['latin'], variable: '--font-jost', display: 'swap' }) + +// LP19: Foodie Paradise (reuses playfairDisplay) +export const firaSans = Fira_Sans({ subsets: ['latin'], weight: ['400', '600', '700'], variable: '--font-fira', display: 'swap' }) + +// LP20: Family Escape +export const lexend = Lexend({ subsets: ['latin'], variable: '--font-lexend', display: 'swap' }) + +// Font groupings by LP for the layout to load only needed fonts +export const LP_FONTS: Record = { + 'golden-hour': ['--font-playfair', '--font-source-sans'], + 'midnight-tropical': ['--font-outfit', '--font-inter'], + 'passport-stamp': ['--font-libre', '--font-lato', '--font-caveat'], + 'crystal-clear': ['--font-dm-sans'], + 'fiesta': ['--font-archivo', '--font-nunito'], + 'the-closer': ['--font-oswald', '--font-roboto'], + 'resort-preview': ['--font-cormorant', '--font-raleway'], + 'split-decision': ['--font-space-grotesk', '--font-work-sans'], + 'calculator': ['--font-ibm-plex'], + 'countdown': ['--font-bebas', '--font-barlow'], + 'the-guide': ['--font-merriweather', '--font-open-sans'], + 'dreamboard': ['--font-poppins', '--font-quicksand'], + 'quiz-funnel': ['--font-manrope', '--font-inter'], + 'social-wall': ['--font-dm-sans'], + 'savings-journal': ['--font-bitter', '--font-source-sans'], + 'couples-retreat': ['--font-lora', '--font-karla'], + 'postcards': ['--font-josefin', '--font-nunito-sans', '--font-dancing'], + 'stress-relief': ['--font-crimson', '--font-jost'], + 'foodie-paradise': ['--font-playfair', '--font-fira'], + 'family-escape': ['--font-lexend'], +} diff --git a/src/app/lp/_config/pages.ts b/src/app/lp/_config/pages.ts new file mode 100644 index 0000000..0eb07b7 --- /dev/null +++ b/src/app/lp/_config/pages.ts @@ -0,0 +1,59 @@ +import type { LPConfig } from './types' + +export const LP_CONFIGS: LPConfig[] = [ + { id: 1, slug: 'golden-hour', name: 'Golden Hour', component: 'LP01GoldenHour', ctaFocus: 'pay-now', metadata: { title: 'Golden Hour — Mexico Paradise Vacations', description: 'Escape to paradise. 5 days, 4 nights all-inclusive Mexico vacation for just $29/month.' } }, + { id: 2, slug: 'midnight-tropical', name: 'Midnight Tropical', component: 'LP02MidnightTropical', ctaFocus: 'pay-now', metadata: { title: 'Exclusive Access — Mexico Paradise Vacations', description: 'Limited spots remaining. Claim your all-inclusive Mexico getaway.' } }, + { id: 3, slug: 'passport-stamp', name: 'Passport Stamp', component: 'LP03PassportStamp', ctaFocus: 'pay-now', metadata: { title: 'Your Next Stamp — Mexico Paradise Vacations', description: 'Adventure awaits. All-inclusive Mexico vacation certificates from $29/month.' } }, + { id: 4, slug: 'crystal-clear', name: 'Crystal Clear', component: 'LP04CrystalClear', ctaFocus: 'pay-now', metadata: { title: '5 Days. 4 Nights. $29/month. — Mexico Paradise', description: 'All-inclusive Mexico vacation. Simple pricing. Incredible value.' } }, + { id: 5, slug: 'fiesta', name: 'Fiesta', component: 'LP05Fiesta', ctaFocus: 'pay-now', metadata: { title: 'Fiesta! — Mexico Paradise Vacations', description: 'Celebrate life with an all-inclusive Mexico vacation from $29/month.' } }, + { id: 6, slug: 'the-closer', name: 'The Closer', component: 'LP06TheCloser', ctaFocus: 'pay-now', metadata: { title: '$3,000 Value for $1.30/day — Mexico Paradise', description: 'The math doesn\'t lie. All-inclusive Mexico vacation for less than your daily coffee.' } }, + { id: 7, slug: 'resort-preview', name: 'Resort Preview', component: 'LP07ResortPreview', ctaFocus: 'pay-now', metadata: { title: 'Tour Your Paradise — Mexico Paradise Vacations', description: 'Preview luxury resorts in Cancun, Cabo, Riviera Maya & Puerto Vallarta.' } }, + { id: 8, slug: 'split-decision', name: 'Split Decision', component: 'LP08SplitDecision', ctaFocus: 'pay-now', metadata: { title: 'Choose Your Paradise — Mexico Paradise Vacations', description: 'Cancun or Cabo? Pick your dream destination. All-inclusive from $29/month.' } }, + { id: 9, slug: 'calculator', name: 'Calculator', component: 'LP09Calculator', ctaFocus: 'pay-now', metadata: { title: 'The Savings Calculator — Mexico Paradise', description: 'See exactly how much you save vs. booking direct. The math speaks for itself.' } }, + { id: 10, slug: 'countdown', name: 'Countdown', component: 'LP10Countdown', ctaFocus: 'pay-now', metadata: { title: 'Time Is Running Out — Mexico Paradise Vacations', description: 'Limited time offer. Claim your all-inclusive Mexico vacation before it\'s gone.' } }, + { id: 11, slug: 'the-guide', name: 'The Guide', component: 'LP11TheGuide', ctaFocus: 'ebook', metadata: { title: 'Budget Luxury Travel Guide — Mexico Paradise', description: 'Free guide: 5 secrets to luxury Mexico vacations on a budget.' } }, + { id: 12, slug: 'dreamboard', name: 'Dreamboard', component: 'LP12Dreamboard', ctaFocus: 'ebook', metadata: { title: 'Build Your Dream Vacation — Mexico Paradise', description: 'Visualize your perfect Mexico getaway. Get the free planning guide.' } }, + { id: 13, slug: 'quiz-funnel', name: 'Quiz Funnel', component: 'LP13QuizFunnel', ctaFocus: 'ebook', metadata: { title: 'Find Your Perfect Destination — Mexico Paradise', description: 'Take the quiz to find your ideal Mexico vacation destination.' } }, + { id: 14, slug: 'social-wall', name: 'Social Wall', component: 'LP14SocialWall', ctaFocus: 'ebook', metadata: { title: 'Join 2,847 Happy Travelers — Mexico Paradise', description: 'See what real travelers are saying about Mexico Paradise Vacations.' } }, + { id: 15, slug: 'savings-journal', name: 'Savings Journal', component: 'LP15SavingsJournal', ctaFocus: 'ebook', metadata: { title: 'Your Vacation Savings Plan — Mexico Paradise', description: '$1.30/day is less than your latte. Start saving for paradise.' } }, + { id: 16, slug: 'couples-retreat', name: 'Couples Retreat', component: 'LP16CouplesRetreat', ctaFocus: 'ebook', metadata: { title: 'You Both Deserve This — Mexico Paradise', description: 'Plan the romantic Mexico getaway you\'ve been dreaming about.' } }, + { id: 17, slug: 'postcards', name: 'Postcards', component: 'LP17Postcards', ctaFocus: 'ebook', metadata: { title: 'Wish You Were Here — Mexico Paradise Vacations', description: 'Send yourself a postcard from the future. Mexico awaits.' } }, + { id: 18, slug: 'stress-relief', name: 'Stress Relief', component: 'LP18StressRelief', ctaFocus: 'ebook', metadata: { title: 'Your Mind Needs a Beach — Mexico Paradise', description: 'Escape the stress. All-inclusive Mexico vacation for your wellbeing.' } }, + { id: 19, slug: 'foodie-paradise', name: 'Foodie Paradise', component: 'LP19FoodieParadise', ctaFocus: 'ebook', metadata: { title: 'Unlimited Everything — Mexico Paradise', description: 'All-inclusive dining at world-class Mexico resorts. From $29/month.' } }, + { id: 20, slug: 'family-escape', name: 'Family Escape', component: 'LP20FamilyEscape', ctaFocus: 'ebook', metadata: { title: 'Give Them the Vacation They Deserve — Mexico Paradise', description: 'Family-friendly all-inclusive Mexico vacations from $29/month.' } }, + // V2 Landing Pages (21-40) — improved with urgency psychology, TikTok embeds, stronger CTAs + { id: 21, slug: 'last-chance', name: 'Last Chance', component: 'LP21LastChance', ctaFocus: 'pay-now', metadata: { title: 'LAST CHANCE — Lock In $29/mo Before Midnight', description: 'This price disappears in minutes. All-inclusive Mexico vacation.' } }, + { id: 22, slug: 'proof', name: 'The Proof', component: 'LP22TheProof', ctaFocus: 'pay-now', metadata: { title: '2,847 Happy Travelers Can\'t Be Wrong', description: 'Watch real TikTok videos from travelers at our resorts.' } }, + { id: 23, slug: 'vip-access', name: 'VIP Access', component: 'LP23VIPAccess', ctaFocus: 'pay-now', metadata: { title: 'VIP ACCESS — Invitation Only Pricing', description: 'You\'ve been selected for exclusive resort pricing. Don\'t let this expire.' } }, + { id: 24, slug: 'one-tap', name: 'One Tap', component: 'LP24OneTap', ctaFocus: 'pay-now', metadata: { title: 'One Tap Away From Paradise', description: 'The simplest way to book your dream Mexico vacation. $29/mo.' } }, + { id: 25, slug: 'fomo-feed', name: 'FOMO Feed', component: 'LP25FOMOFeed', ctaFocus: 'pay-now', metadata: { title: 'Everyone\'s Going to Mexico — Why Aren\'t You?', description: 'See what you\'re missing. Real travelers, real paradise, real cheap.' } }, + { id: 26, slug: 'price-lock', name: 'Price Lock', component: 'LP26PriceLock', ctaFocus: 'pay-now', metadata: { title: 'PRICE LOCK — $29/mo Guaranteed for 30 Minutes', description: 'After this timer expires, the price goes up. Lock it in now.' } }, + { id: 27, slug: 'before-after', name: 'Before & After', component: 'LP27BeforeAfter', ctaFocus: 'pay-now', metadata: { title: 'Your Life Before & After Mexico', description: 'See the transformation. Desk to beach in one payment.' } }, + { id: 28, slug: 'risk-free', name: 'Risk Free', component: 'LP28RiskFree', ctaFocus: 'pay-now', metadata: { title: 'Zero Risk, All Reward — 30-Day Money-Back', description: 'Try it risk-free. If you\'re not amazed, get every penny back.' } }, + { id: 29, slug: 'speed-deal', name: 'Speed Deal', component: 'LP29SpeedDeal', ctaFocus: 'pay-now', metadata: { title: '⚡ FLASH DEAL — 30 Minutes Only', description: 'This deal self-destructs. All-inclusive Mexico from $1.30/day.' } }, + { id: 30, slug: 'influencer', name: 'Influencer', component: 'LP30Influencer', ctaFocus: 'pay-now', metadata: { title: 'As Seen on TikTok — Mexico Paradise', description: 'The vacation deal going viral. Watch the videos, book the trip.' } }, + { id: 31, slug: 'bucket-list', name: 'Bucket List', component: 'LP31BucketList', ctaFocus: 'ebook', metadata: { title: 'Check Mexico Off Your Bucket List', description: 'Life\'s too short for "someday." Get the free planning guide.' } }, + { id: 32, slug: 'deal-breaker', name: 'Deal Breaker', component: 'LP32DealBreaker', ctaFocus: 'ebook', metadata: { title: 'The Deal That Breaks All Other Deals', description: 'Compare us to any travel site. We win every time.' } }, + { id: 33, slug: 'escape-plan', name: 'Escape Plan', component: 'LP33EscapePlan', ctaFocus: 'ebook', metadata: { title: 'Your Escape Plan Starts Here', description: 'Download your free Mexico vacation planning guide.' } }, + { id: 34, slug: 'tiktok-vibes', name: 'TikTok Vibes', component: 'LP34TikTokVibes', ctaFocus: 'ebook', metadata: { title: 'The TikTok-Famous Mexico Vacation', description: 'See why this deal is going viral. Get the free insider guide.' } }, + { id: 35, slug: 'no-brainer', name: 'No Brainer', component: 'LP35NoBrainer', ctaFocus: 'ebook', metadata: { title: 'This Is a No-Brainer — Here\'s Why', description: '$1.30/day for luxury. We\'ll prove it. Get the free breakdown.' } }, + { id: 36, slug: 'weekend-escape', name: 'Weekend Escape', component: 'LP36WeekendEscape', ctaFocus: 'ebook', metadata: { title: 'Turn Any Week Into Paradise', description: '5 days that will change how you think about vacations.' } }, + { id: 37, slug: 'trust-fall', name: 'Trust Fall', component: 'LP37TrustFall', ctaFocus: 'ebook', metadata: { title: 'Don\'t Trust Us — Trust 2,847 Travelers', description: 'Real reviews, real videos, real people. See for yourself.' } }, + { id: 38, slug: 'sunrise', name: 'Sunrise', component: 'LP38Sunrise', ctaFocus: 'ebook', metadata: { title: 'Wake Up to Paradise — Mexico Awaits', description: 'Imagine waking up to ocean views. Get the free travel guide.' } }, + { id: 39, slug: 'adrenaline', name: 'Adrenaline', component: 'LP39Adrenaline', ctaFocus: 'ebook', metadata: { title: 'Adventure Awaits in Mexico', description: 'Ziplines, cenotes, ruins — plus all-inclusive luxury. From $29/mo.' } }, + { id: 40, slug: 'golden-ticket', name: 'Golden Ticket', component: 'LP40GoldenTicket', ctaFocus: 'ebook', metadata: { title: 'You Found the Golden Ticket', description: 'This exclusive offer won\'t last. Claim your all-inclusive paradise.' } }, + // V3 Landing Pages (41-42) — inspired by challenge-style order pages with transformation stories + tier pricing + { id: 41, slug: 'seat-reserved', name: 'Seat Reserved', component: 'LP41SeatReserved', ctaFocus: 'pay-now', metadata: { title: 'Your Seat Is Reserved — Mexico Paradise Vacations', description: 'Your paradise seat is confirmed. Lock in $29/mo before the countdown ends.' } }, + { id: 42, slug: 'vip-pass', name: 'VIP Pass', component: 'LP42VIPPass', ctaFocus: 'pay-now', metadata: { title: 'VIP Platinum Access — Mexico Paradise Vacations', description: 'Private concierge, lifetime rebooking, guest upgrades. VIP cohort closes at midnight.' } }, + // V4 — Real-traveler UGC video lead, 7-section blueprint + { id: 43, slug: 'real-traveler', name: 'Real Traveler', component: 'LP43RealTraveler', ctaFocus: 'pay-now', metadata: { title: 'She Paid $290 for a 5-Star Mexico Vacation — Watch', description: 'Real traveler · Day 4 in Mexico · Same resort her friends paid $2,800 for. See her 20-second story.' } }, +] + +export function getLPConfigBySlug(slug: string): LPConfig | undefined { + return LP_CONFIGS.find(lp => lp.slug === slug || lp.id.toString() === slug) +} + +export function getRandomLPSlug(): string { + const idx = Math.floor(Math.random() * LP_CONFIGS.length) + return LP_CONFIGS[idx].slug +} diff --git a/src/app/lp/_config/types.ts b/src/app/lp/_config/types.ts new file mode 100644 index 0000000..2bb8307 --- /dev/null +++ b/src/app/lp/_config/types.ts @@ -0,0 +1,155 @@ +export interface LPConfig { + id: number + slug: string + name: string + component: string // path for dynamic import + ctaFocus: 'pay-now' | 'ebook' | 'both' + metadata: { + title: string + description: string + } +} + +export interface FormData { + email: string + fullName: string + phone: string +} + +export interface EbookFormData { + email: string + name?: string + source_lp: string +} + +export interface PaymentConfig { + monthlyPrice: number + totalMonths: number + totalPrice: number + oneTimePrice: number +} + +export const PAYMENT_CONFIG: PaymentConfig = { + monthlyPrice: 29, + totalMonths: 10, + totalPrice: 290, + oneTimePrice: 249, +} + +export interface TestimonialData { + quote: string + name: string + location: string + photo?: string +} + +export const TESTIMONIALS: TestimonialData[] = [ + { + quote: "This was the best vacation we've ever had! The all-inclusive resort in Cancun was amazing, and the price was unbeatable.", + name: "Sarah & Mike", + location: "Chicago, IL", + photo: "/images/cdn/photo-1522529599102-193c0d76b5b6.jpg", + }, + { + quote: "I was skeptical at first, but the process was so simple and the vacation exceeded all our expectations. Cabo is breathtaking!", + name: "Jennifer & Tom", + location: "New York, NY", + photo: "/images/cdn/photo-1494790108377-be9c29b29330.jpg", + }, + { + quote: "We paid less than $400 total for a vacation that would normally cost $3,000+. The resort was 5-star quality!", + name: "Maria G.", + location: "Houston, TX", + photo: "/images/cdn/photo-1438761681033-6461ffad8d80.jpg", + }, + { + quote: "The Riviera Maya resort blew our minds. Crystal clear cenotes, amazing food, and world-class service.", + name: "David & Lisa", + location: "Denver, CO", + photo: "/images/cdn/photo-1472099645785-5658abf4ff4e.jpg", + }, + { + quote: "Puerto Vallarta was paradise. We extended our stay an extra 3 nights because we didn't want to leave!", + name: "Rachel T.", + location: "Phoenix, AZ", + photo: "/images/cdn/photo-1544005313-94ddf0286df2.jpg", + }, + { + quote: "Best money I ever spent. The sunset views from our room in Cabo were worth ten times what we paid.", + name: "James & Patricia", + location: "Miami, FL", + photo: "/images/cdn/photo-1500648767791-00dcc994a43e.jpg", + }, +] + +export interface DestinationData { + name: string + tagline: string + images: string[] +} + +export const DESTINATIONS: DestinationData[] = [ + { + name: "Cancun", + tagline: "Pristine beaches & vibrant nightlife", + images: [ + "/images/cdn/photo-1510097467424-192d713fd8b2.jpg", + "/images/cdn/photo-1552074284-5e88ef1aef18.jpg", + "/images/cdn/photo-1510097467424-192d713fd8b2.jpg", + ], + }, + { + name: "Cabo San Lucas", + tagline: "Dramatic cliffs & luxury resorts", + images: [ + "/images/cdn/photo-1593655600619-a88c11180241.jpg", + "/images/cdn/photo-1580846629083-02669741360a.jpg", + "/images/cdn/photo-1527734055665-8def83921139.jpg", + ], + }, + { + name: "Riviera Maya", + tagline: "Ancient ruins & turquoise waters", + images: [ + "/images/cdn/photo-1581710862235-eb6e05d8783f.jpg", + "/images/cdn/photo-1581710862235-eb6e05d8783f.jpg", + "/images/cdn/photo-1581710862235-eb6e05d8783f.jpg", + ], + }, + { + name: "Puerto Vallarta", + tagline: "Stunning sunsets & rich culture", + images: [ + "/images/cdn/photo-1585793753011-397e6e4668d6.jpg", + "/images/cdn/photo-1575762568427-4b23bf947729.jpg", + "/images/cdn/photo-1585793753011-397e6e4668d6.jpg", + ], + }, +] + +export const FAQ_ITEMS = [ + { + question: "When can I travel?", + answer: "You can book your vacation for any available dates within 18 months of your first payment. Some blackout dates may apply during peak holiday seasons.", + }, + { + question: "What's included in 'all-inclusive'?", + answer: "Your all-inclusive package includes accommodation, all meals, drinks (including alcoholic beverages), resort amenities, and access to beaches and pools.", + }, + { + question: "Can I bring my partner?", + answer: "Yes! Each certificate covers a family of four — 2 adults and 2 kids under 12. Additional guests can be added at a discounted rate.", + }, + { + question: "Is there a catch?", + answer: "This special offer is part of our 'Hour to Paradise' program. To receive this deeply discounted vacation rate, we ask that you attend a 90-minute resort tour and presentation about Vacation ownership benefits. There is no obligation to purchase.", + }, + { + question: "Can I get a refund?", + answer: "Yes, we offer a full refund within 30 days of purchase if you haven't booked your travel dates yet.", + }, + { + question: "Can I extend my stay?", + answer: "Yes, you can extend your stay at the same resort for additional nights at a special discounted rate available only to certificate holders.", + }, +] diff --git a/src/app/lp/layout.tsx b/src/app/lp/layout.tsx new file mode 100644 index 0000000..918c8da --- /dev/null +++ b/src/app/lp/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import FloatingPhoneButton from '@/components/lp/shared/FloatingPhoneButton' +import TopPhoneBar from '@/components/lp/shared/TopPhoneBar' + +export const metadata: Metadata = { + title: 'Mexico Paradise Vacations — All-Inclusive Vacation Certificates', + description: '5 days, 4 nights all-inclusive Mexico vacation for just $29/month. Cancun, Cabo, Riviera Maya, Puerto Vallarta.', +} + +export default function LPLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} + +
+ ) +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..8bc0127 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation' + +// Catch-all 404 handler — any unmatched route (typos, removed pages, old +// links, /lp/, bot probes) redirects to the prime landing +// page instead of showing the default 404, so no ad traffic is lost. +export default function NotFound() { + redirect('/lp/golden-hour') +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 91a3f48..cb5634e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,441 +1,23 @@ 'use client' -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Loader2, CheckCircle, LogIn, Plane, Calendar, Hotel, Coffee, Utensils, Umbrella, HelpCircle } from 'lucide-react' -import { toast } from 'sonner' -import { LoginModal } from '@/components/login-modal' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { LP_CONFIGS } from '@/app/lp/_config/pages' export default function Home() { - const [email, setEmail] = useState('') - const [fullName, setFullName] = useState('') - const [phone, setPhone] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [isSuccess, setIsSuccess] = useState(false) - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false) + const router = useRouter() - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!email || !fullName || !phone) { - toast.error('Please fill in all fields') - return - } - - setIsLoading(true) - - try { - // Create signup record - const signupResponse = await fetch('/api/signup', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - full_name: fullName, - phone, - amount: 702, // Total value: $39/month x 18 months - monthly_payment: 39, - payment_plan_months: 18 - }), - }) - - const signupData = await signupResponse.json() - - if (!signupResponse.ok) { - throw new Error(signupData.error || 'Failed to create signup') - } - - // Create payment for first month - const paymentResponse = await fetch('/api/payment/create', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - fullName, - amount: 39, // First month payment - signupId: signupData.id, - isSubscription: true, - }), - }) - - const paymentData = await paymentResponse.json() - - if (!paymentResponse.ok) { - throw new Error(paymentData.error || 'Failed to create payment') - } - - // Redirect to payment checkout - if (paymentData.checkoutUrl) { - window.location.href = paymentData.checkoutUrl - } else { - throw new Error('No checkout URL provided') - } - - } catch (error) { - console.error('Error:', error) - toast.error(error instanceof Error ? error.message : 'An error occurred') - setIsLoading(false) - } - } - - if (isSuccess) { - return ( -
- - - -

Welcome to Paradise!

-

Check your email for your vacation certificate details.

-
-
-
- ) - } + useEffect(() => { + const randomLP = LP_CONFIGS[Math.floor(Math.random() * LP_CONFIGS.length)] + router.replace(`/lp/${randomLP.slug}`) + }, [router]) return ( -
- {/* Header */} -
-
-
-
-
- -
- Mexico Paradise Vacations -
- -
-
-
- - {/* Hero Section */} -
- {/* Hero Banner */} -
-
-

- Escape to Paradise: 5 Days, 4 Nights All-Inclusive Mexico Vacation for Just $39/Month -

-

- Luxury resorts in Cancun, Cabo, Rivera Maya, or Puerto Vallarta await you and your partner -

- -
-
- -
- {/* Left Content */} -
- {/* What's Included */} - - - Your All-Inclusive Paradise Package Includes: - - -
-
- - 5 Days & 4 Nights Accommodation -
-
- - All Meals & Drinks -
-
- - Premium Resort Amenities -
-
- - Access to Beaches & Pools -
-
-
- - Flexible Booking Dates -
-
-

(18-month payment plan at $39/month = $702 total value)

-

- *This special offer is part of our "Hour to Paradise" program. The discounted rate is available because the resort would like to give you a quick tour of facilities and explain benefits of Vacation ownership (90-minute presentation required). -

-
-
-
- - {/* Destinations */} - - - Choose Your Perfect Mexican Paradise - - -
-
- Cancun -
-

Cancun: Pristine beaches & vibrant nightlife

-
-
-
- Cabo San Lucas -
-

Cabo: Dramatic cliffs & luxury resorts

-
-
-
- Rivera Maya -
-

Rivera Maya: Ancient ruins & turquoise waters

-
-
-
- Puerto Vallarta -
-

Puerto Vallarta: Stunning sunsets & culture

-
-
-
-
-
- - {/* How It Works */} - - - How It Works - - -
-
-
1
-

Sign Up & Pay First $39

-
-
-
2
-

Receive Your Certificate

-
-
-
3
-

Book Your Dream Vacation

-
-
-
-
-
- - {/* Right Content - Signup Form */} -
- - -
- Limited Time Offer -
- - Ready for Your Mexican Paradise? - - - Get your all-inclusive vacation certificate today - -
- -
-
- - setFullName(e.target.value)} - className="h-11" - required - /> -
- -
- - setEmail(e.target.value)} - className="h-11" - required - /> -
- -
- - setPhone(e.target.value)} - className="h-11" - required - /> -
- -
-
$39/month
-
for 18 months
-
- Regular price: $1,500+ - Save over $800! -
-

Book immediately after your first payment

-
- - - -

- By signing up, you agree to our payment plan terms and conditions -
- Secure payment powered by Maverick Payments. -

-
-
-
- - {/* Testimonials */} - - - Happy Travelers Love Our Vacation Certificates - - -
-
"
-

- This was the best vacation we've ever had! The all-inclusive resort in Cancun was amazing, and the price was unbeatable. We're already planning our next trip! -

-

- Sarah & Mike, Chicago

-
-
-
"
-

- I was skeptical at first, but the process was so simple and the vacation exceeded all our expectations. Cabo San Lucas is absolutely breathtaking! -

-

- Jennifer & Tom, New York

-
-
-
-
-
- - {/* FAQ Section */} - - - Frequently Asked Questions - - -
-
- -

When can I travel?

-
-

- You can book your vacation for any available dates within 18 months of your first payment. Some blackout dates may apply during peak holiday seasons. -

-
-
-
- -

What's included in 'all-inclusive'?

-
-

- Your all-inclusive package includes accommodation, all meals, drinks (including alcoholic beverages), resort amenities, and access to beaches and pools. -

-
-
-
- -

Can I extend my stay?

-
-

- Yes, you can extend your stay at the same resort for additional nights at a special discounted rate available only to certificate holders. -

-
-
-
- -

What is the "Hour to Paradise" requirement?

-
-

- This special offer is part of our "Hour to Paradise" program. To receive this deeply discounted vacation rate, we ask that you attend a 90-minute resort tour and presentation about Vacation ownership benefits. There is no obligation to purchase. -

-
-
-
-
- - setIsLoginModalOpen(false)} - /> +
+
+
+

Loading your paradise...

+
) -} \ No newline at end of file +} diff --git a/src/app/pay/BenefitsSection.tsx b/src/app/pay/BenefitsSection.tsx new file mode 100644 index 0000000..e2e7415 --- /dev/null +++ b/src/app/pay/BenefitsSection.tsx @@ -0,0 +1,150 @@ +'use client' + +import { Sun, Heart, Brain, Waves, Moon, Smile, Eye, Users, Sparkles, Sandwich, Wind, Coffee } from 'lucide-react' + +interface Bullet { + icon: React.ReactNode + title: string + body: string +} + +const WHAT_YOU_GET: Bullet[] = [ + { icon: , title: '5 days / 4 nights', body: 'in a luxury all-inclusive resort' }, + { icon: , title: 'Family of 4', body: '2 adults + 2 kids under 12 — all covered' }, + { icon: , title: 'Unlimited everything', body: 'food, drinks, premium liquor, swim-up bar' }, + { icon: , title: '4 destinations', body: 'Cancun, Cabo, Riviera Maya, Puerto Vallarta' }, +] + +const FOR_YOUR_BODY: Bullet[] = [ + { icon: , title: 'Vitamin D + circadian reset', body: '5 days of real sunshine resets your sleep and mood chemistry' }, + { icon: , title: 'Ocean swims that don\'t feel like exercise', body: 'salt water, full-body, zero gym energy required' }, + { icon: , title: 'Fresh food, no inflammation', body: 'grilled fish, fresh fruit, real meals — inflammation drops, energy climbs' }, + { icon: , title: 'Sleep without alarms', body: 'no Sunday-night dread. No 6am ping. Just sunrise.' }, +] + +const FOR_YOUR_MIND: Bullet[] = [ + { icon: , title: 'Off the grid', body: 'work email locked in a vault. You\'ll come back sharper than 6 months of "productivity hacks."' }, + { icon: , title: 'Perspective', body: 'looking at the horizon for 5 days makes problems look small. Because they are.' }, + { icon: , title: 'Reconnect', body: 'no phones at dinner. The person across from you remembers what your voice sounds like.' }, + { icon: , title: 'Memories on tap', body: 'the kind you\'ll replay on your worst Tuesdays for the rest of your life' }, +] + +export default function BenefitsSection() { + return ( +
+
+
+

What You Actually Get

+

+ This isn't just a vacation.
+ It's a reset for your body and your mind. +

+

+ Most people don't need another productivity app. They need 5 uninterrupted days where their phone doesn't buzz, the food is fresh, and they remember what their own laugh sounds like. +

+
+ +
+ } + items={WHAT_YOU_GET} + /> + } + items={FOR_YOUR_BODY} + /> + } + items={FOR_YOUR_MIND} + /> +
+ +
+
+

The honest truth

+

+ You're not really buying a vacation.
+ You're buying the version of yourself that comes back from it. +

+

+ Calmer. Lighter. With photos of people you love laughing on a beach.
+ For less than what most people spend on Saturday-night dinners in a month. +

+
+
+
+ ) +} + +interface CardProps { + tone: 'warm' | 'emerald' | 'indigo' + tag: string + title: string + subtitle: string + icon: React.ReactNode + items: Bullet[] +} + +const TONES = { + warm: { + border: 'border-orange-200', + head: 'bg-gradient-to-br from-orange-500 to-amber-500 text-white', + iconBg: 'bg-white/20', + bullet: 'text-orange-600 bg-orange-50', + }, + emerald: { + border: 'border-emerald-200', + head: 'bg-gradient-to-br from-emerald-600 to-teal-600 text-white', + iconBg: 'bg-white/20', + bullet: 'text-emerald-700 bg-emerald-50', + }, + indigo: { + border: 'border-indigo-200', + head: 'bg-gradient-to-br from-indigo-600 to-violet-600 text-white', + iconBg: 'bg-white/20', + bullet: 'text-indigo-700 bg-indigo-50', + }, +} + +function BenefitCard({ tone, tag, title, subtitle, icon, items }: CardProps) { + const t = TONES[tone] + return ( +
+
+
+
+ {icon} +
+ {tag} +
+

{title}

+

{subtitle}

+
+
    + {items.map((b, i) => ( +
  • +
    + {b.icon} +
    +
    +

    {b.title}

    +

    {b.body}

    +
    +
  • + ))} +
+
+ ) +} diff --git a/src/app/pay/PayPageClient.tsx b/src/app/pay/PayPageClient.tsx new file mode 100644 index 0000000..205bcc8 --- /dev/null +++ b/src/app/pay/PayPageClient.tsx @@ -0,0 +1,557 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import Image from 'next/image' +import { + Loader2, Lock, ShieldCheck, CreditCard, CheckCircle, Star, + Plane, Phone, Mail, Check, Headset, Sparkles, X, Undo2, ArrowRight, +} from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { PAYMENT_CONFIG, TESTIMONIALS } from '@/app/lp/_config/types' +import { useEarlyLead } from '@/hooks/useEarlyLead' +import { useTrackingParams } from '@/hooks/useTrackingParams' +import ShowcaseCarousel from './ShowcaseCarousel' +import BenefitsSection from './BenefitsSection' + +const RECENT_BUYERS = [ + { name: 'Sarah from Chicago, IL', when: '2 minutes ago' }, + { name: 'Michael from Austin, TX', when: '7 minutes ago' }, + { name: 'Jennifer from Tampa, FL', when: '12 minutes ago' }, + { name: 'David from Denver, CO', when: '18 minutes ago' }, + { name: 'Maria from Phoenix, AZ', when: '24 minutes ago' }, + { name: 'James from Miami, FL', when: '31 minutes ago' }, + { name: 'Rachel from Seattle, WA', when: '38 minutes ago' }, +] + +const INCLUDED = [ + '5 Days / 4 Nights', + 'All-Inclusive Resort', + 'Unlimited Meals & Drinks', + 'Premium Accommodations', + '4 Destination Choices', + 'Flexible Travel Dates', +] + +const SUPPORT_PHONE = '888-602-2424' + +function formatCardNumber(value: string) { + const digits = value.replace(/\D/g, '').slice(0, 16) + return digits.replace(/(\d{4})(?=\d)/g, '$1 ') +} +function formatExp(value: string) { + const digits = value.replace(/\D/g, '').slice(0, 4) + if (digits.length >= 3) return `${digits.slice(0, 2)}/${digits.slice(2)}` + return digits +} + +export default function PayPageClient() { + const search = useSearchParams() + const repName = search.get('rep') || '' + const initialPlan = (search.get('plan') === 'one-time' ? 'one-time' : 'monthly') as 'monthly' | 'one-time' + const tracking = useTrackingParams('pay-page') + + const [paymentType, setPaymentType] = useState<'monthly' | 'one-time'>(initialPlan) + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [cardNumber, setCardNumber] = useState('') + const [cardExp, setCardExp] = useState('') + const [cardCvv, setCardCvv] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState<{ cert?: string } | null>(null) + + const [tickerIdx, setTickerIdx] = useState(0) + useEffect(() => { + const t = setInterval(() => setTickerIdx(i => (i + 1) % RECENT_BUYERS.length), 4500) + return () => clearInterval(t) + }, []) + + const amount = paymentType === 'monthly' ? PAYMENT_CONFIG.monthlyPrice : PAYMENT_CONFIG.oneTimePrice + const planLabel = paymentType === 'monthly' + ? `$${PAYMENT_CONFIG.monthlyPrice}/mo × ${PAYMENT_CONFIG.totalMonths}` + : `One-time $${PAYMENT_CONFIG.oneTimePrice}` + + const { captured, capture } = useEarlyLead({ + email, phone, name: `${firstName} ${lastName}`.trim() || undefined, + source_lp: 'pay-page', + referral_code: tracking.ref, + utm_source: tracking.utm_source || (repName ? `rep-${repName}` : 'phone-sales'), + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }) + + const ticker = useMemo(() => RECENT_BUYERS[tickerIdx], [tickerIdx]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (!email || !phone || !firstName || !lastName || !cardNumber || !cardExp || !cardCvv) { + setError('Please fill in every field — we need this to issue your certificate.') + return + } + + setIsLoading(true) + try { + const signupRes = await fetch('/api/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + full_name: `${firstName} ${lastName}`, + phone, + amount: paymentType === 'monthly' ? PAYMENT_CONFIG.totalPrice : PAYMENT_CONFIG.oneTimePrice, + monthly_payment: PAYMENT_CONFIG.monthlyPrice, + payment_plan_months: paymentType === 'monthly' ? PAYMENT_CONFIG.totalMonths : 1, + source_lp: 'pay-page', + referral_code: tracking.ref, + utm_source: tracking.utm_source || (repName ? `rep-${repName}` : 'phone-sales'), + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }), + }) + const signupData = await signupRes.json() + if (!signupRes.ok) throw new Error(signupData.error || 'Could not create your account.') + + const payRes = await fetch('/api/payment/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firstName, lastName, email, phone, + cardNumber: cardNumber.replace(/\s/g, ''), + cardExp: cardExp.replace('/', ''), + cardCvv, + paymentType, + signupId: signupData.id, + }), + }) + const payData = await payRes.json() + if (!payRes.ok) { + setError(payData.error || 'Payment was declined. Please check your card details or call us at ' + SUPPORT_PHONE + '.') + return + } + setSuccess({ cert: payData.certificateNumber }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong. Please call us at ' + SUPPORT_PHONE + '.') + } finally { + setIsLoading(false) + } + } + + if (success) return + + return ( +
+ {/* Top trust bar */} +
+
+
+ + Secure 256-bit SSL checkout +
+ + + {SUPPORT_PHONE} + +
+
+ + {/* Header */} +
+
+
+ + Mexico Paradise Vacations +
+
+ {[1, 2, 3, 4, 5].map(i => )} + 4.9 + / 2,847 reviews +
+
+
+ + {/* Live ticker */} +
+
+ + + + + + {ticker.name} just secured a vacation certificate — {ticker.when} + +
+
+ + {/* Hero */} +
+ {repName && ( +
+ + Prepared for you by {repName} +
+ )} + {!repName && tracking.ref && ( +
+ + Referred by {tracking.ref} +
+ )} +

+ Complete Your Vacation Booking +

+

+ 5 days, 4 nights, all-inclusive — choose from Cancun, Cabo, Riviera Maya, or Puerto Vallarta. + Your certificate is issued instantly after payment. +

+
+ + {/* Showcase carousel */} + + + {/* Benefits — body + mind */} + + + {/* Two-column layout */} +
+ + {/* LEFT — form */} +
+
+ + {/* Plan toggle */} +
+

Choose your plan

+
+ + +
+
+ + {/* Contact info */} +
+

Your contact info

+
+
+ + setFirstName(e.target.value)} required className="h-11" /> +
+
+ + setLastName(e.target.value)} required className="h-11" /> +
+
+ +
+ setEmail(e.target.value)} onBlur={capture} + required className={`h-11 ${captured ? 'pr-9' : ''}`} /> + {captured && } +
+
+
+ + setPhone(e.target.value)} onBlur={capture} + placeholder="(555) 123-4567" required className="h-11" /> +
+
+
+ + {/* Card details */} +
+
+

Payment information

+
+ + Encrypted & secure +
+
+
+
+ +
+ setCardNumber(formatCardNumber(e.target.value))} + placeholder="1234 5678 9012 3456" inputMode="numeric" maxLength={19} required + className="h-11 font-mono pr-24" /> +
+ +
+
+
+
+
+ + setCardExp(formatExp(e.target.value))} + placeholder="MM/YY" inputMode="numeric" maxLength={5} required className="h-11 font-mono" /> +
+
+ + setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))} + placeholder="123" inputMode="numeric" maxLength={4} required className="h-11 font-mono" /> +
+
+
+
+ + {/* Charge summary + submit */} +
+
+
+ Today's charge + ${amount.toFixed(2)} +
+

{planLabel}

+ {paymentType === 'monthly' && ( +

+ Then ${PAYMENT_CONFIG.monthlyPrice}/mo × {PAYMENT_CONFIG.totalMonths - 1} more. Cancel anytime. +

+ )} +
+ 100% refund within 30 days, no questions asked +
+
+ + {error && ( +
+ +
+

Payment couldn't go through

+

{error}

+
+
+ )} + + + +

+ By clicking above you authorize {paymentType === 'monthly' + ? `today's $${PAYMENT_CONFIG.monthlyPrice} payment and ${PAYMENT_CONFIG.totalMonths - 1} future monthly charges of $${PAYMENT_CONFIG.monthlyPrice}.` + : `a one-time charge of $${PAYMENT_CONFIG.oneTimePrice}.`} Cancel anytime. 30-day money-back guarantee. +

+ + {/* Card brand strip */} +
+ We accept + + + + +
+
+
+ + {/* Testimonials */} +
+

What real travelers say

+
+ {[1, 2, 3, 4, 5].map(i => )} + 4.9 from 2,847 verified reviews +
+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( +
+
+ {t.photo && ( + {t.name} + )} +
+

{t.name}

+

{t.location}

+
+
+
+ {[1, 2, 3, 4, 5].map(i => )} +
+

“{t.quote}”

+
+ ))} +
+
+ + {/* FAQ */} +
+

Frequently asked questions

+
+ + + + +
+
+
+ + {/* RIGHT — sticky sidebar */} + +
+ + {/* Footer */} + +
+ ) +} + +function CardBrand({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + +function TrustBadge({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( +
+ {icon} + {label} +
+ ) +} + +function FAQItem({ q, a }: { q: string; a: string }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open &&
{a}
} +
+ ) +} + +function SuccessPanel({ email }: { email: string }) { + return ( +
+
+
+ +
+

You're going to Mexico!

+

+ Your vacation certificate has been activated. We just sent your welcome email and certificate details to: +

+
+

{email}

+
+

+ Check your inbox in the next 1–2 minutes (and your spam folder, just in case). + Your email includes your certificate number and a link to your customer portal where you can choose your destination and book travel dates. +

+ + Go to Customer Portal + +

+ Need help? Call {SUPPORT_PHONE} +

+
+
+ ) +} diff --git a/src/app/pay/ShowcaseCarousel.tsx b/src/app/pay/ShowcaseCarousel.tsx new file mode 100644 index 0000000..d055426 --- /dev/null +++ b/src/app/pay/ShowcaseCarousel.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Image from 'next/image' +import useEmblaCarousel from 'embla-carousel-react' +import { ChevronLeft, ChevronRight } from 'lucide-react' + +interface Slide { + src: string + badge: string + headline: string + body: string +} + +const SLIDES: Slide[] = [ + { + src: '/images/showcase/resort-beachfront.jpg', + badge: '5-Star All-Inclusive', + headline: 'World-class beachfront resorts', + body: 'Stay at curated 5-star properties on the Mexican Caribbean and Pacific coasts — chosen for sand, service, and stars.', + }, + { + src: '/images/showcase/infinity-pool-sunset.jpg', + badge: 'Endless Paradise', + headline: 'Watch the sunset from your infinity pool', + body: 'Cocktail in hand, ocean to the horizon. This is what 18 months of "I deserve this" looks like.', + }, + { + src: '/images/showcase/dashboard-screen.jpg', + badge: 'Your Private Dashboard', + headline: 'Login, see everything, book in 2 clicks', + body: 'Manage your certificate, view billing history, choose your dates, and book your resort — all from one beautiful portal.', + }, + { + src: '/images/showcase/certificate-design.jpg', + badge: 'Instant Certificate', + headline: 'Your golden ticket, delivered the moment you pay', + body: 'A unique certificate number is generated and emailed to you the second your payment clears. Frame it, screenshot it — it\'s yours.', + }, + { + src: '/images/showcase/family-vacation-joy.jpg', + badge: 'Whole Family Covered', + headline: 'Bring the family. We\'ll handle the rest.', + body: 'One certificate covers 2 adults and 2 kids under 12 — all-inclusive meals, drinks, and resort amenities for everyone.', + }, +] + +export default function ShowcaseCarousel() { + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: 'center' }) + const [selected, setSelected] = useState(0) + + useEffect(() => { + if (!emblaApi) return + const onSelect = () => setSelected(emblaApi.selectedScrollSnap()) + emblaApi.on('select', onSelect) + emblaApi.on('reInit', onSelect) + onSelect() + return () => { emblaApi.off('select', onSelect); emblaApi.off('reInit', onSelect) } + }, [emblaApi]) + + useEffect(() => { + if (!emblaApi) return + const t = setInterval(() => emblaApi.scrollNext(), 5500) + return () => clearInterval(t) + }, [emblaApi]) + + const scrollTo = useCallback((idx: number) => emblaApi?.scrollTo(idx), [emblaApi]) + + return ( +
+
+
+

A Peek Inside

+

Login. See your certificate. Book your dates.

+

Here's exactly what you're getting.

+
+ +
+
+
+ {SLIDES.map((s, i) => ( +
+
+ {s.headline} +
+
+
+ + {s.badge} + +

{s.headline}

+

{s.body}

+
+
+
+
+ ))} +
+
+ + + +
+ +
+ {SLIDES.map((_, i) => ( +
+
+
+ ) +} diff --git a/src/app/pay/[code]/page.tsx b/src/app/pay/[code]/page.tsx new file mode 100644 index 0000000..10be69a --- /dev/null +++ b/src/app/pay/[code]/page.tsx @@ -0,0 +1,28 @@ +import { redirect, notFound } from 'next/navigation' +import { getAffiliateByShortCode } from '@/lib/db-mysql' + +interface PageProps { + params: Promise<{ code: string }> + searchParams: Promise> +} + +export default async function PayByShortCode({ params, searchParams }: PageProps) { + const { code } = await params + const sp = await searchParams + + // 2-char shortcuts only; anything else is a typo / bot probe + if (!code || code.length < 2 || code.length > 8) notFound() + + const affiliate = await getAffiliateByShortCode(code).catch(() => null) + if (!affiliate) notFound() + + // Forward any extra params (utm_*, plan, etc.) — only inject ref + const qs = new URLSearchParams() + qs.set('ref', affiliate.referral_code) + for (const [k, v] of Object.entries(sp)) { + if (k === 'ref') continue + if (typeof v === 'string') qs.set(k, v) + else if (Array.isArray(v) && v[0]) qs.set(k, v[0]) + } + redirect(`/pay?${qs.toString()}`) +} diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx new file mode 100644 index 0000000..7b5b5e7 --- /dev/null +++ b/src/app/pay/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' +import { Suspense } from 'react' +import PayPageClient from './PayPageClient' + +export const metadata: Metadata = { + title: 'Secure Checkout — Mexico Paradise Vacations', + description: 'Complete your vacation certificate purchase securely. 5 days / 4 nights all-inclusive in Cancun, Cabo, Riviera Maya, or Puerto Vallarta.', + robots: { index: false, follow: false }, +} + +export default function PayPage() { + return ( + }> + + + ) +} diff --git a/src/app/payment/success/page.tsx b/src/app/payment/success/page.tsx index f78fb17..e55e46e 100644 --- a/src/app/payment/success/page.tsx +++ b/src/app/payment/success/page.tsx @@ -1,13 +1,13 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, Suspense } from 'react' import { useSearchParams } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { CheckCircle, Download, Calendar, ExternalLink } from 'lucide-react' import Link from 'next/link' -export default function PaymentSuccess() { +function PaymentSuccessContent() { const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(true) const [paymentData, setPaymentData] = useState(null) @@ -159,4 +159,19 @@ export default function PaymentSuccess() {
) +} + +export default function PaymentSuccess() { + return ( + +
+
+

Loading...

+
+
+ }> + +
+ ) } \ No newline at end of file diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 0000000..7b04a62 --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,91 @@ +import { Plane } from 'lucide-react' +import Link from 'next/link' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Privacy Policy — Mexico Paradise Vacations', + description: 'Privacy policy for Mexico Paradise Vacations.', +} + +export default function PrivacyPage() { + return ( +
+
+
+ +
+ +
+ Mexico Paradise Vacations + + Back to Home +
+
+ +
+

Privacy Policy

+

Last updated: March 21, 2026

+ +
+
+

Information We Collect

+

We collect information you provide directly when purchasing a vacation certificate or downloading our free travel guide, including: name, email address, phone number, and payment information (processed securely through our payment processor — we do not store credit card numbers on our servers).

+
+ +
+

How We Use Your Information

+
    +
  • To process your vacation certificate purchase and recurring payments
  • +
  • To send your vacation certificate and booking confirmations
  • +
  • To communicate important updates about your vacation booking
  • +
  • To send promotional offers and travel content (you may opt out at any time)
  • +
  • To improve our website and services
  • +
+
+ +
+

Payment Security

+

All payment transactions are processed through NMI, a PCI-DSS Level 1 certified payment gateway. Credit card information is encrypted using 256-bit SSL technology and is never stored on our servers. Recurring payments are managed securely through tokenized payment methods.

+
+ +
+

Information Sharing

+

We do not sell, trade, or rent your personal information to third parties. We may share your information only with:

+
    +
  • Participating resort partners to fulfill your vacation booking
  • +
  • Payment processors to complete transactions
  • +
  • Service providers who assist in operating our website
  • +
  • Law enforcement when required by law
  • +
+
+ +
+

Cookies & Analytics

+

We use Google Analytics to understand how visitors interact with our website. We use cookies to maintain your session and preferences. You may disable cookies in your browser settings, though some features may not function properly.

+
+ +
+

Your Rights

+

You may request access to, correction of, or deletion of your personal data by contacting us at 888-602-2424 or emailing us. We will respond to your request within 30 days.

+
+ +
+

Contact

+

Mexico Paradise Vacations (DBA: 724vacation.com)
+ Toll-Free: 888-602-2424
+ Website: hi2b.com

+
+
+
+ +
+

Mexico Paradise Vacations • 724vacation.com • 888-602-2424

+
+ Terms + Privacy + Home +
+
+
+ ) +} diff --git a/src/app/reviews/page.tsx b/src/app/reviews/page.tsx new file mode 100644 index 0000000..a74c606 --- /dev/null +++ b/src/app/reviews/page.tsx @@ -0,0 +1,82 @@ +import { Plane, Star, MapPin } from 'lucide-react' +import Link from 'next/link' +import Image from 'next/image' +import type { Metadata } from 'next' +import { TESTIMONIALS, PAYMENT_CONFIG } from '@/app/lp/_config/types' + +export const metadata: Metadata = { + title: 'Real Reviews — Mexico Paradise Vacations', + description: 'Read what real travelers say about their Mexico Paradise Vacations all-inclusive trips. Honest reviews from Cancun, Cabo, Riviera Maya, and Puerto Vallarta.', +} + +export default function ReviewsPage() { + const avg = 4.8 + const count = TESTIMONIALS.length + + return ( +
+
+
+ +
+ +
+ Mexico Paradise Vacations + + Back to Home +
+
+ +
+
+

What real travelers say

+
+
+ {[1,2,3,4,5].map(i => ( + + ))} +
+ {avg.toFixed(1)} + · {count}+ reviews from real travelers across our four destinations +
+
+ +
+ {TESTIMONIALS.map((t, i) => ( +
+
+ {t.photo && ( +
+ {t.name} +
+ )} +
+
{t.name}
+
{t.location}
+
+
+
+ {[1,2,3,4,5].map(s => ( + + ))} +
+

“{t.quote}”

+
+ ))} +
+ +
+

Ready to write your own review?

+

5 days, 4 nights, all-inclusive at a real beachfront resort. From ${PAYMENT_CONFIG.monthlyPrice}/mo.

+ + Claim My Certificate + +
+ +

+ Reviews are from real customers and may be lightly edited for length and clarity. Photos used with permission. +

+
+
+ ) +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..d297d4f --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from 'next' +import { LP_CONFIGS } from './lp/_config/pages' + +const BASE = 'https://hi2b.com' + +export default function sitemap(): MetadataRoute.Sitemap { + const now = new Date() + const staticPages: MetadataRoute.Sitemap = [ + { url: `${BASE}/`, lastModified: now, changeFrequency: 'weekly', priority: 1.0 }, + { url: `${BASE}/pay`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 }, + { url: `${BASE}/about`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 }, + { url: `${BASE}/faq`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 }, + { url: `${BASE}/reviews`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 }, + { url: `${BASE}/affiliate`, lastModified: now, changeFrequency: 'monthly', priority: 0.6 }, + { url: `${BASE}/dashboard`, lastModified: now, changeFrequency: 'monthly', priority: 0.5 }, + { url: `${BASE}/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 }, + { url: `${BASE}/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 }, + ] + const lpPages: MetadataRoute.Sitemap = LP_CONFIGS.map(lp => ({ + url: `${BASE}/lp/${lp.slug}`, + lastModified: now, + changeFrequency: 'weekly', + priority: 0.8, + })) + return [...staticPages, ...lpPages] +} diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx new file mode 100644 index 0000000..8fb94a7 --- /dev/null +++ b/src/app/terms/page.tsx @@ -0,0 +1,229 @@ +import { Plane } from 'lucide-react' +import Link from 'next/link' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Terms & Conditions — Mexico Paradise Vacations', + description: 'Terms and conditions for vacation certificate purchases.', +} + +export default function TermsPage() { + return ( +
+ {/* Header */} +
+
+ +
+ +
+ Mexico Paradise Vacations + + Back to Home +
+
+ +
+

Terms & Conditions

+

Last updated: March 21, 2026

+ +
+ +
+

1. Offer Overview

+

Mexico Paradise Vacations ("Company," "we," "us") offers all-inclusive vacation certificate packages at participating luxury resorts in Cancun, Cabo San Lucas, Riviera Maya, and Puerto Vallarta, Mexico. Our promotional vacation certificates provide savings of up to 77% off retail pricing and include accommodations, all meals, drinks, and resort amenities for two (2) adults.

+

By purchasing a vacation certificate, you agree to attend a 90-minute vacation ownership presentation at the resort during your stay. There is absolutely no obligation to purchase anything during or after the presentation.

+
+ +
+

2. Pricing & Payment Plans

+

Vacation certificates are available at the following pricing:

+
    +
  • Monthly Plan: $29.00/month for 10 months ($290.00 total)
  • +
  • One-Time Payment: $249.00
  • +
+

Monthly plan payments are automatically charged to the credit card on file on the same day each month. You may book your vacation dates immediately after your first payment is processed. All prices are in US Dollars (USD).

+
+ +
+

3. 30-Day Money-Back Guarantee

+

We offer a 100% money-back guarantee within thirty (30) days of your initial purchase, provided that:

+
    +
  • You have not booked travel dates for your vacation
  • +
  • You submit a refund request in writing via email or by calling our toll-free number
  • +
  • The refund is processed within 5-10 business days to the original payment method
  • +
+

After the 30-day period, all sales are final. Partial refunds are not available after the guarantee period.

+
+ +
+

4. Cancellation of Recurring Payments

+

You may cancel your monthly payment plan at any time by contacting us at 888-602-2424. Upon cancellation:

+
    +
  • No further payments will be charged to your card
  • +
  • Payments already made are non-refundable (after the 30-day guarantee period)
  • +
  • If the certificate has not been fully paid, it will be inactivated and travel cannot be booked
  • +
  • You may reactivate by paying the remaining balance
  • +
+
+ +
+

5. Eligibility Requirements

+

To qualify for the promotional vacation certificate rate, the following requirements must be met:

+ +

Age Requirements

+
    +
  • Married couples: Both persons must be between the ages of 30-68
  • +
  • Cohabitating couples: Both persons must be between the ages of 30-60
  • +
  • Same-sex married couples: Both persons must be between the ages of 35-60
  • +
+ +

Income Requirements

+
    +
  • Minimum $50,000 USD combined annual household income
  • +
  • Must be employed full-time. Part-time employment does not qualify
  • +
  • Retirees are accepted provided the income requirement is met
  • +
+ +

Marital/Relationship Status

+
    +
  • Offer is valid for married and cohabitating couples only
  • +
  • Cohabitating couples must demonstrate a minimum of 2 years living together
  • +
  • Must tour with spouse, fiancé, or significant other if married, engaged, or cohabitating
  • +
+ +

Credit Card Requirement

+
    +
  • Guests must present a valid major credit card at check-in (Visa, Mastercard, or Discover)
  • +
  • Debit cards, check cards, company cards, and American Express are not accepted
  • +
  • The credit card holder must be the qualifying person
  • +
+ +

Language

+

Both qualified participants must fluently speak, read, and understand either English or Spanish.

+ +

Geographic Restrictions

+

This offer is valid only for permanent residents of the 50 United States and Canada, excluding French Canadian provinces. Residents of or those with family or friends living in the resort destination are not eligible.

+
+ +
+

6. Vacation Ownership Presentation

+

As a condition of the promotional rate, certificate holders must attend a 90-minute vacation ownership presentation at the resort. The following conditions apply:

+
    +
  • The presentation is informational only — there is no obligation to purchase
  • +
  • Both qualified adults must attend the presentation together
  • +
  • Activities and excursions cannot be scheduled on the same day as the presentation
  • +
  • Failure to attend the presentation may result in the promotional rate being voided and the retail room rate being charged
  • +
  • Guests cannot attend any other vacation club presentations during this vacation, including adjoining dates
  • +
+
+ +
+

7. Booking & Travel

+
    +
  • You have 18 months from the date of purchase to use your vacation certificate
  • +
  • To book, call our toll-free number: 888-602-2424
  • +
  • Travel dates are subject to availability at participating resorts
  • +
  • Some blackout dates may apply during peak holiday seasons (Christmas, New Year's, Easter, Spring Break)
  • +
  • Must spend the first night at the designated resort in this promotion
  • +
  • Cannot be used consecutively with any other resort stays or promotional offers
  • +
+
+ +
+

8. Rescheduling Policy

+
    +
  • Once travel dates are chosen, changes are permitted only if received at least 21 days prior to the scheduled arrival date
  • +
  • Changes requested within 21 days of arrival will incur a penalty equal to the total cost of the package
  • +
  • No-shows will forfeit the vacation certificate with no refund
  • +
+
+ +
+

9. Certificate Restrictions

+
    +
  • This promotion is non-transferable
  • +
  • Only one certificate per household, family, or known travel group traveling on the same or similar dates
  • +
  • May not be combined with any other promotional offer, discount code, or special pricing
  • +
  • This is a one-time promotion — not available to guests who have previously used a similar promotional offer at any participating resort
  • +
  • Not valid for existing members of any vacation club, timeshare, or loyalty program at participating resorts
  • +
+
+ +
+

10. What's Included

+

Your all-inclusive vacation certificate covers:

+
    +
  • 5 days and 4 nights of luxury resort accommodation
  • +
  • All meals (breakfast, lunch, dinner) at resort restaurants
  • +
  • Unlimited drinks including alcoholic beverages at resort bars and restaurants
  • +
  • Access to resort pools, beaches, and non-motorized water activities
  • +
  • Use of resort amenities (fitness center, entertainment, common areas)
  • +
  • Accommodation for 2 adults
  • +
+

Not included: Airfare, airport transfers, spa services, motorized water sports, off-site excursions, travel insurance, passport/visa fees, personal expenses, tips/gratuities.

+
+ +
+

11. Travel Documentation

+

All travelers are responsible for ensuring they have valid travel documentation:

+
    +
  • A valid passport with at least 6 months remaining before expiration
  • +
  • Any required visas or travel permits
  • +
  • Travel insurance is strongly recommended but not required
  • +
+
+ +
+

12. Limitation of Liability

+

Mexico Paradise Vacations acts as an intermediary between the customer and the resort. We are not responsible for:

+
    +
  • Resort conditions, services, or quality of accommodations
  • +
  • Flight delays, cancellations, or travel disruptions
  • +
  • Personal injury, loss, or damage during travel
  • +
  • Changes in resort policies, amenities, or availability
  • +
  • Force majeure events including natural disasters, pandemics, or government restrictions
  • +
+
+ +
+

13. Privacy & Data

+

Personal information collected during purchase is used solely for processing your vacation certificate, billing, and customer communications. We do not sell or share your personal data with third parties except as necessary to fulfill your vacation booking. See our Privacy Policy for details.

+
+ +
+

14. Contact Information

+
+

Mexico Paradise Vacations

+

DBA: 724vacation.com

+

Toll-Free: 888-602-2424

+

Website: hi2b.com

+

Hours: Monday-Friday 9am-8pm EST | Saturday 10am-4pm EST

+
+
+ +
+

15. Governing Law

+

These terms and conditions shall be governed by and construed in accordance with the laws of the State of Michigan, United States. Any disputes arising under these terms shall be subject to the exclusive jurisdiction of the courts located in the State of Michigan.

+
+ +
+

16. Acceptance

+

By purchasing a vacation certificate from Mexico Paradise Vacations, you acknowledge that you have read, understood, and agree to be bound by these Terms and Conditions. If you do not agree with any part of these terms, do not purchase a vacation certificate.

+
+ +
+
+ + {/* Footer */} +
+

Mexico Paradise Vacations • 724vacation.com • 888-602-2424

+
+ Terms + Privacy + Home +
+
+
+ ) +} diff --git a/src/components/TikTokPixel.tsx b/src/components/TikTokPixel.tsx new file mode 100644 index 0000000..6dc2f84 --- /dev/null +++ b/src/components/TikTokPixel.tsx @@ -0,0 +1,31 @@ +'use client' + +import Script from 'next/script' +import { TIKTOK_PIXEL_ID } from '@/lib/tiktok-pixel' + +/** + * Injects the TikTok Pixel base code. Renders nothing until + * NEXT_PUBLIC_TIKTOK_PIXEL_ID is set, so it is safe to keep mounted. + * + * The base snippet auto-fires a PageView (`ttq.page()`); funnel events + * (SubmitForm, InitiateCheckout, CompletePayment) are fired from the + * forms via the helpers in src/lib/tiktok-pixel.ts. + */ +export default function TikTokPixel() { + if (!TIKTOK_PIXEL_ID) return null + + return ( + + ) +} diff --git a/src/components/lp/GUIDELINES.md b/src/components/lp/GUIDELINES.md new file mode 100644 index 0000000..23802e7 --- /dev/null +++ b/src/components/lp/GUIDELINES.md @@ -0,0 +1,234 @@ +# hi2b.com Landing Page Guidelines + +Based on Brian Moran's "1 Page Blueprint" (SamCart) — adapted to our vacation-certificate offer. + +Every LP we build follows this **7-section sequence**. The order matters: each section earns the right to the next. + +``` +1. HEADLINE → Bold promise that stops the scroll +2. LEAD → Set the stage with a story or scenario +3. STORY → Pinpoint the problem (the old way fails) +4. PITCH → Reveal the solution (the new way works) +5. EVIDENCE → Proof — testimonials, ratings, social proof +6. OFFER → Lay out what they get + price +7. CLOSE → Remove risk, create urgency, ask for the action +``` + +--- + +## 1. THE HEADLINE — Make a bold promise + +**Goal:** Stop the scroll in under 1.5 seconds. + +Pick ONE of these structures: +- **Bold promise:** "5-Star All-Inclusive Mexico Vacation for $29/Month" +- **Outrageous claim:** "$3,000 Resort Vacation for $290 Total — Yes, Really" +- **Question:** "What if Your Next Vacation Cost Less Than Your Phone Bill?" + +**Rules** +- ≤ 12 words +- Specific number when possible ($29, 5 days, 4 nights, 18 months) +- Don't promise what we can't deliver (no "free" — it's discounted) +- Sub-headline (1 sentence) softens it: who it's for + what they get + when + +**Component:** Hero section of every LP. Big bold font (text-5xl+), high contrast on photo background. + +--- + +## 2. THE LEAD — Set the stage + +**Goal:** Make them feel "this is for me." + +Three approaches: +- **Scenario:** "Picture this: It's Tuesday morning. Your laptop is in a hotel safe. You're 8,000 feet up looking at turquoise water…" +- **Story start:** "Last March I almost canceled our Mexico trip because of the price. Then a friend told me about this…" +- **A few questions:** "Tired of working 50 weeks for 2? Watching others post beach photos? Stuck in the 'someday' mindset?" + +**Rules** +- 2-4 sentences max +- Talk to ONE person ("you", not "people") +- Drop the marketing voice — write like you're texting a friend +- Bridge to the problem (section 3) + +**Component:** Short intro paragraph below the hero, before the "old way / new way" comparison. + +--- + +## 3. THE STORY — Pinpoint the problem (the OLD way) + +**Goal:** Make the current solution feel painful. + +Bullet 3-5 ❌ pain points of "the old way": +- ❌ Pay $3,000+ per person upfront for an all-inclusive +- ❌ Use 2 weeks of vacation in one shot, drained when you return +- ❌ Crowded tourist resorts with mediocre food +- ❌ Book 6 months out, hope your dates still work +- ❌ Sticker shock kills the romance every time + +Story angles that work: +- **Your story:** What you tried that failed +- **Their story:** What the reader is currently going through +- **A customer story:** "Sarah from Chicago used to…" + +**Rules** +- Be specific. "Vacations are expensive" = generic. "$2,400 for 4 nights in Cancun last spring" = real. +- Don't trash competitors — trash the situation +- End with a transition: "There has to be a better way." / "Then I found out about something different." + +**Component:** Comparison table or "old way vs new way" two-column block. We use a red ❌ style for the old way. + +--- + +## 4. THE PITCH — Share the solution (the NEW way) + +**Goal:** Introduce the product as the obvious answer. + +Bullet 3-5 ✅ benefits of "the new way": +- ✅ Pay $29/month — same as a streaming bundle +- ✅ Lock in 5-star all-inclusive at today's prices +- ✅ Use your certificate any time in the next 18 months +- ✅ Bring the whole family — 2 adults + 2 kids under 12 covered +- ✅ 30-day money-back guarantee — zero risk + +Structure: +- Name the product clearly: "the Mexico Paradise Vacation Certificate" +- Explain it in one sentence: "A pre-paid travel voucher you fund $29/month, redeemable at 4 luxury Mexican resorts." +- List the benefits — not features. (Feature: "All-inclusive." Benefit: "Never see another menu price.") + +**Component:** Benefits grid (3-4 columns with icons), same green ✅ check pattern used in PayNowForm modal. + +--- + +## 5. THE EVIDENCE — Build credibility + +**Goal:** Quiet the "is this legit?" voice in their head. + +Stack at least 3 of these: +- ⭐ Star rating with review count ("4.9 / 2,847 reviews") — already in `/pay` +- 👤 Customer testimonials with **photo + name + city** (we have 6 in `TESTIMONIALS` array in `_config/types.ts`) +- 📸 Real photos of resorts/destinations (`public/images/cdn/photo-*.jpg`) +- 📹 TikTok carousel (we have 242 in `TikTokCarousel.tsx`) +- 📰 Press / certification badges: SSL, PCI Level 1, NMI Verified, 30-Day Refund +- 🟢 Live activity ticker ("Sarah from Chicago just secured a vacation — 2 min ago") +- 🔢 Count of customers: "Join 8,500+ happy travelers" + +**Rules** +- One real photo > ten stock photos +- Quotes need specifics ("we paid $390 for what would've cost $3,000") not generics ("great experience") +- Vary the demographics across testimonials (age, race, family type) + +**Component:** Testimonials grid + trust badges + TikTokCarousel. The `/pay` page is a complete reference build. + +--- + +## 6. THE OFFER — Lay out what they get + +**Goal:** Make the price feel like a no-brainer. + +Three-step pattern: +1. **Explain each piece** — what's in the certificate + - 5 days / 4 nights all-inclusive + - Choice of 4 resorts (Cancun, Cabo, Riviera Maya, Puerto Vallarta) + - 2 adults + 2 kids under 12 covered + - Unlimited food, drinks, premium liquor + - 18-month flexible booking window +2. **Build the value** — anchor against the real cost + - "Comparable retail vacations: $2,400-$3,800" + - "Total cost with us: $290 (monthly) or $249 (one-time)" + - Show savings as a dollar amount AND a percentage +3. **Reveal the price** — only AFTER value is built + - Monthly: $29/mo × 10 = $290 + - One-time: $249 (save $41) + - Show original price struck through ($59/mo or $599 one-time) + +**Rules** +- Never lead with price +- Two tiers is the sweet spot — three confuses, one feels arbitrary +- Use the actual `PAYMENT_CONFIG` constants — never hardcode prices + +**Component:** Pricing toggle (monthly vs one-time) — pattern used in PayNowForm, EbookCaptureForm, and /pay. + +--- + +## 7. THE CLOSE — Seal the deal + +**Goal:** Give every reason to act NOW. + +The five risk-killers (use ALL of them): +1. **Remove the risk** — "30-day, no-questions-asked money-back guarantee" +2. **Sweeten the deal** — Add a bonus (free PDF guide, free upgrade, extra night) +3. **Create urgency** — Real, not fake ("Founders pricing ends [date]", "Only 47 certificates left this month") +4. **Strong CTA** — Action verb + benefit ("Claim My Certificate", "Get My Vacation", "Lock In My Spot") +5. **A stern warning** — What they lose by waiting ("Prices go to $39/mo on [date]", "Once these slots fill, we close enrollment") + +**Trust strip below CTA:** +- 🔒 Secure 256-bit SSL +- 🛡 30-Day Money-Back +- ⭐ 4.9 from 2,847 reviews +- 📞 Talk to a human: 888-602-2424 + +**Component:** Sticky bottom CTA bar on mobile + final hero CTA + footer reassurance block. + +--- + +## hi2b-specific rules (non-negotiable) + +### Hooks for every LP +1. **Every primary CTA fires `/api/claim`** — captures email + phone + IP, sends PDF, marks confirmed +2. **`useTrackingParams` everywhere** — preserves `?ref=` (affiliate), `?utm_*` across the session +3. **`useEarlyLead` on every form** — debounced 800ms field-blur capture (silent, no email) +4. **Two-button hierarchy after lead capture:** primary "See the $29/mo Special Offer" + secondary "Skip and Go to Payment →" + +### Components to reuse (don't rebuild) +- `EbookCaptureForm` — email-only capture flow with auto-PDF + payment upsell +- `PayNowForm` — email + phone capture, then full card-payment modal +- `TikTokCarousel` — 242-video social proof carousel +- `CountdownTimer` — urgency clock (use real expiry, not infinite reset) +- `_config/types.ts` — PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS + +### Image rules +- Use local `/images/cdn/photo-*.jpg` — all 50 are verified Mexico destinations +- For new imagery, generate via `scripts/generate-showcase-images.ts` (Gemini 3 image preview) +- Aspect ratios: hero 16:9 or 21:9 mobile, testimonial avatars 1:1 +- Never use raw Unsplash CDN links — we self-host + +### Copy DON'Ts +- ❌ "Limited time only!" without a real date +- ❌ "Act now or miss out!" generic urgency +- ❌ Stock-photo people who look like ads +- ❌ Three-color rainbow gradients (looks AI-generated) +- ❌ "Click here" CTAs — always describe the outcome +- ❌ Promising "free" — it's discounted, not free +- ❌ Inventing testimonials — pull from `TESTIMONIALS` or get real ones + +### Tracking the LP performs +Every LP must: +1. Set its `source_lp` to its slug (e.g., `golden-hour`, `last-chance`) +2. Pass tracking down to forms via the `useTrackingParams(sourceLp)` hook +3. Show up in `/admin/sales` with attribution +4. Show up in `/admin/analytics` for conversion-rate comparison + +--- + +## Quality checklist before shipping a new LP + +- [ ] All 7 sections present in order +- [ ] Headline ≤ 12 words, specific, not generic +- [ ] Lead talks to ONE person (no "people who" / "customers who") +- [ ] Old way vs new way is concrete with dollar amounts +- [ ] At least 3 evidence elements stacked (stars + testimonials + photos minimum) +- [ ] Price revealed AFTER value is built +- [ ] CTA fires `/api/claim` and opens payment modal +- [ ] `?ref=` and `?utm_*` flow through to signup payload +- [ ] Money-back guarantee visible near every CTA +- [ ] Mobile pass: form is reachable in under 3 thumb-scrolls +- [ ] Image alt text on every photo +- [ ] Page slug registered in `src/app/lp/_config/pages.ts` +- [ ] Test purchase end-to-end in sandbox before live deploy + +--- + +## When in doubt + +> **People don't buy vacations. They buy the version of themselves that comes back from one.** +> Sell the transformation, not the transaction. diff --git a/src/components/lp/pages/LP01GoldenHour.tsx b/src/components/lp/pages/LP01GoldenHour.tsx new file mode 100644 index 0000000..4867f0c --- /dev/null +++ b/src/components/lp/pages/LP01GoldenHour.tsx @@ -0,0 +1,432 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Sun, Utensils, Wine, Waves, Star, MapPin, ArrowRight, Check } from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const HERO_IMAGE = '/images/cdn/photo-1507525428034-b723cf961d3e.jpg' +const BEACH_IMAGE = '/images/cdn/photo-1519046904884-53103b34b206.jpg' +const RESORT_IMAGE = '/images/cdn/photo-1582719508461-905c673771fd.jpg' +const POOL_IMAGE = '/images/cdn/photo-1571896349842-33c89424de2d.jpg' +const DINNER_IMAGE = '/images/cdn/photo-1414235077428-338989a2e8c0.jpg' + +const DESTINATION_IMAGES: Record = { + 'Cancun': '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + 'Cabo San Lucas': '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + 'Riviera Maya': '/images/cdn/photo-1518638150340-f706e86654de.jpg', + 'Puerto Vallarta': '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', +} + +const INCLUDED_ITEMS = [ + { icon: Sun, title: '5 Days & 4 Nights', description: 'Luxurious resort accommodations with ocean views' }, + { icon: Utensils, title: 'All Meals Included', description: 'Breakfast, lunch, dinner at world-class restaurants' }, + { icon: Wine, title: 'Unlimited Drinks', description: 'Premium cocktails, wine, and refreshments all day' }, + { icon: Waves, title: 'Resort Amenities', description: 'Pools, beach access, spa, fitness center, and more' }, +] + +export default function LP01GoldenHour() { + const [scrollY, setScrollY] = useState(0) + + useEffect(() => { + const handleScroll = () => setScrollY(window.scrollY) + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + // Gradient deepens as user scrolls + const gradientOpacity = Math.min(0.4, scrollY / 3000) + + return ( +
+ + + {/* Warm golden gradient overlay that deepens on scroll */} +
+ + {/* Top bar */} +
+ Limited Time Offer + | + + remaining +
+ + {/* Hero Section */} +
+
+ Golden sunset over a pristine Mexican beach +
+
+
+ +
+

+ Mexico Paradise Vacations +

+

+ Picture yourself + + in paradise + +

+

+ 5 days and 4 nights at an all-inclusive Mexican resort. + Starting at just ${PAYMENT_CONFIG.monthlyPrice}/month. +

+ +
+
+ + {/* Social proof strip */} +
+
+
+
+
+ {TESTIMONIALS.slice(0, 4).map((t, i) => ( + {t.name} + ))} +
+ + 2,400+ happy travelers + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + 4.8/5 + average rating +
+
+
+
+ + {/* What's Included */} +
+
+

+ Everything You Need +

+

+ What's Included +

+

+ Your vacation certificate covers everything for an unforgettable getaway. No hidden fees, no surprises. +

+ +
+ {INCLUDED_ITEMS.map((item) => ( +
+
+ +
+

+ {item.title} +

+

{item.description}

+
+ ))} +
+ + {/* Lifestyle image strip */} +
+ Luxury resort suite + Infinity pool overlooking ocean + Fine dining experience +
+
+
+ + {/* Destinations */} +
+
+

+ Choose Your Paradise +

+

+ Four Stunning Destinations +

+

+ Each destination offers its own unique character. Pick the one that calls to you. +

+ +
+ {DESTINATIONS.map((dest) => ( +
+ {dest.name} +
+
+
+ + Mexico +
+

+ {dest.name} +

+

{dest.tagline}

+
+
+ ))} +
+
+
+ + {/* Full-bleed beach image divider */} +
+ Turquoise ocean with golden sand +
+

+ Your golden hour awaits +

+
+
+ + {/* Pricing + Form Section */} +
+
+

+ Simple, Transparent Pricing +

+

+ Claim Your Certificate +

+

+ Lock in your price today. Travel anytime within 18 months. +

+ +
+ {/* Pricing details */} +
+

+ Your Vacation Certificate Includes +

+
    + {[ + '5 days & 4 nights at a luxury all-inclusive resort', + 'All meals, drinks, and snacks included', + 'Your choice of 4 stunning destinations', + 'Flexible booking — travel within 18 months', + '30-day money-back guarantee', + 'Bring a partner at no extra cost', + ].map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+
+ $1,500+ + + Save over $1,100 + +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /month for {PAYMENT_CONFIG.totalMonths} months +
+

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time payment +

+
+
+ + {/* Pay Now Form */} +
+

+ Get Started Today +

+

+ Secure your certificate in under 2 minutes +

+ + +
+
+
+
+ + {/* Testimonials */} +
+
+

+ Real Stories +

+

+ What Our Travelers Say +

+ +
+ {TESTIMONIALS.slice(0, 6).map((testimonial, i) => ( + + ))} +
+
+
+ + {/* FAQ */} +
+
+

+ Questions & Answers +

+ + +

+ Frequently Asked Questions +

+ + +
+
+ + {/* Final CTA */} +
+
+

+ Your sunset is waiting +

+

+ Join over 2,400 travelers who have experienced paradise for a fraction of the cost. +

+ + +
+
+ + {/* Footer */} +
+
+

Mexico Paradise Vacations © {new Date().getFullYear()}. All rights reserved.

+

+ Your certificate is valid for 18 months from purchase date. + A 90-minute resort presentation is required to receive the discounted rate. +

+
+
+ + {/* Sticky Mobile CTA */} + +
+ ) +} diff --git a/src/components/lp/pages/LP02MidnightTropical.tsx b/src/components/lp/pages/LP02MidnightTropical.tsx new file mode 100644 index 0000000..cc06800 --- /dev/null +++ b/src/components/lp/pages/LP02MidnightTropical.tsx @@ -0,0 +1,395 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { Sparkles, Shield, Clock, Users, Star, Zap, ArrowRight, Check, Gift } from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const HERO_IMAGE = '/images/cdn/photo-1540541338287-41700207dee6.jpg' +const BEACH_NIGHT = '/images/cdn/photo-1507525428034-b723cf961d3e.jpg' + +const DESTINATION_IMAGES: Record = { + 'Cancun': '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + 'Cabo San Lucas': '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + 'Riviera Maya': '/images/cdn/photo-1518638150340-f706e86654de.jpg', + 'Puerto Vallarta': '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', +} + +const VIP_PERKS = [ + { icon: Sparkles, text: '5 days & 4 nights — luxury all-inclusive' }, + { icon: Users, text: 'Bring your partner — no extra charge' }, + { icon: Clock, text: '18 months to book your travel dates' }, + { icon: Shield, text: '30-day full money-back guarantee' }, + { icon: Gift, text: 'All meals, drinks & resort amenities included' }, + { icon: Zap, text: 'Instant digital certificate delivery' }, +] + +export default function LP02MidnightTropical() { + const [certificatesLeft, setCertificatesLeft] = useState(23) + const [animateGlow, setAnimateGlow] = useState(false) + const glowRef = useRef(null) + + useEffect(() => { + // Pulse glow effect + glowRef.current = setInterval(() => { + setAnimateGlow(true) + setTimeout(() => setAnimateGlow(false), 1500) + }, 4000) + return () => { + if (glowRef.current) clearInterval(glowRef.current) + } + }, []) + + // Simulate scarcity countdown + useEffect(() => { + const timer = setInterval(() => { + setCertificatesLeft(prev => { + if (prev <= 5) return 23 + return prev - 1 + }) + }, 45000) + return () => clearInterval(timer) + }, []) + + return ( +
+ + + {/* Animated gradient background */} +
+
+
+ + {/* Urgency bar */} +
+
+ + + Only {certificatesLeft} certificates remaining at this price + + | + Offer expires in + +
+
+ + {/* Hero */} +
+
+ Tropical resort at twilight +
+
+ +
+
+ + EXCLUSIVE VIP OFFER +
+ +

+ Your VIP Ticket to{' '} + + Paradise + +

+ +

+ 5 days. 4 nights. All-inclusive luxury in Mexico. +
+ Starting at just{' '} + ${PAYMENT_CONFIG.monthlyPrice}/month. +

+ + + +

+ {certificatesLeft} of 50 certificates remaining this month +

+
+
+ + {/* Glassmorphic feature cards */} +
+
+

+ Everything Included +

+

+ No hidden fees. No surprises. Just paradise. +

+ +
+ {VIP_PERKS.map((perk) => ( +
+
+
+ +
+

{perk.text}

+
+
+ ))} +
+
+
+ + {/* Destinations with glass cards */} +
+
+

+ Choose Your Destination +

+

+ Four world-class Mexican destinations. The choice is yours. +

+ +
+ {DESTINATIONS.map((dest) => ( +
+ {dest.name} +
+
+

+ {dest.name} +

+

{dest.tagline}

+
+ {/* Glow line on hover */} +
+
+ ))} +
+
+
+ + {/* VIP Ticket-style pricing */} +
+
+
+ {/* Ticket header */} +
+
+ + VIP CERTIFICATE +
+

+ Mexico Paradise +

+

5 Days / 4 Nights All-Inclusive

+ + {/* Ticket perforation dots */} +
+
+
+ + {/* Ticket body */} +
+
+
+ $1,500+ + + 73% OFF + +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ for {PAYMENT_CONFIG.totalMonths} months · or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ +
    + {[ + 'Luxury all-inclusive resort stay', + 'Choice of 4 destinations', + 'All meals & unlimited drinks', + 'Flexible dates within 18 months', + '30-day money-back guarantee', + ].map((item) => ( +
  • + + {item} +
  • + ))} +
+ + + +
+

+ Only {certificatesLeft} certificates left at this price +

+
+
+
+ + +
+
+ + {/* Testimonials */} +
+
+

+ What VIP Travelers Say +

+ +
+ {TESTIMONIALS.slice(0, 6).map((t, i) => ( + + ))} +
+
+
+ + {/* FAQ */} +
+
+ + +

+ Questions? We've Got Answers +

+ + +
+
+ + {/* Bottom CTA */} +
+
+

+ {certificatesLeft} certificates remaining +

+

+ Don't Miss Out +

+

+ This exclusive VIP rate won't last. Secure your certificate today. +

+ +
+
+ + {/* Footer */} +
+
+

Mexico Paradise Vacations © {new Date().getFullYear()}. All rights reserved.

+

+ Your certificate is valid for 18 months from purchase date. + A 90-minute resort presentation is required to receive the discounted rate. +

+
+
+ + {/* Social Proof Ticker */} + + + {/* Sticky Mobile CTA */} + +
+ ) +} diff --git a/src/components/lp/pages/LP03PassportStamp.tsx b/src/components/lp/pages/LP03PassportStamp.tsx new file mode 100644 index 0000000..a2e8ce8 --- /dev/null +++ b/src/components/lp/pages/LP03PassportStamp.tsx @@ -0,0 +1,511 @@ +'use client' + +import { useState } from 'react' +import { Plane, MapPin, Stamp, Star, Calendar, Shield, Utensils, Palmtree, ArrowRight, Check } from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const HERO_IMAGE = '/images/cdn/photo-1436491865332-7a61a109db05.jpg' + +const DESTINATION_IMAGES: Record = { + 'Cancun': '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + 'Cabo San Lucas': '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + 'Riviera Maya': '/images/cdn/photo-1518638150340-f706e86654de.jpg', + 'Puerto Vallarta': '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', +} + +const STAMP_DATES = ['MAR 2026', 'APR 2026', 'JUN 2026', 'SEP 2026'] + +const JOURNEY_STEPS = [ + { icon: Stamp, title: 'Claim Your Certificate', description: 'Secure your spot with a simple payment plan' }, + { icon: Calendar, title: 'Pick Your Dates', description: 'Choose any available week within 18 months' }, + { icon: MapPin, title: 'Choose Your Destination', description: 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta' }, + { icon: Plane, title: 'Pack Your Bags', description: 'Show up and enjoy — everything else is covered' }, +] + +export default function LP03PassportStamp() { + const [activeDestination, setActiveDestination] = useState(0) + + return ( +
+ + + {/* Paper texture overlay */} +
+ + {/* Top banner */} +
+

+ + Adventure awaits! + + | + Your next passport stamp is just ${PAYMENT_CONFIG.monthlyPrice}/month away +

+
+ + {/* Hero */} +
+
+ Vintage map and travel accessories +
+
+ +
+
+ {/* Vintage airplane doodle */} +
+ +
+
+ +

+ Mexico Paradise Vacations presents... +

+ +

+ Your Next Stamp + Awaits +

+ +

+ 5 days and 4 nights at an all-inclusive Mexican resort. Four breathtaking destinations to choose from. +

+ +

+ Starting at just{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/month + {' '} + for {PAYMENT_CONFIG.totalMonths} months +

+ + +
+
+
+ + {/* Journey steps */} +
+
+

+ It's easier than you think +

+

+ Your Journey in 4 Simple Steps +

+ +
+ {JOURNEY_STEPS.map((step, i) => ( +
+ {/* Step number stamp */} +
+ {i + 1} +
+ +

+ {step.title} +

+

{step.description}

+
+ ))} +
+
+
+ + {/* Passport-style destination cards */} +
+
+

+ Pick your paradise +

+

+ Your Passport Destinations +

+ + {/* Destination tabs */} +
+ {DESTINATIONS.map((dest, i) => ( + + ))} +
+ + {/* Active destination - passport page style */} +
+
+ {/* Passport page header */} +
+ + MEXICO ENTRY STAMP + + + {STAMP_DATES[activeDestination]} + +
+ + {/* Destination image with stamp overlay */} +
+ {DESTINATIONS[activeDestination].name} + {/* Stamp overlay */} +
+ Approved + {STAMP_DATES[activeDestination]} + MEXICO +
+
+ + {/* Destination info */} +
+

+ {DESTINATIONS[activeDestination].name} +

+

{DESTINATIONS[activeDestination].tagline}

+ +
+ {['All-Inclusive Resort', 'Ocean Views', 'Gourmet Dining', 'Beach Access'].map((feature) => ( +
+ + {feature} +
+ ))} +
+ + {/* Handwritten note */} +
+

+ “Can't wait to visit! This is going to be amazing!” +

+
+
+
+
+
+
+ + {/* What's included — journal style */} +
+
+

+ Everything you need for the perfect trip +

+

+ What's In Your Certificate +

+ +
+ {[ + { icon: Palmtree, title: '5 Days & 4 Nights', desc: 'Luxurious resort accommodations with stunning views', note: 'Check-in is a breeze!' }, + { icon: Utensils, title: 'All Meals & Drinks', desc: 'Breakfast, lunch, dinner, and unlimited beverages', note: 'The food is incredible' }, + { icon: MapPin, title: '4 Destinations', desc: 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta', note: 'Hard to choose just one!' }, + { icon: Shield, title: '30-Day Guarantee', desc: 'Full refund if you change your mind within 30 days', note: 'Totally risk-free' }, + ].map((item) => ( +
+ +

+ {item.title} +

+

{item.desc}

+

+ ^ {item.note} +

+
+ ))} +
+
+
+ + {/* Pricing & Form */} +
+
+

+ Ready to go? +

+

+ Claim Your Vacation Certificate +

+ +
+ {/* Pricing card */} +
+
+
+ +
+
+

+ Travel Certificate +

+

5 days / 4 nights all-inclusive

+
+
+ +
+
+ $1,500+ + + Save over $1,100 + +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /month +
+

+ for {PAYMENT_CONFIG.totalMonths} months · or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ +
    + {[ + 'Luxury all-inclusive resort', + 'All meals and unlimited drinks', + 'Your choice of 4 destinations', + 'Bring your partner for free', + '18 months to book your dates', + '30-day money-back guarantee', + ].map((item) => ( +
  • + + {item} +
  • + ))} +
+ +

+ The best decision you'll make this year! +

+
+ + {/* Form */} +
+

+ Start Your Adventure +

+

Fill in your details and we'll get you booked

+ + + + +
+
+
+
+ + {/* Testimonials */} +
+
+

+ From our travelers' journals +

+

+ Traveler Stories +

+ +
+ {TESTIMONIALS.slice(0, 6).map((t, i) => ( + + ))} +
+
+
+ + {/* FAQ */} +
+
+

+ Before you pack... +

+ + +

+ Frequently Asked Questions +

+ +
+ +
+
+
+ + {/* Final CTA */} +
+
+ +

+ The adventure begins now +

+

+ Your passport is waiting for a new stamp! +

+ +
+
+ + {/* Footer */} +
+
+

Mexico Paradise Vacations © {new Date().getFullYear()}. All rights reserved.

+

+ Your certificate is valid for 18 months from purchase date. + A 90-minute resort presentation is required to receive the discounted rate. +

+
+
+ + {/* Sticky Mobile CTA */} + +
+ ) +} diff --git a/src/components/lp/pages/LP04CrystalClear.tsx b/src/components/lp/pages/LP04CrystalClear.tsx new file mode 100644 index 0000000..285f3b7 --- /dev/null +++ b/src/components/lp/pages/LP04CrystalClear.tsx @@ -0,0 +1,187 @@ +'use client' + +import { ArrowRight, Check } from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const BEACH_IMAGE = '/images/cdn/photo-1507525428034-b723cf961d3e.jpg' + +export default function LP04CrystalClear() { + return ( +
+ + + {/* Hero — radically minimal */} +
+
+

+ Mexico Paradise Vacations +

+ +

+ 5 days. 4 nights. +
+ $29/month. +
+ That's it. +

+ +

+ All-inclusive Mexico vacation certificate. + Four destinations. Zero hidden fees. +

+ + +
+
+ + {/* Single beach image — full bleed */} +
+ Crystal clear turquoise water and white sand beach +
+ + {/* Simple value props */} +
+
+
+ {[ + 'Luxury all-inclusive resort stay', + 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta', + 'All meals and unlimited drinks', + 'Bring your partner at no extra cost', + 'Book anytime within 18 months', + '30-day money-back guarantee', + ].map((item) => ( +
+ + {item} +
+ ))} +
+
+
+ + {/* Pricing — dead simple */} +
+
+
+ $1,500+ + + Save $1,100+ + +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ for {PAYMENT_CONFIG.totalMonths} months · or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+
+ + {/* Email-first form — minimal */} +
+
+

+ Ready? +

+

+ Takes less than 2 minutes. +

+ + + + +
+
+ + {/* One-line testimonial */} +
+
+

+ “{TESTIMONIALS[0].quote}” +

+
+ {TESTIMONIALS[0].photo && ( + {TESTIMONIALS[0].name} + )} + + {TESTIMONIALS[0].name}, {TESTIMONIALS[0].location} + +
+
+
+ + + + {/* Tiny FAQ */} +
+
+

FAQ

+ +
+
+ + {/* Final CTA — ultra minimal */} +
+
+

+ Paradise is waiting. +

+ +
+
+ + {/* Footer */} +
+
+

Mexico Paradise Vacations © {new Date().getFullYear()}

+

+ Certificate valid 18 months. 90-minute resort presentation required. +

+
+
+ + {/* Sticky Mobile CTA */} + +
+ ) +} diff --git a/src/components/lp/pages/LP05Fiesta.tsx b/src/components/lp/pages/LP05Fiesta.tsx new file mode 100644 index 0000000..b447afe --- /dev/null +++ b/src/components/lp/pages/LP05Fiesta.tsx @@ -0,0 +1,538 @@ +'use client' + +import { useState, useRef, useCallback } from 'react' +import { Sun, Utensils, Waves, Palmtree, Music, Star, MapPin, ArrowRight, Check, PartyPopper, Heart, Sparkles } from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const HERO_IMAGE = '/images/cdn/photo-1518105779142-d975f22f1b0a.jpg' +const BEACH_FIESTA = '/images/cdn/photo-1519046904884-53103b34b206.jpg' + +const DESTINATION_IMAGES: Record = { + 'Cancun': '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + 'Cabo San Lucas': '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + 'Riviera Maya': '/images/cdn/photo-1518638150340-f706e86654de.jpg', + 'Puerto Vallarta': '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', +} + +const INCLUDED_FEATURES = [ + { icon: Sun, title: '5 Days & 4 Nights', desc: 'Wake up to ocean views every morning', color: '#FFD600' }, + { icon: Utensils, title: 'All Meals & Drinks', desc: 'Gourmet dining and unlimited beverages', color: '#FF1744' }, + { icon: Waves, title: 'Beach & Pool Access', desc: 'Pristine beaches and sparkling pools', color: '#00BFA5' }, + { icon: Palmtree, title: 'Resort Amenities', desc: 'Spa, fitness center, entertainment', color: '#FFD600' }, + { icon: Heart, title: 'Whole Family Covered', desc: '2 adults + 2 kids under 12', color: '#FF1744' }, + { icon: Music, title: 'Entertainment', desc: 'Live music, shows, and nightlife', color: '#00BFA5' }, +] + +// SVG Papel Picado Banner Component +function PapelPicadoBanner({ className = '' }: { className?: string }) { + const colors = ['#FF1744', '#FFD600', '#00BFA5', '#FF6D00', '#AA00FF', '#FF1744', '#FFD600', '#00BFA5'] + + return ( +
+ + {/* String line */} + + {/* Papel picado flags */} + {colors.map((color, i) => { + const x = i * 150 + const w = 140 + return ( + + {/* Flag body */} + + {/* Decorative cutouts */} + + + + + + {/* Heart cutout */} + + + ) + })} + +
+ ) +} + +// Confetti particle +interface Particle { + id: number + x: number + y: number + color: string + size: number + rotation: number +} + +export default function LP05Fiesta() { + const [confettiParticles, setConfettiParticles] = useState([]) + const confettiIdRef = useRef(0) + + const spawnConfetti = useCallback((e: React.MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + const colors = ['#FF1744', '#FFD600', '#00BFA5', '#FF6D00', '#AA00FF', '#2979FF'] + + const newParticles: Particle[] = Array.from({ length: 20 }, () => { + confettiIdRef.current += 1 + return { + id: confettiIdRef.current, + x: cx + (Math.random() - 0.5) * 200, + y: cy + (Math.random() - 0.5) * 150 - 50, + color: colors[Math.floor(Math.random() * colors.length)], + size: 6 + Math.random() * 6, + rotation: Math.random() * 360, + } + }) + + setConfettiParticles(prev => [...prev, ...newParticles]) + + // Clean up after animation + setTimeout(() => { + setConfettiParticles(prev => prev.filter(p => !newParticles.find(np => np.id === p.id))) + }, 1200) + }, []) + + return ( +
+ + + {/* Confetti particles */} +
+ {confettiParticles.map((p) => ( +
0.5 ? '50%' : '2px', + opacity: 0.9, + }} + /> + ))} +
+ + {/* CSS for confetti animation */} + + + {/* Papel Picado Banner */} + + + {/* Hero */} +
+
+ Colorful Mexican beach scene +
+
+ +
+
+ + Mexico Paradise Vacations +
+ +

+ Life is a{' '} + + Fiesta! + + +

+ +

+ 5 days of all-inclusive paradise in Mexico. + Four amazing destinations. Starting at just{' '} + ${PAYMENT_CONFIG.monthlyPrice}/month! +

+ + +
+
+ + {/* Colorful wave divider */} +
+ + + + + +
+ + {/* What's Included */} +
+
+
+

+ Todo Incluido +

+

+ What's Included +

+
+ +
+ {INCLUDED_FEATURES.map((feature) => ( +
+
+ +
+

+ {feature.title} +

+

{feature.desc}

+
+ ))} +
+
+
+ + {/* Destination cards */} +
+ {/* Top papel picado */} + + +
+
+

+ Destinos +

+

+ Choose Your Adventure +

+
+ +
+ {DESTINATIONS.map((dest, i) => { + const borderColors = ['#FF1744', '#FFD600', '#00BFA5', '#FF6D00'] + return ( +
+ {dest.name} +
+
+
+ + Mexico +
+

+ {dest.name} +

+

{dest.tagline}

+
+ {/* Colored corner accent */} +
+
+ ) + })} +
+
+
+ + {/* Beach divider */} +
+ Beautiful beach in Mexico +
+

+ Vamos a la playa! +

+
+
+ + {/* Pricing & Form */} +
+
+
+

+ Precios Increibles +

+

+ Grab Your Certificate +

+
+ +
+ {/* Pricing card — fiesta style */} +
+ {/* Festive header */} +
+ +

+ Fiesta Package +

+

5 Days / 4 Nights All-Inclusive

+
+ +
+
+
+ $1,500+ + + SAVE $1,100+ + +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ for {PAYMENT_CONFIG.totalMonths} months · or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ +
    + {[ + 'Luxury all-inclusive resort stay', + 'All meals and unlimited drinks', + 'Choice of 4 destinations', + 'Bring your partner free', + '18 months to book dates', + '30-day money-back guarantee', + ].map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ + {/* Form */} +
+

+ Let's Get This Party Started! +

+

+ Secure your certificate and start planning your fiesta +

+ + + + +
+
+
+
+ + {/* Testimonials */} +
+
+
+

+ Testimonios +

+

+ Happy Travelers +

+
+ +
+ {TESTIMONIALS.slice(0, 6).map((t, i) => { + const borderColors = ['#FF1744', '#FFD600', '#00BFA5'] + return ( + + ) + })} +
+
+
+ + {/* FAQ */} +
+
+
+

+ Preguntas Frecuentes +

+ + +

+ Got Questions? +

+
+ +
+ +
+
+
+ + {/* Final CTA */} +
+ +
+ +

+ The fiesta starts now! +

+

+ Don't wait — grab your all-inclusive Mexico vacation certificate today. +

+ +
+
+ + {/* Footer */} +
+
+

Mexico Paradise Vacations © {new Date().getFullYear()}. All rights reserved.

+

+ Your certificate is valid for 18 months from purchase date. + A 90-minute resort presentation is required to receive the discounted rate. +

+
+
+ + {/* Sticky Mobile CTA */} + +
+ ) +} diff --git a/src/components/lp/pages/LP06TheCloser.tsx b/src/components/lp/pages/LP06TheCloser.tsx new file mode 100644 index 0000000..fa05455 --- /dev/null +++ b/src/components/lp/pages/LP06TheCloser.tsx @@ -0,0 +1,654 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { Check, ArrowRight, AlertTriangle, Gift, Star, Shield, Zap, Clock, ChevronDown } from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const RED = '#B71C1C' +const YELLOW = '#FFD54F' +const GREEN = '#4CAF50' + +const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) +} + +function CTAButton({ text = 'YES! I Want My Vacation Certificate!' }: { text?: string }) { + return ( + + ) +} + +function YellowHighlight({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function SavingsCounter() { + const [scrollPercent, setScrollPercent] = useState(0) + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY + const docHeight = document.documentElement.scrollHeight - window.innerHeight + setScrollPercent(Math.min(1, scrollTop / docHeight)) + } + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + const savings = Math.floor(scrollPercent * 2601) + + return ( +
+

+ Your savings +

+

+ ${savings.toLocaleString()} +

+
+ ) +} + +function ValueItem({ item, value }: { item: string; value: string }) { + return ( +
+ +
+ {item} +
+ + {value} + +
+ ) +} + +export default function LP06TheCloser() { + return ( +
+ + + + + {/* Attention Bar */} +
+ + WARNING: THIS OFFER EXPIRES TONIGHT AT MIDNIGHT + +
+ + {/* Hero Section */} +
+
+

+ Attention: Anyone who wants an incredible Mexico vacation without the incredible price tag +

+

+ How To Get A 5-Day All-Inclusive Mexico Vacation + For Just $1.30 Per Day +

+

+ (That's less than your morning coffee... for a vacation your friends will think cost you $3,000+) +

+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + Rated 4.8/5 by 2,847 happy travelers +
+ Stunning beachfront resort in Mexico + +
+
+ + {/* Problem Section */} +
+
+

+ Let Me Guess... You're TIRED Of Watching Everyone Else + Take Amazing Vacations While You Stay Home? +

+
+

+ You scroll through Instagram and see your friends lounging on pristine beaches, + sipping cocktails at infinity pools, and eating at five-star restaurants... +

+

+ And you think: "Must be nice to afford that." +

+

+ Because every time you look at booking a real vacation, the numbers make your stomach turn. + $3,000... $4,000... $5,000+ for just a few days of paradise. +

+

+ You've tried the budget travel hacks. The "secret" websites. The off-season deals. + And you STILL end up paying way more than you planned. +

+

+ But what if it didn't have to be that way? +

+
+
+
+ + {/* Solution Section */} +
+
+

+ Introducing The Mexico Paradise Vacation Certificate +

+

+ A revolutionary way to experience a 5-day, 4-night all-inclusive Mexico vacation at + a fraction of the cost of traditional booking. +

+
+ {DESTINATIONS.map((dest) => ( +
+ {dest.name} +
+
+

+ {dest.name} +

+

{dest.tagline}

+
+
+ ))} +
+

+ Choose ANY of these world-class destinations. Your certificate, your choice. +

+ +
+
+ + {/* Value Stack Section */} +
+
+

+ Here's EVERYTHING You're Getting Today +

+

+ (When you add it all up, the value is absolutely INSANE) +

+ +
+ + + + + + + + + +
+
+ + Total Retail Value: + + + $3,550 + +
+
+ + YOUR Price Today: + + + Just ${PAYMENT_CONFIG.monthlyPrice}/mo + +
+

+ Or ${PAYMENT_CONFIG.oneTimePrice} one-time payment (best value!) +

+
+
+ +
+

+ That's a $3,000+ value for just $1.30 per day! +

+

+ Less than a cup of coffee. Less than a candy bar. Less than a single song on iTunes. +

+
+ + +
+
+ + {/* Social Proof - Testimonials */} +
+
+

+ Don't Just Take Our Word For It... +

+

+ Real people. Real vacations. Real savings. +

+
+ {TESTIMONIALS.slice(0, 4).map((t, i) => ( + + ))} +
+
+
+ + {/* Comparison Table */} +
+
+

+ See How We Stack Up Against The Competition +

+

+ The difference is crystal clear +

+
+ +
+
+ +
+
+
+ + {/* What If Section */} +
+
+

+ Imagine This... +

+
+

+ Picture yourself 6 months from now... +

+

+ You wake up in a luxurious resort room. The sound of waves crashing on the beach + filters through the open balcony doors. You stretch, smile, and walk out to see the + turquoise Caribbean stretching out before you. +

+

+ You head down to breakfast. Eggs benedict, fresh tropical fruit, bottomless mimosas. + All included. +

+

+ Your afternoon? Maybe the infinity pool. Maybe snorkeling. Maybe a couples massage at the spa. + Whatever you want. +

+

+ And the best part? While everyone around you paid $3,000+ for the same experience, + you paid less than $400. +

+

+ That's the power of the Mexico Paradise Vacation Certificate. +

+
+
+
+ + {/* More Testimonials */} +
+
+

+ Even MORE Happy Travelers... +

+
+ {TESTIMONIALS.slice(4).map((t, i) => ( + + ))} +
+
+
+ + {/* Objection Handling */} +
+
+

+ "But What If..." +

+
+
+

+ "What if I can't travel right away?" +

+

+ No problem! You have 18 full months to book your trip. + Pick the dates that work for YOUR schedule. +

+
+
+

+ "What if I don't like it?" +

+

+ We offer a 100% money-back guarantee within 30 days. + Zero risk. Zero hassle. +

+
+
+

+ "Can I really afford $29/month?" +

+

+ Let's put it this way: that's $1.30 per day. + Skip one latte a week and you're covered. + Can you really afford NOT to take a vacation? +

+
+
+

+ "Is this legit?" +

+

+ We're a verified business with 2,847+ happy customers, + a 4.8/5 star rating, and SSL-encrypted payments. Your money is safe. +

+
+
+
+
+ + {/* Urgency Section */} +
+
+

+ + This Price Won't Last Forever +

+

+ When the timer hits zero, this offer is GONE. + The price goes back to regular retail, and you'll be kicking yourself + for not acting when you had the chance. +

+
+ +
+

+ Only 47 certificates remaining at this price! +

+ +
+
+ + {/* Guarantee Section */} +
+
+ +

+ Our 30-Day Money-Back Guarantee +

+

+ Try the Mexico Paradise Vacation Certificate for a full 30 days. + If you're not completely thrilled with your purchase, + simply contact us and we'll refund every single penny. + No questions asked. No hoops to jump through. + Your satisfaction is 100% guaranteed. +

+
+
+ + {/* Final Breakdown + CTA Section */} +
+
+

+ Let's Do The Math One More Time... +

+
+
+
+ Resort Stay (5 days) + $1,200 +
+
+ All-Inclusive Package + $800 +
+
+ Amenities & Activities + $400 +
+
+ Guest Pass + $500 +
+
+ Extras & Perks + $650 +
+
+
+ Total Value: + $3,550 +
+
+ + You Pay: + +
+ + ${PAYMENT_CONFIG.monthlyPrice}/mo + +

or ${PAYMENT_CONFIG.oneTimePrice} one-time

+
+
+
+
+ +

+ You save over $3,000 with + ZERO risk (30-day guarantee) +

+
+
+ + {/* Signup Form Section */} +
+
+

+ + Claim Your Certificate NOW +

+

+ Fill out the form below to secure your ${PAYMENT_CONFIG.monthlyPrice}/month vacation certificate +

+ +
+ +
+
+
+ + {/* FAQ Section */} + + +
+
+

+ Frequently Asked Questions +

+ +
+
+ + {/* Final Closing */} +
+
+

+ You Have Two Choices Right Now... +

+
+
+

+ Option A: Do Nothing +

+
    +
  • + - + Keep scrolling Instagram jealously +
  • +
  • + - + Pay $3,000+ next time you travel +
  • +
  • + - + Wonder "what if" for the next year +
  • +
+
+
+

+ Option B: Take Action +

+
    +
  • + + Lock in $1.30/day for paradise +
  • +
  • + + Save over $3,000 guaranteed +
  • +
  • + + Be the one YOUR friends envy +
  • +
+
+
+ +

+ 30-day money-back guarantee. SSL encrypted. Cancel anytime. +

+
+
+ + {/* P.S. Section */} +
+
+

+ P.S. — Remember, this special price of ${PAYMENT_CONFIG.monthlyPrice}/month is + only available while the countdown timer is running. Once it hits zero, the price goes up. + There are only 47 certificates left at this price. +

+

+ P.P.S. — Still not sure? You're protected by our 30-day + money-back guarantee. That means you can try this completely risk-free. + If you don't love it, you get every penny back. What do you have to lose? +

+

+ P.P.P.S. — Think about it this way: for the price of a few + fast food meals per month, you could be lounging on a beach in Mexico. + The only question is... will you take action? +

+
+
+ + {/* Footer */} +
+

+ Mexico Paradise Vacations. All rights reserved. +

+

+ This site is not a part of the Facebook or Google websites. Results may vary. +

+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP07ResortPreview.tsx b/src/components/lp/pages/LP07ResortPreview.tsx new file mode 100644 index 0000000..8f0b6fd --- /dev/null +++ b/src/components/lp/pages/LP07ResortPreview.tsx @@ -0,0 +1,446 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { + Play, + Waves, + UtensilsCrossed, + Dumbbell, + Sparkles, + Wine, + Sun, + MapPin, + ChevronLeft, + ChevronRight, + Star, + ArrowRight, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS } from '@/app/lp/_config/types' + +const NAVY = '#1A237E' +const GOLD = '#C9B037' +const BEIGE = '#F5F5DC' + +const AMENITIES = [ + { + icon: Waves, + title: 'Infinity Pools', + description: 'Multiple heated pools with ocean views, swim-up bars, and private cabanas.', + image: '/images/cdn/photo-1582719508461-905c673771fd.jpg', + }, + { + icon: UtensilsCrossed, + title: 'World-Class Dining', + description: 'From authentic Mexican cuisine to international fine dining, all included.', + image: '/images/cdn/photo-1414235077428-338989a2e8c0.jpg', + }, + { + icon: Sparkles, + title: 'Luxury Spa', + description: 'Full-service spa with traditional temazcal, massages, and beauty treatments.', + image: '/images/cdn/photo-1544161515-4ab6ce6db874.jpg', + }, + { + icon: Dumbbell, + title: 'Fitness & Activities', + description: 'State-of-the-art gym, yoga classes, water sports, and guided excursions.', + image: '/images/cdn/photo-1540497077202-7c8a3999166f.jpg', + }, + { + icon: Wine, + title: 'Premium Bar & Lounge', + description: 'Top-shelf spirits, craft cocktails, and an extensive wine list, all day long.', + image: '/images/cdn/photo-1514362545857-3bc16c4c7d1b.jpg', + }, + { + icon: Sun, + title: 'Private Beach', + description: 'White sand, crystal-clear waters, and beach service with complimentary towels.', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + }, +] + +const GALLERY_IMAGES = [ + { src: '/images/cdn/photo-1566073771259-6a8506099945.jpg', caption: 'Oceanfront Suite' }, + { src: '/images/cdn/photo-1520250497591-112f2f40a3f4.jpg', caption: 'Resort Pool' }, + { src: '/images/cdn/photo-1551882547-ff40c63fe5fa.jpg', caption: 'Lobby & Gardens' }, + { src: '/images/cdn/photo-1571896349842-33c89424de2d.jpg', caption: 'Beachfront Dining' }, + { src: '/images/cdn/photo-1584132967334-10e028bd69f7.jpg', caption: 'Sunset Terrace' }, + { src: '/images/cdn/photo-1615460549969-36fa19521a4f.jpg', caption: 'Spa Retreat' }, + { src: '/images/cdn/photo-1602002418816-5c0aeef426aa.jpg', caption: 'Luxury Room' }, + { src: '/images/cdn/photo-1596436889106-be35e843f974.jpg', caption: 'Beach Paradise' }, +] + +function HorizontalGallery() { + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(true) + + const checkScroll = () => { + if (!scrollRef.current) return + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current + setCanScrollLeft(scrollLeft > 10) + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10) + } + + useEffect(() => { + const el = scrollRef.current + if (!el) return + el.addEventListener('scroll', checkScroll, { passive: true }) + checkScroll() + return () => el.removeEventListener('scroll', checkScroll) + }, []) + + const scroll = (direction: 'left' | 'right') => { + if (!scrollRef.current) return + const amount = scrollRef.current.clientWidth * 0.7 + scrollRef.current.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' }) + } + + return ( +
+
+ {GALLERY_IMAGES.map((img, i) => ( +
+
+ {img.caption} +
+

+ {img.caption} +

+
+
+ ))} +
+ + {canScrollLeft && ( + + )} + {canScrollRight && ( + + )} +
+ ) +} + +export default function LP07ResortPreview() { + return ( +
+ + + {/* Full-Screen Hero with Video Placeholder */} +
+ Luxury resort aerial view +
+ + {/* Play Button Overlay */} +
+ +

+ Your Future Awaits +

+

+ Tour Your Future
+ Resort +

+

+ Step inside the all-inclusive luxury resorts where your next vacation begins. + 5 days. 4 nights. From just ${PAYMENT_CONFIG.monthlyPrice}/month. +

+ + Reserve Your Stay + + +
+ + {/* Scroll indicator */} +
+

Scroll to explore

+
+
+
+
+
+ + {/* Introduction */} +
+
+

+ An Immersive Experience +

+

+ Where Elegance Meets Paradise +

+

+ Our hand-selected partner resorts represent the finest in Mexican hospitality. + Each property offers world-class amenities, stunning natural beauty, and the kind + of service that turns a vacation into a lifelong memory. +

+
+
+ + {/* Resort Amenity Showcase */} +
+
+
+

+ Resort Amenities +

+

+ Everything You Could Dream Of +

+
+ +
+ {AMENITIES.map((amenity) => ( +
+
+ {amenity.title} +
+
+ +
+
+
+

+ {amenity.title} +

+

{amenity.description}

+
+
+ ))} +
+
+
+ + {/* Horizontal Scroll Gallery */} +
+
+
+

+ Visual Tour +

+

+ A Glimpse of Paradise +

+
+ +
+
+ + {/* Destination Carousel */} +
+
+
+

+ Choose Your Destination +

+

+ Four Stunning Locations +

+
+ +
+
+ + {/* Testimonials */} +
+
+
+

+ Guest Experiences +

+

+ Words From Our Guests +

+
+
+ {TESTIMONIALS.slice(0, 4).map((t, i) => ( + + ))} +
+
+
+ + {/* Pricing Section */} +
+
+

+ Your Investment +

+

+ An Extraordinary Value +

+

+ Five days of all-inclusive luxury for less than a single night at most resorts. +

+
+ +
+
+
+ + {/* Signup Form */} +
+
+ Beach sunset +
+
+
+
+

+ Reserve Now +

+

+ Begin Your Journey +

+

+ Secure your all-inclusive vacation certificate today +

+
+
+ +
+
+ +
+
+
+ + {/* FAQ */} +
+
+
+

+ Questions +

+ + +

+ Frequently Asked +

+
+
+ +
+
+
+ + {/* Footer */} +
+

+ Mexico Paradise Vacations +

+

+ Luxury experiences, extraordinary value. +

+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP08SplitDecision.tsx b/src/components/lp/pages/LP08SplitDecision.tsx new file mode 100644 index 0000000..742ec8c --- /dev/null +++ b/src/components/lp/pages/LP08SplitDecision.tsx @@ -0,0 +1,455 @@ +'use client' + +import { useState } from 'react' +import { + Check, + ArrowRight, + TreePalm, + Waves, + Sun, + UtensilsCrossed, + Wine, + Shield, + Star, + MapPin, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS } from '@/app/lp/_config/types' + +const AMBER = '#FF6F00' +const BLUE = '#1565C0' +const PINK = '#E91E63' + +const cancun = DESTINATIONS.find(d => d.name === 'Cancun')! +const cabo = DESTINATIONS.find(d => d.name === 'Cabo San Lucas')! + +const SHARED_BENEFITS = [ + { icon: Waves, text: '5 days / 4 nights all-inclusive' }, + { icon: UtensilsCrossed, text: 'Unlimited meals & drinks included' }, + { icon: Sun, text: 'Beach & pool access all day' }, + { icon: Wine, text: 'Premium bar & cocktail service' }, + { icon: Shield, text: '30-day money-back guarantee' }, + { icon: Star, text: 'Rated 4.8/5 by travelers' }, +] + +const CANCUN_HIGHLIGHTS = [ + 'World-famous turquoise waters', + 'Vibrant nightlife & entertainment', + 'Mayan ruins nearby (Chichen Itza)', + 'Snorkeling in the Great Mesoamerican Reef', + 'Year-round tropical weather', +] + +const CABO_HIGHLIGHTS = [ + 'Dramatic cliffs meet the Pacific Ocean', + 'Iconic El Arco rock formation', + 'World-class sport fishing', + 'Desert-meets-ocean landscape', + 'Luxurious boutique resort vibes', +] + +function DestinationCard({ + name, + tagline, + image, + highlights, + color, + side, +}: { + name: string + tagline: string + image: string + highlights: string[] + color: string + side: 'left' | 'right' +}) { + return ( +
+
+ {name} +
+
+ +

+ {name} +

+

{tagline}

+
+
+
+
    + {highlights.map((h, i) => ( +
  • + + + {h} + +
  • + ))} +
+
+
+ ) +} + +export default function LP08SplitDecision() { + const [hoveredSide, setHoveredSide] = useState<'left' | 'right' | null>(null) + + return ( +
+ + + {/* Split-Screen Hero */} +
+
+ {/* Cancun Side */} +
setHoveredSide('left')} + onMouseLeave={() => setHoveredSide(null)} + style={{ + flex: hoveredSide === 'left' ? 1.2 : hoveredSide === 'right' ? 0.8 : 1, + }} + > + Cancun +
+
+

+ Option A +

+

+ Cancun +

+

{cancun.tagline}

+
+
+ + {/* Cabo Side */} +
setHoveredSide('right')} + onMouseLeave={() => setHoveredSide(null)} + style={{ + flex: hoveredSide === 'right' ? 1.2 : hoveredSide === 'left' ? 0.8 : 1, + }} + > + Cabo San Lucas +
+
+

+ Option B +

+

+ Cabo +

+

{cabo.tagline}

+
+
+
+ + {/* Center divider text */} +
+
+ + VS + +
+
+ + {/* Bottom text overlay */} +
+

+ The Only Question Is WHICH Paradise +

+
+
+ + {/* "Both Include" Section */} +
+
+

+ No Matter Which You Choose... +

+

+ Both destinations include everything below. +

+
+ {SHARED_BENEFITS.map((benefit) => ( +
+
+ +
+

+ {benefit.text} +

+
+ ))} +
+
+
+ + {/* Detailed Split Comparison */} +
+
+

+ Compare Your Options +

+
+
+ +
+
+ +
+
+
+

+ Plus Riviera Maya and Puerto Vallarta are also available! +

+

+ Your certificate works at any of our 4 stunning destinations. +

+
+
+
+ + {/* The Choice is Easy */} +
+
+

+ Forget "Cancun vs. Cabo"— +
The Real Choice Is Vacation vs. No Vacation +

+

+ For just ${PAYMENT_CONFIG.monthlyPrice}/month, you get 5 days of all-inclusive paradise. + Pick your destination later. Lock in the price now. +

+ + Choose Your Paradise + + +
+
+ + {/* Other Destinations Preview */} +
+
+

+ Also Available With Your Certificate +

+
+ {DESTINATIONS.filter(d => d.name !== 'Cancun' && d.name !== 'Cabo San Lucas').map((dest) => ( +
+ {dest.name} +
+
+

+ {dest.name} +

+

{dest.tagline}

+
+
+ ))} +
+
+
+ + {/* Pricing */} +
+
+

+ One Price. Any Destination. +

+

+ Your certificate is valid at all 4 locations. +

+
+ +
+
+
+ + {/* Testimonials */} +
+
+

+ What Travelers Are Saying +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* Signup Form */} +
+
+
+
+
+
+

+ Ready To Decide? +

+

+ Secure your certificate now. Choose your destination later. +

+
+
+ +
+
+ +
+
+
+ + {/* FAQ */} + + +
+
+

+ Common Questions +

+ +
+
+ + {/* Footer */} +
+

+ Mexico Paradise Vacations. All rights reserved. +

+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP09Calculator.tsx b/src/components/lp/pages/LP09Calculator.tsx new file mode 100644 index 0000000..afed3d9 --- /dev/null +++ b/src/components/lp/pages/LP09Calculator.tsx @@ -0,0 +1,451 @@ +'use client' + +import { useState, useMemo } from 'react' +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts' +import { + Calculator, + TrendingDown, + DollarSign, + Check, + ArrowRight, + BarChart3, + Percent, + PiggyBank, + Shield, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS } from '@/app/lp/_config/types' + +const PRIMARY_BLUE = '#0D47A1' +const GREEN = '#00C853' +const BG = '#E8EAF6' + +const BOOKING_OPTIONS = [ + { label: 'Budget Hotel', nights: 5, costPerNight: 180, allInclusive: false, mealsPerDay: 80, drinksPerDay: 30 }, + { label: 'Mid-Range Resort', nights: 5, costPerNight: 350, allInclusive: false, mealsPerDay: 120, drinksPerDay: 50 }, + { label: 'All-Inclusive Resort', nights: 5, costPerNight: 550, allInclusive: true, mealsPerDay: 0, drinksPerDay: 0 }, + { label: 'Luxury Resort', nights: 5, costPerNight: 800, allInclusive: true, mealsPerDay: 0, drinksPerDay: 0 }, +] + +function InteractiveCalculator() { + const [selectedOption, setSelectedOption] = useState(2) + const [travelers, setTravelers] = useState(2) + + const option = BOOKING_OPTIONS[selectedOption] + + const traditionalCost = useMemo(() => { + const roomCost = option.costPerNight * option.nights + const mealCost = option.allInclusive ? 0 : option.mealsPerDay * option.nights * travelers + const drinkCost = option.allInclusive ? 0 : option.drinksPerDay * option.nights * travelers + return roomCost + mealCost + drinkCost + }, [selectedOption, travelers, option]) + + const ourCost = PAYMENT_CONFIG.oneTimePrice + const savings = Math.max(0, traditionalCost - ourCost) + const savingsPercent = traditionalCost > 0 ? Math.round((savings / traditionalCost) * 100) : 0 + + const chartData = [ + { + name: option.label, + cost: traditionalCost, + fill: '#EF5350', + }, + { + name: 'Expedia Avg', + cost: Math.round(traditionalCost * 0.85), + fill: '#FF9800', + }, + { + name: 'Mexico Paradise', + cost: ourCost, + fill: GREEN, + }, + ] + + return ( +
+ {/* Controls */} +
+
+ +
+ {BOOKING_OPTIONS.map((opt, i) => ( + + ))} +
+
+
+ +
+ {[1, 2, 3, 4].map((n) => ( + + ))} +
+

+ Certificate covers 2 guests. Additional guests at discounted rate. +

+ + {/* Savings Highlight */} +
+

+ YOUR ESTIMATED SAVINGS +

+

+ ${savings.toLocaleString()} +

+

+ That's {savingsPercent}% less than {option.label} +

+
+
+
+ + {/* Chart */} +
+

+ Cost Comparison: 5-Day Mexico Vacation +

+
+ + + + + `$${v}`} + /> + [`$${value.toLocaleString()}`, 'Total Cost']} + contentStyle={{ fontFamily: 'var(--font-ibm-plex)', fontSize: '13px' }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + +
+
+ Traditional + Online Travel + Mexico Paradise +
+
+
+ ) +} + +function StatCard({ + icon: Icon, + value, + label, + color = PRIMARY_BLUE, +}: { + icon: React.ElementType + value: string + label: string + color?: string +}) { + return ( +
+ +

+ {value} +

+

+ {label} +

+
+ ) +} + +export default function LP09Calculator() { + return ( +
+ + + {/* Header */} +
+
+
+ + + Mexico Paradise + +
+ + Get Started + +
+
+ + {/* Hero */} +
+
+
+ + Data-Driven Travel Savings +
+

+ The Math
+ Doesn't Lie +

+

+ See exactly how much you save with a Mexico Paradise Vacation Certificate + compared to booking through traditional channels. +

+ + {/* Stats Row */} +
+ + + + +
+
+
+ + {/* Interactive Calculator */} +
+
+
+

+ Interactive Cost Calculator +

+

+ Compare our price against different booking options. Adjust the inputs below. +

+
+ +
+
+ + {/* Comparison Table */} +
+
+
+

+ Feature-by-Feature Breakdown +

+

+ It's not just about price. See what's included. +

+
+
+ +
+
+
+ + {/* Value Breakdown */} +
+
+

+ What's Included In Your ${PAYMENT_CONFIG.oneTimePrice} +

+

+ Every item below is included. No hidden fees. No surprises. +

+
+ {[ + { item: '5 Days / 4 Nights', value: '$1,200 value' }, + { item: 'All Meals Included', value: '$800 value' }, + { item: 'Unlimited Drinks', value: '$400 value' }, + { item: 'Resort Amenities', value: '$400 value' }, + { item: 'Bring A Guest Free', value: '$500 value' }, + { item: 'Flexible Booking', value: '$200 value' }, + ].map((item) => ( +
+ +
+

{item.item}

+

{item.value}

+
+
+ ))} +
+
+

+ Total retail value:{' '} + $3,500{' '} + + ${PAYMENT_CONFIG.oneTimePrice} + +

+
+
+
+ + {/* Social Proof */} +
+
+

+ Verified Reviews +

+

+ From travelers who ran the numbers and took the trip. +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* Pricing + Form */} +
+
+
+

+ Ready To Save? +

+

+ Lock in your vacation certificate at today's price. +

+
+
+
+
+

$3,000+

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+
+ +
+
+ +
+
+
+ + + + {/* FAQ */} +
+
+

+ Frequently Asked Questions +

+
+ +
+
+
+ + {/* Bottom CTA */} +
+
+

+ The numbers speak for themselves. +

+

+ Save an average of $2,601 on your next Mexico vacation. +

+ + Start Saving Now + + +
+
+ + {/* Footer */} +
+

+ Mexico Paradise Vacations. All rights reserved. +

+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP10Countdown.tsx b/src/components/lp/pages/LP10Countdown.tsx new file mode 100644 index 0000000..a37e8df --- /dev/null +++ b/src/components/lp/pages/LP10Countdown.tsx @@ -0,0 +1,501 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Flame, + Check, + ArrowRight, + AlertTriangle, + Zap, + Star, + Shield, + MapPin, + Clock, + Gift, + Lock, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS } from '@/app/lp/_config/types' + +const BLACK = '#000000' +const RED = '#F44336' +const AMBER = '#FFC107' + +const FEATURES = [ + { + icon: MapPin, + title: '4 Premium Destinations', + description: 'Cancun, Cabo San Lucas, Riviera Maya, or Puerto Vallarta', + }, + { + icon: Clock, + title: '5 Days / 4 Nights', + description: 'A full vacation, not a weekend getaway', + }, + { + icon: Gift, + title: 'All-Inclusive Package', + description: 'Every meal, every drink, every activity included', + }, + { + icon: Star, + title: '4-5 Star Resorts', + description: 'Luxury properties with world-class amenities', + }, + { + icon: Shield, + title: '30-Day Money Back', + description: 'Full refund if you change your mind', + }, + { + icon: Zap, + title: 'Flexible Booking', + description: '18 months to choose your travel dates', + }, +] + +function PulsingDot() { + return ( + + + + + ) +} + +function SpotsCounter() { + const [spots, setSpots] = useState(47) + + useEffect(() => { + const interval = setInterval(() => { + setSpots(prev => { + if (prev <= 12) return prev + return Math.random() > 0.7 ? prev - 1 : prev + }) + }, 30000) + return () => clearInterval(interval) + }, []) + + return ( +
+ + {spots} + spots remaining +
+ ) +} + +export default function LP10Countdown() { + return ( +
+ + + {/* Urgency Bar */} +
+
+ + + LIMITED TIME OFFER — PRICE INCREASES WHEN TIMER HITS ZERO + + +
+
+ + {/* Countdown Hero */} +
+ {/* Background */} +
+ Dark beach scene +
+
+ + {/* Animated background particles effect using CSS */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ +
+
+ + + Exclusive Limited Drop + + +
+ +

+ OFFER EXPIRES
+ WHEN THE CLOCK
+ HITS ZERO +

+ + {/* Giant Countdown */} +
+ +
+ +
+ +
+ + + Claim Your Certificate Now + + +

+ ${PAYMENT_CONFIG.monthlyPrice}/mo for {PAYMENT_CONFIG.totalMonths} months or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + {/* Scroll indicator */} +
+
+
+
+
+
+ + {/* "What You're Getting" Section */} +
+
+
+

+ WHAT YOU'RE GETTING +

+

+ 5 days of all-inclusive luxury. Here's the full breakdown. +

+
+ +
+ {FEATURES.map((feature) => ( +
+ +

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+
+ + {/* Price Anchor */} +
+
+

+ THE REAL COST OF NOT ACTING +

+
+
+

Book Direct

+

+ $3,000+ +

+
+
+

Expedia / Hotels.com

+

+ $2,500+ +

+
+
+

Mexico Paradise

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+
+
+

+ That's ${PAYMENT_CONFIG.monthlyPrice}/month — + less than a streaming subscription for a luxury vacation. +

+
+
+ + {/* Destination Previews */} +
+
+
+

+ CHOOSE YOUR DESTINATION +

+

+ All four locations included with your certificate. +

+
+ +
+ {DESTINATIONS.map((dest) => ( +
+ {dest.name} +
+
+

+ {dest.name} +

+

{dest.tagline}

+
+
+ +
+
+ ))} +
+
+
+ + {/* Testimonials */} +
+
+

+ WHAT OTHERS ARE SAYING +

+
+ {TESTIMONIALS.slice(0, 4).map((t, i) => ( + + ))} +
+
+
+ + {/* Mini Countdown Reminder */} +
+
+

+ TIME IS RUNNING OUT +

+
+ +
+ + Don't Miss Out + + +
+
+ + {/* Trust Badges */} +
+
+

+ SECURE & GUARANTEED +

+
+ {[ + { icon: Lock, label: '256-bit SSL' }, + { icon: Shield, label: '30-Day Guarantee' }, + { icon: Star, label: '4.8/5 Rating' }, + { icon: Check, label: 'Verified Business' }, + ].map((badge) => ( +
+
+ +
+ {badge.label} +
+ ))} +
+
+
+ + {/* Signup Form */} +
+
+
+ +
+
+
+ + + Secure Your Spot + + +
+

+ CLAIM YOUR CERTIFICATE +

+

+ Before the clock runs out and this price disappears. +

+
+ +
+ {/* Mini countdown in form */} +
+ + + remaining +
+ + +
+ +
+ +
+
+
+ + {/* FAQ */} + + +
+
+

+ QUESTIONS? ANSWERED. +

+ +
+
+ + {/* Final CTA */} +
+
+

+ THE CLOCK IS TICKING +

+
+ +
+ + Last Chance — Get It Now + + +
+
+ + {/* Footer */} +
+

+ Mexico Paradise Vacations. All rights reserved. +

+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP11TheGuide.tsx b/src/components/lp/pages/LP11TheGuide.tsx new file mode 100644 index 0000000..0852a44 --- /dev/null +++ b/src/components/lp/pages/LP11TheGuide.tsx @@ -0,0 +1,429 @@ +'use client' + +import { useState, useEffect } from 'react' +import { BookOpen, Clock, User, ChevronRight, MapPin } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { TESTIMONIALS, DESTINATIONS, PAYMENT_CONFIG } from '@/app/lp/_config/types' + +export default function LP11TheGuide() { + const [scrollProgress, setScrollProgress] = useState(0) + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY + const docHeight = document.documentElement.scrollHeight - window.innerHeight + const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0 + setScrollProgress(Math.min(progress, 100)) + } + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + return ( +
+ + + {/* Reading Progress Bar */} +
+
+
+ + {/* Top Navigation Bar */} + + + {/* Article Header */} +
+
+ + Travel Guide + + + + 12 min read + + + + Editorial Team + +
+ +

+ 5 Secrets to Luxury Mexico Vacations on a Budget +

+ +

+ How savvy travelers are staying at 5-star all-inclusive resorts in Cancun, Cabo, + and the Riviera Maya for less than $1.30 a day. Yes, you read that right. +

+ +
+ Author +
+

Marco Rivera

+

Travel Editor -- Updated March 2026

+
+
+
+ + {/* Hero Image */} +
+
+ Cancun beach resort at sunset +
+

Cancun, Mexico -- One of four destinations in the program

+
+
+
+ + {/* Article Body */} +
+ {/* Intro paragraph */} +

+ Every year, millions of Americans dream of a tropical getaway but convince themselves + it's out of reach. The average all-inclusive Mexico vacation costs between $2,500 and + $4,000 per couple. But what if there was a way to enjoy the same crystal-clear waters, + gourmet dining, and luxury suites for a fraction of that price? +

+ +

+ After spending three years investigating vacation certificate programs across the + industry, our editorial team uncovered a legitimate pathway that thousands of travelers + are already using. Here's what we found. +

+ + {/* Secret #1 */} +

+ Secret #1: The Resort Presentation Model +

+ +

+ Here's what most people don't realize: luxury resorts in Mexico have a massive + customer acquisition problem. Their rooms are often half-empty during shoulder seasons, + and they're willing to offer deeply discounted stays to potential future members. +

+ +

+ The catch? You attend a 90-minute resort presentation during your stay. No obligation + to buy anything. You listen, you say "no thank you" if it's not for you, + and you go back to sipping margaritas by the infinity pool. +

+ +

+ This model has existed for decades in the timeshare industry, but a new wave of + vacation certificate programs has made it accessible, transparent, and genuinely + affordable. The result? A 5-night, all-inclusive stay at a resort that normally + charges $400+/night, available for as little as $29/month. +

+ + {/* Inline destination images */} +
+ Cabo San Lucas + Riviera Maya +
+ + {/* Secret #2 */} +

+ Secret #2: Timing Is Everything +

+ +

+ The second secret experienced budget-luxury travelers know is that when you + travel matters just as much as how you book. Mexico's shoulder seasons -- + May through mid-June and September through November -- offer the same stunning weather + with a fraction of the crowds. +

+ +

+ Certificate programs give you an 18-month window to book, which means you can + strategically choose dates when resorts roll out their best perks: room upgrades, + spa credits, and premium dining packages. One couple we interviewed scored a + suite upgrade that would have cost $200/night extra -- simply by traveling in + early October. +

+ + {/* Secret #3 preview */} +

+ Secret #3: The All-Inclusive Advantage +

+ +

+ Most travelers underestimate how much they spend on food, drinks, and activities + during a vacation. Our research found the average couple spends $150-$250 per day on + dining and entertainment alone. With an all-inclusive certificate, every meal, every + cocktail, every poolside snack is already covered. +

+ + {/* Callout box */} +
+

+ By the numbers +

+

+ ${PAYMENT_CONFIG.monthlyPrice}/month x {PAYMENT_CONFIG.totalMonths} months = ${PAYMENT_CONFIG.totalPrice} total +

+

+ vs. the average all-inclusive vacation cost of $3,200+ per couple. + That's over $2,800 in savings. +

+
+ +

+ But there are two more secrets that can save you even more -- and they're the + ones most "travel hack" articles won't tell you about... +

+ + {/* Content Gate - Fade out effect */} +
+

+ Secret #4 involves a little-known booking strategy that experienced certificate + holders use to maximize their stay. And Secret #5? It's the one thing that + separates travelers who have a "good" vacation from those who have the + trip of a lifetime... +

+
+
+
+ + {/* Ebook Capture Section */} +
+
+ +

+ Read the Full Guide -- Free +

+

+ Get all 5 secrets plus our destination comparison chart, packing checklist, + and insider resort ratings. Delivered instantly to your inbox. +

+
+ +
+

+ Join 12,400+ readers who downloaded this guide. +

+
+
+ + {/* Destination Preview */} +
+

+ Four Destinations, One Certificate +

+
+ {DESTINATIONS.map((dest) => ( +
+ {dest.name} +
+
+

+ {dest.name} +

+

{dest.tagline}

+
+
+ ))} +
+
+ + {/* Testimonials */} +
+

+ What Travelers Are Saying +

+
+ {TESTIMONIALS.slice(0, 4).map((t, i) => ( + + ))} +
+
+ + {/* Secondary PayNow Section */} +
+
+

+ Ready to Skip the Guide? +

+

+ Claim your 5-day, 4-night all-inclusive Mexico vacation certificate now. + Just ${PAYMENT_CONFIG.monthlyPrice}/month or ${PAYMENT_CONFIG.oneTimePrice} one-time. +

+
+ +
+ +
+
+ + + + {/* FAQ */} +
+

+ Frequently Asked Questions +

+ +
+ + {/* Footer */} + + + +
+ ) +} diff --git a/src/components/lp/pages/LP12Dreamboard.tsx b/src/components/lp/pages/LP12Dreamboard.tsx new file mode 100644 index 0000000..a2428e8 --- /dev/null +++ b/src/components/lp/pages/LP12Dreamboard.tsx @@ -0,0 +1,389 @@ +'use client' + +import { useState } from 'react' +import { Heart, Sparkles, Plane, Star } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { TESTIMONIALS, PAYMENT_CONFIG } from '@/app/lp/_config/types' + +const DREAM_PHOTOS = [ + { + src: '/images/cdn/photo-1552074284-5e88ef1aef18.jpg', + label: 'Cancun Sunsets', + height: 'h-72', + }, + { + src: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + label: 'Pristine Beaches', + height: 'h-48', + }, + { + src: '/images/cdn/photo-1551882547-ff40c63fe5fa.jpg', + label: 'Luxury Pools', + height: 'h-64', + }, + { + src: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg', + label: 'Riviera Maya', + height: 'h-56', + }, + { + src: '/images/cdn/photo-1544551763-46a013bb70d5.jpg', + label: 'Crystal Waters', + height: 'h-80', + }, + { + src: '/images/cdn/photo-1580846629083-02669741360a.jpg', + label: 'Cabo Magic', + height: 'h-48', + }, + { + src: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg', + label: 'Ocean Views', + height: 'h-60', + }, + { + src: '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', + label: 'Golden Horizons', + height: 'h-72', + }, + { + src: '/images/cdn/photo-1519046904884-53103b34b206.jpg', + label: 'Beach Bliss', + height: 'h-52', + }, + { + src: '/images/cdn/photo-1571896349842-33c89424de2d.jpg', + label: 'Resort Paradise', + height: 'h-72', + }, + { + src: '/images/cdn/photo-1473116763249-2faaef81ccda.jpg', + label: 'Puerto Vallarta', + height: 'h-56', + }, + { + src: '/images/cdn/photo-1540541338287-41700207dee6.jpg', + label: 'Coastal Dreams', + height: 'h-64', + }, +] + +export default function LP12Dreamboard() { + const [likedPhotos, setLikedPhotos] = useState>(new Set()) + + const toggleLike = (index: number) => { + setLikedPhotos((prev) => { + const next = new Set(prev) + if (next.has(index)) { + next.delete(index) + } else { + next.add(index) + } + return next + }) + } + + return ( +
+ + + {/* Hero Section */} +
+ {/* Decorative circles */} +
+
+
+ +
+
+ + + Build Your Dream Vacation Board + +
+ +

+ Where Will Your +
+ + Dreams Take You? + +

+ +

+ Close your eyes. Picture yourself on a pristine Mexican beach, cocktail in hand, + waves lapping at your feet. Now open them -- and start planning. +

+ +
+ + + + Downloaded by 12,400+ dreamers + +
+
+
+ + {/* Masonry Photo Grid */} +
+

+ Your Vacation Mood Board +

+

+ Tap the heart on your favorites -- your dream vacation is closer than you think +

+ +
+ {DREAM_PHOTOS.map((photo, i) => ( +
+ {photo.label} +
+ + {/* Like button */} + + + {/* Label */} +
+

{photo.label}

+
+
+ ))} +
+ + {likedPhotos.size > 0 && ( +
+

+ + You've saved {likedPhotos.size} dream{likedPhotos.size !== 1 ? 's' : ''} -- get the guide to make them real! +

+
+ )} +
+ + {/* Ebook Capture Overlay Section */} +
+
+ {/* Decorative background */} +
+
+
+
+ +
+

+ Turn Dreams into Plans +

+

+ Get our free "Budget Luxury Travel" guide with 5 secrets to + luxury Mexico vacations on a budget. +

+
+ + +
+
+
+ + {/* Destinations showcase */} +
+

+ Four Dreamy Destinations +

+ +
+ {[ + { name: 'Cancun', img: '/images/cdn/photo-1552074284-5e88ef1aef18.jpg' }, + { name: 'Cabo San Lucas', img: '/images/cdn/photo-1580846629083-02669741360a.jpg' }, + { name: 'Riviera Maya', img: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg' }, + { name: 'Puerto Vallarta', img: '/images/cdn/photo-1473116763249-2faaef81ccda.jpg' }, + ].map((d) => ( +
+ {d.name} +
+
+

+ {d.name} +

+
+
+ ))} +
+
+ + {/* Testimonials */} +
+

+ Dreamers Who Made It Real +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+ + {/* PayNow Section */} +
+
+ +

+ Your Dream Vacation Starts Here +

+

+ 5 days, 4 nights all-inclusive in Mexico. + Starting at just ${PAYMENT_CONFIG.monthlyPrice}/month. +

+ + + + + + +
+
+ + + + {/* Footer */} + + + +
+ ) +} diff --git a/src/components/lp/pages/LP13QuizFunnel.tsx b/src/components/lp/pages/LP13QuizFunnel.tsx new file mode 100644 index 0000000..1b3f362 --- /dev/null +++ b/src/components/lp/pages/LP13QuizFunnel.tsx @@ -0,0 +1,508 @@ +'use client' + +import { useState } from 'react' +import { ChevronRight, ChevronLeft, Compass, Sun, Moon, Users, Wallet, MapPin, Sparkles, Check } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { TESTIMONIALS, PAYMENT_CONFIG } from '@/app/lp/_config/types' + +interface QuizOption { + label: string + value: string + icon: React.ReactNode + description: string +} + +interface QuizQuestion { + id: number + question: string + subtitle: string + options: QuizOption[] +} + +const QUESTIONS: QuizQuestion[] = [ + { + id: 1, + question: 'What calls to you more?', + subtitle: 'Choose the vibe that matches your dream vacation', + options: [ + { + label: 'Beach & Relaxation', + value: 'beach', + icon: , + description: 'Soft sand, turquoise water, total bliss', + }, + { + label: 'Adventure & Exploration', + value: 'adventure', + icon: , + description: 'Ruins, cenotes, snorkeling, zip-lines', + }, + ], + }, + { + id: 2, + question: 'How do you spend your evenings?', + subtitle: 'Your ideal vacation night looks like...', + options: [ + { + label: 'Vibrant Nightlife', + value: 'nightlife', + icon: , + description: 'Clubs, bars, live music, dancing', + }, + { + label: 'Peaceful Relaxation', + value: 'relaxation', + icon: , + description: 'Spa, sunset cocktails, stargazing', + }, + ], + }, + { + id: 3, + question: "What's your budget style?", + subtitle: 'How you like to plan your spending', + options: [ + { + label: 'Spread It Out', + value: 'monthly', + icon: , + description: `$${PAYMENT_CONFIG.monthlyPrice}/month -- easy payments`, + }, + { + label: 'Pay Once & Done', + value: 'one-time', + icon: , + description: `$${PAYMENT_CONFIG.oneTimePrice} one-time -- save more`, + }, + ], + }, + { + id: 4, + question: "Who's coming with you?", + subtitle: 'Your travel crew matters for the perfect destination', + options: [ + { + label: 'Partner / Couple', + value: 'partner', + icon: , + description: 'Romantic getaway for two', + }, + { + label: 'Friends / Group', + value: 'group', + icon: , + description: 'Fun times with the crew', + }, + ], + }, +] + +interface DestinationResult { + name: string + tagline: string + description: string + image: string + highlights: string[] +} + +const RESULTS: Record = { + cancun: { + name: 'Cancun', + tagline: 'Your Perfect Match!', + description: + 'With its stunning beaches, vibrant nightlife, and easy accessibility, Cancun is the ideal destination for your travel style. Enjoy world-class resorts, crystal-clear Caribbean waters, and endless entertainment options.', + image: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + highlights: ['Pristine white sand beaches', 'World-class nightlife scene', 'Nearby Mayan ruins', 'Water sports paradise'], + }, + cabo: { + name: 'Cabo San Lucas', + tagline: 'Your Dream Destination Awaits!', + description: + 'Dramatic desert landscapes meet the sea in Cabo San Lucas. Perfect for couples seeking romance and adventure alike, with whale watching, sunset cruises, and luxury dining.', + image: '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + highlights: ['Iconic El Arco landmark', 'Luxury resort experiences', 'Whale watching season', 'Desert-meets-ocean scenery'], + }, + riviera: { + name: 'Riviera Maya', + tagline: 'Adventure Meets Paradise!', + description: + 'The Riviera Maya offers the best of both worlds -- ancient Mayan ruins, mysterious cenotes, and pristine Caribbean coastline. An explorer\'s dream with all the luxury you deserve.', + image: '/images/cdn/photo-1518638150340-f706e86654de.jpg', + highlights: ['Sacred cenote swimming', 'Tulum ruins by the sea', 'Eco-adventure parks', 'Secluded beach coves'], + }, + vallarta: { + name: 'Puerto Vallarta', + tagline: 'Culture & Coast Combined!', + description: + 'Puerto Vallarta charms with its cobblestone streets, vibrant art scene, and breathtaking Pacific sunsets. The warmth of Mexican culture shines brightest here.', + image: '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', + highlights: ['Stunning Pacific sunsets', 'Rich cultural heritage', 'Malecon boardwalk', 'Authentic Mexican cuisine'], + }, +} + +function getResult(answers: Record): DestinationResult { + const a1 = answers[1] + const a2 = answers[2] + const a4 = answers[4] + + if (a1 === 'adventure' && a2 === 'relaxation') return RESULTS.riviera + if (a1 === 'beach' && a2 === 'nightlife') return RESULTS.cancun + if (a1 === 'beach' && a4 === 'partner') return RESULTS.cabo + if (a1 === 'adventure' && a2 === 'nightlife') return RESULTS.cancun + if (a4 === 'partner' && a2 === 'relaxation') return RESULTS.cabo + if (a1 === 'adventure') return RESULTS.riviera + if (a4 === 'group') return RESULTS.cancun + return RESULTS.vallarta +} + +export default function LP13QuizFunnel() { + const [step, setStep] = useState<'intro' | 'quiz' | 'result'>('intro') + const [currentQuestion, setCurrentQuestion] = useState(0) + const [answers, setAnswers] = useState>({}) + + const handleAnswer = (questionId: number, value: string) => { + const newAnswers = { ...answers, [questionId]: value } + setAnswers(newAnswers) + + // Auto-advance after short delay + setTimeout(() => { + if (currentQuestion < QUESTIONS.length - 1) { + setCurrentQuestion((prev) => prev + 1) + } else { + setStep('result') + } + }, 400) + } + + const goBack = () => { + if (currentQuestion > 0) { + setCurrentQuestion((prev) => prev - 1) + } else { + setStep('intro') + } + } + + const result = step === 'result' ? getResult(answers) : null + const progress = step === 'quiz' ? ((currentQuestion + 1) / QUESTIONS.length) * 100 : 0 + + return ( +
+ + + {/* Intro Screen */} + {step === 'intro' && ( +
+ {/* Background decoration */} +
+
+ +
+
+ +
+ +

+ Find Your Perfect +
+ + Mexico Destination + +

+ +

+ Answer 4 quick questions and we'll match you with your ideal vacation spot -- + plus get a free travel guide tailored to your style. +

+ + + +

Takes less than 60 seconds

+ + {/* Feature grid */} +
+ {[ + { icon: , label: '4 Destinations' }, + { icon: , label: 'Personalized' }, + { icon: , label: 'Free Guide' }, + ].map((f) => ( +
+
{f.icon}
+

{f.label}

+
+ ))} +
+
+
+ )} + + {/* Quiz Steps */} + {step === 'quiz' && ( +
+ {/* Progress Bar */} +
+
+ + + Question {currentQuestion + 1} of {QUESTIONS.length} + +
+
+
+
+
+ + {/* Question */} +
+
+

+ {QUESTIONS[currentQuestion].question} +

+

+ {QUESTIONS[currentQuestion].subtitle} +

+ +
+ {QUESTIONS[currentQuestion].options.map((option) => { + const isSelected = answers[QUESTIONS[currentQuestion].id] === option.value + return ( + + ) + })} +
+
+
+ + {/* Step dots */} +
+ {QUESTIONS.map((_, i) => ( +
+ ))} +
+
+ )} + + {/* Result Screen */} + {step === 'result' && result && ( +
+ {/* Result Hero */} +
+ {result.name} +
+
+
+

+ + Based on your answers... +

+

+ {result.name} +

+

{result.tagline}

+
+
+
+ + {/* Result Content */} +
+

+ {result.description} +

+ + {/* Highlights */} +
+ {result.highlights.map((h) => ( +
+ + {h} +
+ ))} +
+ + {/* Ebook Gate */} +
+

+ Get Your Personalized {result.name} Guide +

+

+ Our free "Budget Luxury Travel" guide includes insider tips + specific to {result.name} -- best times to visit, hidden gems, and how to + get 5-star experiences on a budget. +

+
+ +
+

+ Instant download -- no spam, ever. +

+
+ + {/* Pricing teaser */} +
+

Your {result.name} vacation starts at

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

+ 5 days, 4 nights all-inclusive -- or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + {/* Testimonials */} +
+

+ What Other Travelers Say +

+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+ + {/* PayNow Section */} +
+

+ Ready to Book {result.name}? +

+

+ Claim your all-inclusive vacation certificate now +

+ + +
+ + {/* Retake */} +
+ +
+
+ + + + {/* Footer */} + + + +
+ )} +
+ ) +} diff --git a/src/components/lp/pages/LP14SocialWall.tsx b/src/components/lp/pages/LP14SocialWall.tsx new file mode 100644 index 0000000..ab04997 --- /dev/null +++ b/src/components/lp/pages/LP14SocialWall.tsx @@ -0,0 +1,392 @@ +'use client' + +import { useState } from 'react' +import { Heart, MessageCircle, Send, Bookmark, MoreHorizontal, Camera, Users } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { TESTIMONIALS, PAYMENT_CONFIG } from '@/app/lp/_config/types' + +interface SocialPost { + id: number + username: string + avatar: string + image: string + caption: string + likes: number + comments: number + timeAgo: string + location: string +} + +const SOCIAL_POSTS: SocialPost[] = [ + { + id: 1, + username: 'sarahtravels_', + avatar: '/images/cdn/photo-1494790108377-be9c29b29330.jpg', + image: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + caption: 'Still can\'t believe this was real life. Cancun you have my heart forever. Best $29/month I ever spent! #MexicoParadise #BudgetLuxury', + likes: 847, + comments: 43, + timeAgo: '2d', + location: 'Cancun, Mexico', + }, + { + id: 2, + username: 'mike.and.jen', + avatar: '/images/cdn/photo-1472099645785-5658abf4ff4e.jpg', + image: '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + caption: 'El Arco at sunset hits different when you know you paid less than $400 for the whole trip. The resort was 5-star quality! #CaboSanLucas #VacationCertificate', + likes: 1243, + comments: 67, + timeAgo: '3d', + location: 'Cabo San Lucas, Mexico', + }, + { + id: 3, + username: 'wanderlust.maria', + avatar: '/images/cdn/photo-1438761681033-6461ffad8d80.jpg', + image: '/images/cdn/photo-1518638150340-f706e86654de.jpg', + caption: 'Swimming in cenotes is a spiritual experience. The Riviera Maya exceeded every expectation. All meals included, all drinks included. This was the smartest travel decision I ever made.', + likes: 2104, + comments: 89, + timeAgo: '5d', + location: 'Riviera Maya, Mexico', + }, + { + id: 4, + username: 'dave_explores', + avatar: '/images/cdn/photo-1500648767791-00dcc994a43e.jpg', + image: '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', + caption: 'Puerto Vallarta sunsets are unmatched. We extended 3 extra nights because we couldn\'t leave. The food alone was worth 10x what we paid. #PuertoVallarta #Sunset', + likes: 956, + comments: 38, + timeAgo: '1w', + location: 'Puerto Vallarta, Mexico', + }, + { + id: 5, + username: 'beach.rachel', + avatar: '/images/cdn/photo-1544005313-94ddf0286df2.jpg', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + caption: 'POV: You\'re paying $1.30/day for THIS. My friends thought I was joking when I told them the price. Nope, just smart travel planning. Download the free guide, seriously. #BudgetTravel', + likes: 3201, + comments: 156, + timeAgo: '4d', + location: 'Cancun, Mexico', + }, + { + id: 6, + username: 'james.patricia', + avatar: '/images/cdn/photo-1522529599102-193c0d76b5b6.jpg', + image: '/images/cdn/photo-1551882547-ff40c63fe5fa.jpg', + caption: 'The infinity pool at our resort in Cabo. All-inclusive means all-inclusive -- every cocktail, every meal, every sunset. We saved over $2,800 compared to booking directly. Not a typo.', + likes: 1678, + comments: 72, + timeAgo: '6d', + location: 'Cabo San Lucas, Mexico', + }, + { + id: 7, + username: 'travelwith.lisa', + avatar: '/images/cdn/photo-1494790108377-be9c29b29330.jpg', + image: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg', + caption: 'When people ask how we afford to travel so much... I just smile. The vacation certificate program changed everything for us. 5 nights for what most people pay for 1.', + likes: 2489, + comments: 104, + timeAgo: '1w', + location: 'Riviera Maya, Mexico', + }, + { + id: 8, + username: 'sunset.chris', + avatar: '/images/cdn/photo-1472099645785-5658abf4ff4e.jpg', + image: '/images/cdn/photo-1468413253725-0d5181091f76.jpg', + caption: 'Day 4 in Puerto Vallarta and I never want to leave. The Malecon at golden hour is pure magic. If you\'re still on the fence, just get the free guide -- you\'ll see. #GoldenHour', + likes: 1102, + comments: 51, + timeAgo: '3d', + location: 'Puerto Vallarta, Mexico', + }, +] + +function formatLikes(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return n.toString() +} + +function SocialPostCard({ post }: { post: SocialPost }) { + const [liked, setLiked] = useState(false) + const [saved, setSaved] = useState(false) + const displayLikes = liked ? post.likes + 1 : post.likes + + return ( +
+ {/* Header */} +
+
+ {post.username} +
+

{post.username}

+

{post.location}

+
+
+ +
+ + {/* Image */} + {post.caption} + + {/* Action buttons */} +
+
+ + + +
+ +
+ + {/* Likes & Caption */} +
+

+ {formatLikes(displayLikes)} likes +

+

+ {post.username}{' '} + {post.caption} +

+

+ View all {post.comments} comments -- {post.timeAgo} ago +

+
+
+ ) +} + +export default function LP14SocialWall() { + return ( +
+ + + {/* Hero */} +
+
+
+
+ + #MexicoParadise +
+ +

+ Join 2,847 +
+ Happy Travelers +

+ +

+ Real people. Real vacations. Real savings. See what our travelers + are posting from their all-inclusive Mexico getaways. +

+ +
+ +
+ + 12,400+ downloads +
+
+ + {/* Traveler avatars */} +
+
+ {TESTIMONIALS.slice(0, 5).map((t, i) => ( + {t.name} + ))} +
+ + + 2,842 more travelers + +
+
+
+ + {/* Social Post Grid */} +
+

+ Straight from Their Feeds +

+

+ What travelers are sharing about their Mexico Paradise experience +

+ +
+ {SOCIAL_POSTS.slice(0, 6).map((post) => ( + + ))} +
+
+ + {/* Stats Bar */} +
+
+ {[ + { value: '2,847', label: 'Happy Travelers' }, + { value: '4.8/5', label: 'Average Rating' }, + { value: '$2,800+', label: 'Avg. Savings' }, + { value: '18 mo', label: 'Booking Window' }, + ].map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+
+ + {/* More posts */} +
+
+ {SOCIAL_POSTS.slice(6).map((post) => ( + + ))} +
+
+ + {/* Ebook Capture */} +
+
+
+ +
+

+ Your Turn to Post +

+

+ Get our free "Budget Luxury Travel" guide and discover 5 secrets + to luxury Mexico vacations on a budget. Your feed is about to level up. +

+ +
+
+ + {/* Testimonials */} +
+

+ Verified Reviews +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+ + {/* PayNow Section */} +
+
+

+ Join the Community +

+

+ 5 days, 4 nights all-inclusive in Mexico. + Starting at just ${PAYMENT_CONFIG.monthlyPrice}/month. +

+ + + + + + +
+
+ + + + {/* Footer */} + + + +
+ ) +} diff --git a/src/components/lp/pages/LP15SavingsJournal.tsx b/src/components/lp/pages/LP15SavingsJournal.tsx new file mode 100644 index 0000000..6b7dda6 --- /dev/null +++ b/src/components/lp/pages/LP15SavingsJournal.tsx @@ -0,0 +1,465 @@ +'use client' + +import { DollarSign, TrendingDown, Coffee, Plane, PiggyBank, Calculator, ArrowDown, Check } from 'lucide-react' +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { TESTIMONIALS, PAYMENT_CONFIG } from '@/app/lp/_config/types' + +const DAILY_COMPARISONS = [ + { label: 'Morning Latte', cost: 5.50, icon: Coffee, color: '#795548' }, + { label: 'Fast Food Lunch', cost: 12.00, icon: DollarSign, color: '#F44336' }, + { label: 'Streaming Subs', cost: 4.30, icon: DollarSign, color: '#9C27B0' }, + { label: 'Mexico Vacation', cost: 1.30, icon: Plane, color: '#2E7D32' }, +] + +const SAVINGS_CHART_DATA = [ + { name: 'Booking Direct', cost: 3200, fill: '#EF5350' }, + { name: 'Travel Agent', cost: 2800, fill: '#FF7043' }, + { name: 'Online Deal', cost: 2100, fill: '#FFA726' }, + { name: 'Certificate', cost: 399, fill: '#2E7D32' }, +] + +const COST_BREAKDOWN = [ + { item: 'Hotel (4 nights)', regular: 1600, certificate: 0 }, + { item: 'All meals', regular: 600, certificate: 0 }, + { item: 'Drinks', regular: 300, certificate: 0 }, + { item: 'Resort amenities', regular: 200, certificate: 0 }, + { item: 'Certificate cost', regular: 0, certificate: 399 }, +] + +export default function LP15SavingsJournal() { + const totalRegular = COST_BREAKDOWN.reduce((sum, item) => sum + item.regular, 0) + const totalCertificate = COST_BREAKDOWN.reduce((sum, item) => sum + item.certificate, 0) + const savings = totalRegular - totalCertificate + + return ( +
+ + + {/* Hero */} +
+ {/* Decorative pattern */} +
+ +
+
+ + + The Savings Calculator + +
+ +

+ The Numbers That +
+ Will Surprise You +

+ +

+ A 5-star Mexico vacation for less than your daily coffee habit. + Let's break down the math. +

+ + {/* Daily cost hero stat */} +
+

Your daily vacation cost

+
+ + $1.30 + + /day +
+

+ That's less than a morning latte. +

+ +
+
+
+ + {/* Daily Cost Comparison */} +
+

+ What $1.30 a Day Looks Like +

+

+ Things you spend more on every day without thinking twice +

+ +
+ {DAILY_COMPARISONS.map((item) => { + const IconComponent = item.icon + const isVacation = item.label === 'Mexico Vacation' + return ( +
+
+ +
+
+

{item.label}

+

+ ${item.cost.toFixed(2)}/day +

+
+ {isVacation && ( + + BEST + + )} +
+ ) + })} +
+ +
+ +

+ Skip your latte 4 days a month and your vacation pays for itself. +

+

+ 4 lattes = $22.00 -- that's more than half a monthly payment of ${PAYMENT_CONFIG.monthlyPrice}. +

+
+
+ + {/* Savings Chart */} +
+
+

+ How Booking Methods Compare +

+

+ Average cost for 5 nights all-inclusive Mexico vacation (per couple) +

+ +
+
+ + + `$${value}`} + axisLine={false} + tickLine={false} + tick={{ fontSize: 12, fill: '#9CA3AF' }} + /> + + [`$${value}`, 'Cost']} + contentStyle={{ + borderRadius: '8px', + border: '1px solid #E5E7EB', + boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', + }} + /> + + + +
+ +
+ +

+ Certificate holders save an average of $2,801 per vacation +

+
+
+
+
+ + {/* Cost Breakdown Table */} +
+

+ The Full Breakdown +

+

+ What's included in your certificate vs. paying full price +

+ +
+ + + + + + + + + + {COST_BREAKDOWN.map((row) => ( + + + + + + ))} + + + + + + + + + + + + +
ItemRegular PriceCertificate
{row.item} + {row.regular > 0 ? `$${row.regular.toLocaleString()}` : '--'} + + {row.certificate > 0 ? `$${row.certificate}` : ( + + + Included + + )} +
Total + ${totalRegular.toLocaleString()} + + ${totalCertificate} +
+ You save + + ${savings.toLocaleString()} +
+
+
+ + {/* Ebook Capture */} +
+
+
+ +
+

+ Get the Full Savings Guide +

+

+ Our free "Budget Luxury Travel" guide reveals 5 more strategies to + maximize your savings -- including a trick that can save you up to $500 extra + on your trip. +

+
+ +
+

+ Join 12,400+ smart travelers who downloaded this guide +

+
+
+ + {/* Payment option highlight */} +
+

+ Two Ways to Save +

+

+ Choose the payment plan that fits your budget +

+ +
+ {/* Monthly */} +
+

Monthly Plan

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

+ for {PAYMENT_CONFIG.totalMonths} months (${PAYMENT_CONFIG.totalPrice} total) +

+

+ That's just ${(PAYMENT_CONFIG.monthlyPrice / 30).toFixed(2)}/day +

+
+ Most Popular +
+
+ + {/* One-time */} +
+

One-Time Payment

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+

+ single payment -- done! +

+

+ That's ${(PAYMENT_CONFIG.oneTimePrice / 365).toFixed(2)}/day over a year +

+
+ Best Value +
+
+
+
+ + {/* Testimonials */} +
+

+ Smart Travelers, Happy Reviews +

+
+ {TESTIMONIALS.slice(0, 4).map((t, i) => ( + + ))} +
+
+ + {/* PayNow Section */} +
+
+

+ Start Saving Today +

+

+ Claim your 5-day, 4-night all-inclusive Mexico vacation certificate +

+ + + + +
+
+ + + + {/* FAQ */} +
+

+ Questions About Pricing & Value +

+ +
+ + {/* Footer */} + + + +
+ ) +} diff --git a/src/components/lp/pages/LP16CouplesRetreat.tsx b/src/components/lp/pages/LP16CouplesRetreat.tsx new file mode 100644 index 0000000..d5e7e0c --- /dev/null +++ b/src/components/lp/pages/LP16CouplesRetreat.tsx @@ -0,0 +1,445 @@ +'use client' + +import { useState } from 'react' +import { Heart, Sparkles, Wine, Sunset, Star, MapPin, Gift, Music } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const ROSE = '#880E4F' +const WINE_COLOR = '#4A0025' +const GOLD = '#D4AF37' + +const couplesTestimonials = [ + { + quote: "We renewed our vows on the beach at sunset. This trip brought us closer together than we've been in years.", + name: "Sarah & Mike", + location: "Chicago, IL", + photo: "/images/cdn/photo-1522529599102-193c0d76b5b6.jpg", + }, + { + quote: "Our anniversary trip to Cabo was pure magic. Candlelit dinners, couples massages, and the most breathtaking views together.", + name: "Jennifer & Tom", + location: "New York, NY", + photo: "/images/cdn/photo-1494790108377-be9c29b29330.jpg", + }, + { + quote: "We fell in love all over again in the Riviera Maya. The romance package made everything feel so special and intimate.", + name: "David & Lisa", + location: "Denver, CO", + photo: "/images/cdn/photo-1472099645785-5658abf4ff4e.jpg", + }, +] + +const romanticBenefits = [ + { + icon: Wine, + title: "Private Candlelit Dinners", + description: "Dine together under the stars with gourmet cuisine and premium wines included in your all-inclusive stay.", + }, + { + icon: Sparkles, + title: "Couples Spa Experiences", + description: "Side-by-side massages, aromatherapy baths, and relaxation rituals designed for the two of you.", + }, + { + icon: Sunset, + title: "Sunset Beach Walks", + description: "Miles of pristine shoreline reserved for your private moments together as the sky paints itself gold.", + }, + { + icon: Music, + title: "Live Music & Dancing", + description: "Sway together to live Latin rhythms at the resort's intimate lounges and open-air terraces.", + }, + { + icon: MapPin, + title: "Romantic Excursions", + description: "Snorkeling together in crystal cenotes, sailing at sunset, or exploring ancient ruins hand in hand.", + }, + { + icon: Gift, + title: "Special Touches", + description: "Rose petal turndowns, champagne on arrival, and little surprises that make your getaway unforgettable.", + }, +] + +export default function LP16CouplesRetreat() { + const [showAllBenefits, setShowAllBenefits] = useState(false) + + return ( +
+ + + {/* Hero Section */} +
+
+ Romantic couple on beach at sunset +
+
+
+ +
+
+ + + A Romantic Escape for Two + + +
+ +

+ You Both Deserve This +

+ +

+ 5 days and 4 nights at an all-inclusive Mexican paradise. + Just the two of you, the ocean, and nothing on your calendar. +

+ +

+ Starting at just ${PAYMENT_CONFIG.monthlyPrice}/month together +

+ +
+

+ Get our free couples travel guide first: +

+ +
+
+ + {/* Decorative candlelight glow */} +
+
+ + {/* What Awaits You Both */} +
+
+
+

+ Your Romantic Itinerary +

+

+ What Awaits You Both +

+

+ Every detail of your escape is designed for connection, relaxation, and romance. +

+
+ +
+ {romanticBenefits + .slice(0, showAllBenefits ? undefined : 3) + .map((benefit) => ( +
+ +

+ {benefit.title} +

+

+ {benefit.description} +

+
+ ))} +
+ + {!showAllBenefits && ( +
+ +
+ )} +
+
+ + {/* Side-by-side image + quote */} +
+
+
+ Romantic resort setting +
+
+
+ +
+ “The best thing we ever did for our relationship was stop saying + ‘someday’ and book the trip.” +
+

+ — Every couple who finally went +

+
+
+
+
+ + {/* Destination Carousel */} +
+
+
+

+ Choose Your Escape Together +

+

+ Four Romantic Destinations +

+
+ + +
+
+ + {/* Ebook Capture Section */} +
+
+ +

+ Free Couples Travel Guide +

+

+ Discover 5 secrets to planning a luxury Mexico vacation together — + without the luxury price tag. Written for couples, by couples. +

+ + + +

+ Join 12,000+ couples who downloaded our guide +

+
+
+ + {/* Pricing + Pay Now */} +
+
+
+

+ Your Romantic Getaway +

+

+ Ready to Go Together? +

+

+ 5 days, 4 nights, all-inclusive — for both of you. +

+
+ +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo for {PAYMENT_CONFIG.totalMonths} months +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + +
+ + +
+
+ + {/* Couples Testimonials */} +
+
+
+

+ Love Stories from Paradise +

+
+ +
+ {couplesTestimonials.map((t) => ( + + ))} +
+
+
+ + + + {/* FAQ */} +
+
+

+ Questions Couples Ask +

+ + +
+
+ + {/* Final CTA */} +
+ +

+ Your Love Story Deserves a Beautiful Setting +

+

+ Start with our free guide. Dream together tonight, travel together soon. +

+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP17Postcards.tsx b/src/components/lp/pages/LP17Postcards.tsx new file mode 100644 index 0000000..703bfc0 --- /dev/null +++ b/src/components/lp/pages/LP17Postcards.tsx @@ -0,0 +1,535 @@ +'use client' + +import { useState } from 'react' +import { Plane, Send, MapPin, Stamp } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const BROWN = '#5D4037' +const AIRMAIL_BLUE = '#1565C0' +const RED = '#D32F2F' +const CREAM = '#FFF8E1' +const PAPER = '#FFFDF5' + +interface PostcardData { + destination: string + image: string + message: string + stamp: string + dateline: string +} + +const postcards: PostcardData[] = [ + { + destination: 'Cancun', + image: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + message: "Dear Future You,\n\nThe water here is the most unreal shade of turquoise. We spent all day at the pool bar and didn't spend a dime — everything's included! Tomorrow we're snorkeling. Wish you were here already.\n\nWith love from paradise,\nYour Future Self", + stamp: 'MX', + dateline: 'Cancun, Mexico', + }, + { + destination: 'Cabo San Lucas', + image: '/images/cdn/photo-1580415200778-625cb1890ab5.jpg', + message: "Querido amigo,\n\nThe arch at Land's End is even more stunning in person. Had the best fish tacos of my life today, and the sunset from our balcony — I can't even describe it. Why didn't we come sooner?\n\nNever leaving,\nYour Happy Self", + stamp: 'MX', + dateline: 'Cabo San Lucas, Mexico', + }, + { + destination: 'Riviera Maya', + image: '/images/cdn/photo-1518638150340-f706e86654de.jpg', + message: "Hey there,\n\nSwam in a cenote today. Underground. Crystal clear water surrounded by ancient limestone. Then explored Mayan ruins. This place is pure magic — history and paradise mixed together.\n\nCome see for yourself,\nYour Adventurous Side", + stamp: 'MX', + dateline: 'Riviera Maya, Mexico', + }, + { + destination: 'Puerto Vallarta', + image: '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', + message: "Hola from PV!\n\nWalked the Malecon at sunset. Street musicians, amazing art, and the most gorgeous views of the bay. Had dinner at a rooftop restaurant — all included! This town has so much soul.\n\nSending sunshine,\nThe Relaxed You", + stamp: 'MX', + dateline: 'Puerto Vallarta, Mexico', + }, +] + +function PostcardCard({ postcard }: { postcard: PostcardData }) { + const [flipped, setFlipped] = useState(false) + + return ( +
setFlipped(!flipped)} + style={{ perspective: '1000px' }} + > +
+ {/* Front — Photo side */} +
+ {/* Airmail border */} +
+ +
+ {postcard.destination} +
+ +
+
+
+

+ {postcard.destination} +

+

+ {postcard.dateline} +

+
+ + Flip me! + +
+
+
+ + {/* Back — Message side */} +
+
+

+ {postcard.dateline} +

+

+ {postcard.message} +

+
+ +
+ + Click to flip back + +
+
+ + + {postcard.stamp} + +
+
+
+
+
+
+ ) +} + +export default function LP17Postcards() { + return ( +
+ + + {/* Hero */} +
+
+ Beautiful Mexico coastline +
+
+ +
+ {/* Airmail decoration */} +
+
+ +
+
+ +

+ Wish You Were Here +

+ +

+ Soon you will be... +

+ +

+ 5 days, 4 nights, all-inclusive at a luxury Mexican resort. + Starting at just ${PAYMENT_CONFIG.monthlyPrice}/month. + Your next postcard writes itself. +

+ +
+

+ Start planning with our free travel guide: +

+ +
+
+
+ + {/* Postcard Collection */} +
+
+
+ +

+ Postcards from Paradise +

+

+ Click each postcard to read the message on the back +

+
+ +
+ {postcards.map((postcard) => ( + + ))} +
+
+
+ + {/* What's Included — styled like a travel itinerary */} +
+
+
+

+ Your Travel Itinerary +

+
+ +
+
+ + + MEXICO PARADISE VACATIONS + +
+ + {[ + { day: 'Included', item: '5 Days / 4 Nights at a luxury all-inclusive resort' }, + { day: 'Included', item: 'All meals — breakfast, lunch, dinner, and snacks' }, + { day: 'Included', item: 'Unlimited drinks — cocktails, beer, wine, soft drinks' }, + { day: 'Included', item: 'Resort pools, beaches, and amenities' }, + { day: 'Included', item: 'Your choice of 4 stunning destinations' }, + { day: 'Included', item: '18 months to book your travel dates' }, + ].map((item, i) => ( +
+ + {item.day} + + {item.item} +
+ ))} + +
+ + TOTAL COST + +
+ + $1,500+ + + + ${PAYMENT_CONFIG.monthlyPrice}/mo + +
+
+
+
+
+ + {/* Ebook Section */} +
+
+ +

+ Free: Budget Luxury Travel Guide +

+

+ 5 secrets to luxury Mexico vacations on a budget +

+

+ Your first class ticket to smarter travel planning +

+ + +
+
+ + {/* Pay Now Section */} +
+
+
+

+ Ready to Send Your Own Postcard? +

+

+ Book your all-inclusive Mexico vacation today +

+
+ +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo for {PAYMENT_CONFIG.totalMonths} months +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + +
+ + +
+
+ + + + {/* FAQ */} +
+
+

+ Frequently Asked Questions +

+ + +
+
+ + {/* Final CTA */} +
+

+ Wish you were here? +

+

+ Soon You Will Be. +

+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP18StressRelief.tsx b/src/components/lp/pages/LP18StressRelief.tsx new file mode 100644 index 0000000..907e6b4 --- /dev/null +++ b/src/components/lp/pages/LP18StressRelief.tsx @@ -0,0 +1,464 @@ +'use client' + +import { Leaf, Waves, Sun, CloudSun, Heart, Wind, Droplets, TreePine } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const TEAL = '#004D40' +const MINT = '#E0F2F1' +const SOFT_TEAL = '#00796B' +const LIGHTEST = '#F1F8F7' + +const stressStats = [ + { stat: '77%', label: 'of Americans report physical symptoms of stress regularly' }, + { stat: '48%', label: 'say stress has increased in the past 5 years' }, + { stat: '1 in 3', label: 'haven\'t taken a vacation in over 2 years' }, +] + +const wellnessBenefits = [ + { + icon: Waves, + title: 'Ocean Therapy', + description: 'The sound of waves naturally lowers cortisol levels. Your resort sits steps from the shore.', + }, + { + icon: Sun, + title: 'Vitamin D Reset', + description: 'Sunshine boosts serotonin production, helping restore your natural sleep-wake cycle.', + }, + { + icon: Wind, + title: 'Digital Detox', + description: 'No deadlines, no meetings, no notifications. Five days of being truly present.', + }, + { + icon: Droplets, + title: 'Spa & Wellness', + description: 'On-site spa facilities with massage, hydrotherapy, and relaxation areas included in your stay.', + }, + { + icon: Leaf, + title: 'Nature Immersion', + description: 'Tropical gardens, cenotes, and jungle paths — nature is the original stress reliever.', + }, + { + icon: Heart, + title: 'Connection', + description: 'Uninterrupted time with the people who matter most. No rushing, no agenda.', + }, +] + +const reliefTestimonials = [ + { + quote: "I didn't realize how burned out I was until day two when I finally stopped thinking about work. By day four, I felt like a different person. I actually cried happy tears.", + name: "Maria G.", + location: "Houston, TX", + photo: "/images/cdn/photo-1438761681033-6461ffad8d80.jpg", + }, + { + quote: "My therapist told me to take a real vacation. This was it. The sound of the ocean, no phone, good food — I came back genuinely rested for the first time in years.", + name: "Rachel T.", + location: "Phoenix, AZ", + photo: "/images/cdn/photo-1544005313-94ddf0286df2.jpg", + }, + { + quote: "We both work stressful jobs. This trip was medicine. Waking up without an alarm, eating breakfast overlooking the ocean — we needed every second of it.", + name: "David & Lisa", + location: "Denver, CO", + photo: "/images/cdn/photo-1472099645785-5658abf4ff4e.jpg", + }, +] + +export default function LP18StressRelief() { + return ( +
+ + + {/* Hero — Serene, minimal */} +
+
+ Peaceful beach meditation at sunrise +
+
+ +
+ + +

+ Your Mind Needs
+ a Beach +

+ +

+ Five days of warm sand, gentle waves, and absolutely nothing + you have to do. All-inclusive. All taken care of. +

+ +

+ From ${PAYMENT_CONFIG.monthlyPrice}/month · No rush, no pressure +

+ +
+

+ Start with a free guide to planning your escape: +

+ +
+
+
+ + {/* Breathing space */} +
+ + {/* Why You Need This */} +
+
+

+ Why You Need This Escape +

+ +

+ You already know. The tension in your shoulders. The racing thoughts at 2 AM. + The feeling that you are always behind on something. Your body and mind are + asking for a pause. This is that pause. +

+ +
+ {stressStats.map((item) => ( +
+

+ {item.stat} +

+

+ {item.label} +

+
+ ))} +
+
+
+ + {/* Breathing space */} +
+ + {/* Wellness Benefits */} +
+
+
+

+ How This Trip Heals +

+

+ Science-backed reasons why vacation is medicine +

+
+ +
+ {wellnessBenefits.map((benefit) => ( +
+
+ +
+
+

+ {benefit.title} +

+

+ {benefit.description} +

+
+
+ ))} +
+
+
+ + {/* Peaceful image break */} +
+ Serene ocean view +
+

+ “Almost everything will work again if you unplug it for a few minutes—including you.” +

+
+
+ + {/* Destinations — gentle presentation */} +
+
+
+

+ Four Places to Find Your Peace +

+
+ +
+ {DESTINATIONS.map((dest) => ( +
+
+ {dest.name} +
+
+

+ {dest.name} +

+

+ {dest.tagline} +

+
+
+ ))} +
+
+
+ + {/* Ebook Section */} +
+
+ +

+ Your Free Travel Guide +

+

+ Budget Luxury Travel: 5 secrets to luxury Mexico vacations on a budget +

+

+ No sales pitch. Just helpful information to start dreaming. +

+ + +
+
+ + {/* Gentle Pay Now */} +
+
+
+

+ When You Are Ready +

+

+ 5 days, 4 nights, all-inclusive. Take your time deciding. +

+
+ +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /month for {PAYMENT_CONFIG.totalMonths} months +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day refund guarantee +

+
+ + +
+ + +
+
+ + {/* Testimonials */} +
+
+
+

+ They Came Back Renewed +

+
+ +
+ {reliefTestimonials.map((t) => ( + + ))} +
+
+
+ + + + {/* FAQ */} +
+
+

+ Common Questions +

+ + +
+
+ + {/* Gentle final CTA */} +
+ +

+ Give Yourself Permission to Rest +

+

+ Start with the free guide. No commitment, no rush. + Just a first step toward the break you deserve. +

+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP19FoodieParadise.tsx b/src/components/lp/pages/LP19FoodieParadise.tsx new file mode 100644 index 0000000..1988398 --- /dev/null +++ b/src/components/lp/pages/LP19FoodieParadise.tsx @@ -0,0 +1,496 @@ +'use client' + +import { UtensilsCrossed, Wine, Coffee, IceCream, Flame, ChefHat, GlassWater, Beef } from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, FAQ_ITEMS } from '@/app/lp/_config/types' + +const CHILE_RED = '#BF360C' +const CHOCOLATE = '#3E2723' +const AMBER = '#FF8F00' +const CREAM = '#FFF8E1' +const DARK_BG = '#1A0F0A' + +interface MenuItem { + name: string + description: string + tag?: string +} + +interface MenuSection { + title: string + icon: React.ElementType + items: MenuItem[] +} + +const menuSections: MenuSection[] = [ + { + title: 'Breakfast Buffet', + icon: Coffee, + items: [ + { name: 'Chilaquiles Verdes', description: 'Crispy tortillas in tangy tomatillo salsa, crema, queso fresco, and fried eggs', tag: 'Chef\'s Pick' }, + { name: 'Tropical Fruit Station', description: 'Fresh mango, papaya, pineapple, dragon fruit, and coconut' }, + { name: 'Huevos Rancheros', description: 'Farm eggs on corn tortillas with ranchero sauce, refried beans, and avocado' }, + { name: 'Made-to-Order Omelettes', description: 'Choose your fillings: peppers, mushrooms, chorizo, Oaxaca cheese' }, + ], + }, + { + title: 'Poolside Lunch', + icon: GlassWater, + items: [ + { name: 'Baja Fish Tacos', description: 'Beer-battered mahi-mahi, chipotle crema, mango pico, shredded cabbage', tag: 'Fan Favorite' }, + { name: 'Ceviche Trio', description: 'Shrimp, octopus, and fish ceviche with avocado, lime, and tostadas' }, + { name: 'Grilled Lobster Quesadilla', description: 'Butter-poached lobster, Oaxaca cheese, roasted corn salsa' }, + { name: 'Guacamole Fresco', description: 'Tableside-prepared with Hass avocados, serrano chile, cilantro, lime' }, + ], + }, + { + title: 'Dinner Grill', + icon: Flame, + items: [ + { name: 'Surf & Turf Mexicano', description: 'Grilled ribeye with chimichurri and garlic butter shrimp', tag: 'Signature' }, + { name: 'Cochinita Pibil', description: 'Slow-roasted Yucatan pork in achiote, pickled red onion, habanero' }, + { name: 'Mole Negro', description: 'Heritage recipe with 28 ingredients, served over free-range chicken' }, + { name: 'Whole Grilled Red Snapper', description: 'Al pastor-seasoned, grilled over charcoal with roasted vegetables' }, + ], + }, + { + title: 'All-Day Bar', + icon: Wine, + items: [ + { name: 'Premium Margaritas', description: 'Classic lime, mango habanero, tamarind, hibiscus, spicy watermelon', tag: 'Unlimited' }, + { name: 'Mexican Craft Beer', description: 'Rotating selection of local craft breweries from across Mexico' }, + { name: 'Fresh Juice Bar', description: 'Cold-pressed juices, smoothies, and agua frescas made to order' }, + { name: 'Top-Shelf Spirits', description: 'Premium tequila, mezcal, rum, whiskey — all included in your stay' }, + ], + }, +] + +const diningVenues = [ + { + name: 'Oceanfront Grill', + description: 'Seafood and steaks with your toes in the sand', + image: '/images/cdn/photo-1414235077428-338989a2e8c0.jpg', + }, + { + name: 'La Hacienda', + description: 'Authentic regional Mexican cuisine in a colonial courtyard', + image: '/images/cdn/photo-1555396273-367ea4eb4db5.jpg', + }, + { + name: 'Teppanyaki Live', + description: 'Japanese-Mexican fusion with live tableside cooking', + image: '/images/cdn/photo-1517248135467-4c7edcad34c4.jpg', + }, + { + name: 'Dolce Vita', + description: 'Italian-inspired dishes with a Mexican twist', + image: '/images/cdn/photo-1550966871-3ed3cdb51f3a.jpg', + }, +] + +export default function LP19FoodieParadise() { + return ( +
+ + + {/* Hero */} +
+
+ Gourmet Mexican cuisine spread +
+
+ +
+
+ + + All-Inclusive Dining + + +
+ +

+ Unlimited Everything +

+ +

+ 5 days, 4 nights of all-you-can-eat gourmet cuisine, unlimited premium drinks, + and world-class dining at a luxury Mexican resort. +

+ +

+ From ${PAYMENT_CONFIG.monthlyPrice}/month · Every meal, every drink, every bite — included. +

+ +
+

+ Get our free travel guide to eating your way through Mexico: +

+ +
+
+
+ + {/* The Menu */} +
+
+
+ +

+ The Menu +

+

+ A taste of what awaits — all included in your stay +

+
+
+ + {menuSections.map((section, sIndex) => ( +
+
+ +

+ {section.title} +

+
+
+ +
+ {section.items.map((item) => ( +
+
+
+ + {item.name} + + {item.tag && ( + + {item.tag} + + )} +
+

+ {item.description} +

+
+ + Included + +
+ ))} +
+ + {sIndex < menuSections.length - 1 && ( +
+ )} +
+ ))} + +
+

+ Plus dessert buffets, 24-hour room service, late-night snack bars, and more. +

+

+ All included. Eat as much as you want. +

+
+
+
+ + {/* Dining Venues */} +
+
+
+

+ Multiple Restaurants, One Resort +

+

+ No reservations needed. No checks at the end of the meal. +

+
+ +
+ {diningVenues.map((venue) => ( +
+
+ {venue.name} +
+
+

+ {venue.name} +

+

+ {venue.description} +

+
+
+ ))} +
+
+
+ + {/* All You Can Eat emphasis */} +
+
+ +

+ All You Can Eat & Drink +

+

+ Breakfast. Lunch. Dinner. Snacks. Cocktails. Premium liquor. Craft beer. + Fresh juice. Coffee. Room service. Every single bite and sip is included + in your certificate price. No hidden costs. No resort fees. No surprise bar tabs. +

+ +
+ {[ + { icon: Coffee, label: 'Breakfast Buffet' }, + { icon: UtensilsCrossed, label: 'Multi-Course Dinners' }, + { icon: Wine, label: 'Premium Bar' }, + { icon: IceCream, label: 'Dessert & Snacks' }, + ].map((item) => ( +
+ +

+ {item.label} +

+
+ ))} +
+
+
+ + {/* Ebook Section */} +
+
+ +

+ Free: Budget Luxury Travel Guide +

+

+ 5 secrets to luxury Mexico vacations on a budget +

+

+ Including insider tips on the best resort dining experiences +

+ + +
+
+ + {/* Pay Now */} +
+
+
+

+ Ready to Feast? +

+

+ 5 days, 4 nights, unlimited food and drink. +

+
+ +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo for {PAYMENT_CONFIG.totalMonths} months +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · Every meal included +

+
+ + +
+ + +
+
+ + + + {/* FAQ */} +
+
+

+ Frequently Asked Questions +

+ + +
+
+ + {/* Final CTA */} +
+ +

+ Your Table Is Waiting +

+

+ Start with the free guide. Then come hungry. +

+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP20FamilyEscape.tsx b/src/components/lp/pages/LP20FamilyEscape.tsx new file mode 100644 index 0000000..76d04d8 --- /dev/null +++ b/src/components/lp/pages/LP20FamilyEscape.tsx @@ -0,0 +1,532 @@ +'use client' + +import { + Sun, Waves, TreePalm, Gamepad2, Shield, Heart, + Umbrella, IceCream, Music, Fish, Castle, Palette, + Coffee, Sparkles, Users, Star, MapPin, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const OCEAN_BLUE = '#0277BD' +const ORANGE = '#FF6F00' +const GREEN = '#7CB342' +const LIGHT_BLUE = '#E1F5FE' +const SAND = '#FFF8E1' + +const kidsGet = [ + { icon: Waves, text: 'Splash parks and kid-friendly pools' }, + { icon: Castle, text: 'Kids club with supervised activities' }, + { icon: IceCream, text: 'Unlimited ice cream and snacks' }, + { icon: Gamepad2, text: 'Beach games, sandcastle contests' }, + { icon: Fish, text: 'Snorkeling in shallow, calm waters' }, + { icon: Palette, text: 'Arts & crafts and treasure hunts' }, +] + +const parentsGet = [ + { icon: Coffee, text: 'Quiet adults-only pool and lounge' }, + { icon: Sparkles, text: 'Spa treatments and massage' }, + { icon: Music, text: 'Evening entertainment and live shows' }, + { icon: Sun, text: 'Beach time without checking the clock' }, + { icon: Heart, text: 'Date nights while kids are at the club' }, + { icon: Umbrella, text: 'All meals handled — no cooking, no dishes' }, +] + +const familyActivities = [ + { + title: 'Beach Adventures', + description: 'Build sandcastles, swim in warm turquoise waters, and spot tropical fish together.', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + }, + { + title: 'Snorkeling for All Ages', + description: 'Calm, shallow reefs perfect for first-time snorkelers. Equipment provided for the whole family.', + image: '/images/cdn/photo-1544551763-46a013bb70d5.jpg', + }, + { + title: 'Pool Party Every Day', + description: 'Waterslides, splash pads, and a swim-up bar (juice for the kids, cocktails for you).', + image: '/images/cdn/photo-1576610616656-d3aa5d1f4534.jpg', + }, + { + title: 'Cultural Exploration', + description: 'Visit ancient Mayan ruins, local markets, and learn about Mexican culture as a family.', + image: '/images/cdn/photo-1518638150340-f706e86654de.jpg', + }, +] + +const familyTestimonials = [ + { + quote: "Our kids still talk about this trip every single day. The kids club was amazing — they didn't want to leave! Meanwhile, we got actual relaxation time.", + name: "Sarah & Mike", + location: "Chicago, IL", + photo: "/images/cdn/photo-1522529599102-193c0d76b5b6.jpg", + }, + { + quote: "Best family vacation ever. Not having to worry about meal costs with three hungry kids was a game-changer. Everything was included!", + name: "Jennifer & Tom", + location: "New York, NY", + photo: "/images/cdn/photo-1494790108377-be9c29b29330.jpg", + }, + { + quote: "The kids learned to snorkel, built a hundred sandcastles, and made friends from all over. We actually came back rested — as parents! That never happens.", + name: "James & Patricia", + location: "Miami, FL", + photo: "/images/cdn/photo-1500648767791-00dcc994a43e.jpg", + }, +] + +export default function LP20FamilyEscape() { + return ( +
+ + + {/* Hero */} +
+
+ Beautiful family beach vacation +
+
+ +
+
+ + + +
+ +

+ Give Them the Vacation +
+ + They've Been Asking For + +

+ +

+ 5 days, 4 nights at an all-inclusive Mexican resort. + Unlimited fun for the kids. Actual relaxation for you. +

+ +

+ Starting at just ${PAYMENT_CONFIG.monthlyPrice}/month for the whole family +

+ +
+

+ Get our free family travel planning guide: +

+ +
+
+ + {/* Playful wave divider */} +
+ + + +
+
+ + {/* Kids Get / Parents Get Split */} +
+
+
+

+ Something for Everyone +

+

+ The whole family wins on this vacation +

+
+ +
+ {/* Kids Column */} +
+
+
+ +
+

+ Kids Get... +

+
+ +
+ {kidsGet.map((item) => ( +
+
+ +
+ + {item.text} + +
+ ))} +
+ +
+

+ “This is the BEST vacation EVER!” +

+

+ — Every kid who visits +

+
+
+ + {/* Parents Column */} +
+
+
+ +
+

+ Parents Get... +

+
+ +
+ {parentsGet.map((item) => ( +
+
+ +
+ + {item.text} + +
+ ))} +
+ +
+

+ “We actually came back rested!” +

+

+ — Every parent who visits +

+
+
+
+
+
+ + {/* Family Activities */} +
+
+
+

+ Adventures the Whole Family Will Love +

+

+ Create memories that last a lifetime +

+
+ +
+ {familyActivities.map((activity) => ( +
+
+ {activity.title} +
+
+

+ {activity.title} +

+

+ {activity.description} +

+
+
+ ))} +
+
+
+ + {/* What's All Included banner */} +
+
+

+ All-Inclusive Means All-Inclusive +

+ +
+ {[ + { icon: '🏨', label: 'Luxury Resort Room' }, + { icon: '🍽️', label: 'All Meals Included' }, + { icon: '🍹', label: 'Unlimited Drinks' }, + { icon: '🏊', label: 'Pools & Beach' }, + { icon: '🎭', label: 'Kids Club' }, + { icon: '🎪', label: 'Evening Shows' }, + { icon: '🏄', label: 'Water Sports' }, + { icon: '🎯', label: '18 Months to Book' }, + ].map((item) => ( +
+ {item.icon} +

{item.label}

+
+ ))} +
+
+
+ + {/* Destination Previews */} +
+
+
+

+ Pick Your Family's Paradise +

+

+ Four family-friendly destinations to choose from +

+
+ +
+ {DESTINATIONS.map((dest) => ( +
+
+ {dest.name} +
+
+

+ {dest.name} +

+

+ {dest.tagline} +

+
+
+ ))} +
+
+
+ + {/* Ebook Section */} +
+
+ +

+ Free Family Travel Guide +

+

+ Budget Luxury Travel: 5 secrets to luxury Mexico vacations on a budget +

+

+ Tips on traveling with kids, packing lists, and picking the right resort +

+ + +
+
+ + {/* Pay Now */} +
+
+
+

+ Ready for Family Fun? +

+

+ 5 days, 4 nights, all-inclusive — the whole family +

+
+ +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo for {PAYMENT_CONFIG.totalMonths} months +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day money-back guarantee +

+
+ + +
+ + +
+
+ + {/* Family Testimonials */} +
+
+
+

+ Families Love It Here +

+
+ +
+ {familyTestimonials.map((t) => ( + + ))} +
+
+
+ + + + {/* FAQ */} +
+
+

+ Family Travel FAQs +

+ + +
+
+ + {/* Final CTA */} +
+
+ + + +
+

+ They'll Remember This Forever +

+

+ Start planning with our free guide. The best family memories are just ahead. +

+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP21LastChance.tsx b/src/components/lp/pages/LP21LastChance.tsx new file mode 100644 index 0000000..4eb8bad --- /dev/null +++ b/src/components/lp/pages/LP21LastChance.tsx @@ -0,0 +1,465 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + AlertTriangle, + Flame, + Clock, + Check, + MapPin, + Star, + Shield, + Zap, + Gift, + Users, + Eye, + ArrowDown, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const RED = '#F44336' +const AMBER = '#FFC107' +const DARK_BG = '#0D0D0D' + +const FEATURES = [ + { + icon: MapPin, + title: '4 Premium Destinations', + description: 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta', + }, + { + icon: Clock, + title: '5 Days / 4 Nights', + description: 'A real vacation — not a weekend trip', + }, + { + icon: Gift, + title: 'All-Inclusive', + description: 'Every meal, every drink, every activity included', + }, + { + icon: Star, + title: '4-5 Star Resorts', + description: 'Luxury properties with world-class amenities', + }, + { + icon: Shield, + title: '30-Day Refund', + description: 'Full money back if you change your mind', + }, + { + icon: Zap, + title: '18 Months to Book', + description: 'Flexible scheduling on your terms', + }, +] + +function PulsingDot({ color = RED }: { color?: string }) { + return ( + + + + + ) +} + +function LiveVisitorCount() { + const [count, setCount] = useState(347) + const [claimed, setClaimed] = useState(23) + + useEffect(() => { + const interval = setInterval(() => { + setCount(prev => prev + (Math.random() > 0.5 ? 1 : -1)) + if (Math.random() > 0.8) { + setClaimed(prev => prev + 1) + } + }, 3000) + return () => clearInterval(interval) + }, []) + + return ( +
+
+ + + {count} people visited this page today + +
+
+ + + {claimed} certificates claimed in the last hour + +
+
+ ) +} + +function SpotsRemaining() { + const [spots, setSpots] = useState(12) + + useEffect(() => { + const interval = setInterval(() => { + setSpots(prev => { + if (prev <= 3) return prev + return Math.random() > 0.85 ? prev - 1 : prev + }) + }, 8000) + return () => clearInterval(interval) + }, []) + + return ( +
+ + + ONLY {spots} CERTIFICATES LEFT AT THIS PRICE + +
+ ) +} + +export default function LP21LastChance() { + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + {/* ========== HERO ========== */} +
+ {/* Background glow effect */} +
+
+ +
+ {/* Warning badge */} +
+ + + Final warning — Price expires soon + +
+ + {/* Headline */} +

+ This Price{' '} + + Dies + {' '} + When The Timer Hits Zero +

+ + {/* Giant countdown */} +
+ +
+ + {/* Live visitor count */} +
+ +
+ + {/* Spots remaining */} +
+ +
+ + {/* Price display with anchor */} +
+

Regular price: $59/mo

+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ {PAYMENT_CONFIG.totalMonths} months = ${PAYMENT_CONFIG.totalPrice} total | Or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + {/* CTA 1: Pulsing red button */} + + +

+ 30-day money-back guarantee. No questions asked. +

+ +
+ +
+
+
+ + {/* ========== WHAT YOU LOSE ========== */} +
+
+
+

+ Don't Lose{' '} + Everything You're About to Get +

+

+ When this timer runs out, you lose access to the lowest price we've ever offered + on a 5-day all-inclusive Mexico vacation. +

+
+ + {/* Second countdown */} +
+ +
+ +
+ {FEATURES.map((feature) => ( +
+ +

{feature.title}

+

{feature.description}

+
+ ))} +
+ + {/* CTA 2 */} +
+ +
+
+
+ + {/* ========== TIKTOK SECTION ========== */} +
+ +
+ + {/* ========== TESTIMONIALS ========== */} +
+
+

+ They Almost Missed Out.{' '} + They Didn't. +

+

+ These travelers locked in the same price you see right now. +

+ +
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* ========== THIRD COUNTDOWN STRIP ========== */} +
+
+ +

+ Time remaining at this price: +

+ +
+
+ + {/* ========== DESTINATIONS ========== */} +
+
+

+ Choose Your Paradise +

+ +
+
+ + {/* ========== SIGNUP FORM ========== */} +
+
+ {/* Mini countdown above form */} +
+
+ + + This price is only guaranteed for + + +
+ +

+ Lock In Your Price Now +

+

+ Don't lose the ${PAYMENT_CONFIG.monthlyPrice}/mo price. + It goes back to{' '} + $59/mo when the timer expires. +

+
+ +
+ +
+ + +
+
+ + {/* ========== FAQ ========== */} + + +
+
+

+ Questions? We've Got Answers. +

+ +
+
+ + {/* ========== FINAL CTA ========== */} +
+
+

+ Last Chance. We Mean It. +

+

+ This page will not be available at this price again. Every second you wait + is a second closer to losing ${PAYMENT_CONFIG.monthlyPrice}/mo forever. +

+ + + +
+ +
+ +

+ 5 days / 4 nights all-inclusive Mexico vacation. + ${PAYMENT_CONFIG.monthlyPrice}/mo x {PAYMENT_CONFIG.totalMonths} months or ${PAYMENT_CONFIG.oneTimePrice} one-time. + 30-day money-back guarantee. +

+
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP22TheProof.tsx b/src/components/lp/pages/LP22TheProof.tsx new file mode 100644 index 0000000..3fa0770 --- /dev/null +++ b/src/components/lp/pages/LP22TheProof.tsx @@ -0,0 +1,370 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Star, + Check, + Users, + Award, + ThumbsUp, + ArrowRight, + Shield, + Clock, + MapPin, + Heart, + TrendingUp, + BadgeCheck, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const GREEN = '#10B981' +const DARK_TEXT = '#1F2937' + +function StatsBar() { + const [claimedCount, setClaimedCount] = useState(2847) + + useEffect(() => { + const interval = setInterval(() => { + if (Math.random() > 0.6) { + setClaimedCount(prev => prev + 1) + } + }, 5000) + return () => clearInterval(interval) + }, []) + + const stats = [ + { value: claimedCount.toLocaleString(), label: 'Certificates Claimed', icon: Users }, + { value: '4.8', label: 'Average Rating', icon: Star, suffix: '/5 ★' }, + { value: '98%', label: 'Would Recommend', icon: ThumbsUp }, + { value: '4+', label: 'Years in Business', icon: Award }, + ] + + return ( +
+
+
+ {stats.map((stat) => ( +
+ +

+ {stat.value} + {stat.suffix && {stat.suffix}} +

+

{stat.label}

+
+ ))} +
+
+
+ ) +} + +function RecentActivity() { + const [activities, setActivities] = useState([ + { name: 'Sarah M.', location: 'TX', action: 'claimed a certificate', time: '2 min ago' }, + { name: 'David K.', location: 'FL', action: 'booked Cancun', time: '5 min ago' }, + { name: 'Jessica R.', location: 'CA', action: 'claimed a certificate', time: '8 min ago' }, + { name: 'Mike T.', location: 'NY', action: 'chose Riviera Maya', time: '12 min ago' }, + { name: 'Amanda L.', location: 'IL', action: 'claimed a certificate', time: '15 min ago' }, + ]) + + return ( +
+ {activities.map((activity, i) => ( +
+ +

+ {activity.name} from {activity.location} {activity.action} +

+ {activity.time} +
+ ))} +
+ ) +} + +function VerifiedBadge() { + return ( + + + Verified + + ) +} + +export default function LP22TheProof() { + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + {/* ========== HERO ========== */} +
+
+ {/* Social proof badge */} +
+ + + 2,847+ happy travelers and counting + + +
+ +

+ Don't Take Our Word For It — +
+ Watch Real Travelers +

+ +

+ Thousands of people just like you have already locked in their Mexico vacation + at ${PAYMENT_CONFIG.monthlyPrice}/mo. Here's what they're saying. +

+ + {/* Timer strip */} +
+ + + Don't lose the $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo price — expires in + + +
+ + {/* CTA 1 */} +
+ +
+
+
+ + {/* ========== STATS BAR ========== */} + + + {/* ========== TIKTOK HERO SECTION ========== */} +
+ +
+ + {/* ========== TESTIMONIAL WALL ========== */} +
+
+
+

+ Real Reviews From Real Travelers +

+

+ Every single review is from a verified certificate holder +

+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + 4.8 out of 5 + (2,847 reviews) +
+
+ + {/* All 6 testimonials */} +
+ {TESTIMONIALS.map((t, i) => ( +
+ +
+ +
+
+ ))} +
+ + {/* CTA 2 */} +
+ +
+
+
+ + {/* ========== RECENT ACTIVITY ========== */} +
+
+
+

+ Happening Right Now +

+

+ Real people claiming their certificates as you read this +

+
+ +

+ + 23 people claimed this in the last hour +

+
+
+ + {/* ========== COUNTDOWN STRIP ========== */} +
+
+

+ Don't lose the ${PAYMENT_CONFIG.monthlyPrice}/mo price — only available for: +

+ +
+
+ + {/* ========== COMPARISON TABLE ========== */} +
+
+
+

+ See How We Compare +

+

+ The same vacation that costs $2,500+ on Expedia — for just ${PAYMENT_CONFIG.oneTimePrice} +

+
+ +
+
+ + {/* ========== DESTINATIONS ========== */} +
+
+

+ 4 Stunning Destinations +

+

+ Choose from Mexico's most beautiful resort locations +

+ +
+
+ + {/* ========== SIGNUP FORM ========== */} +
+
+
+
+ + + Only available for + +
+ +

+ Join 2,847 Happy Travelers +

+

+ Don't lose the{' '} + $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo price. + It won't last. +

+
+ +
+ +
+ + +
+
+ + + + {/* ========== FAQ ========== */} +
+
+

+ Frequently Asked Questions +

+ +
+
+ + {/* ========== FINAL CTA ========== */} +
+
+

+ 2,847 People Can't Be Wrong +

+

+ Don't lose the ${PAYMENT_CONFIG.monthlyPrice}/mo price. Once the timer hits zero, + it goes back to $59/mo. +

+ +
+ +
+ + + +

+ 5 days / 4 nights all-inclusive. ${PAYMENT_CONFIG.monthlyPrice}/mo x{' '} + {PAYMENT_CONFIG.totalMonths} months. 30-day money-back guarantee. +

+
+
+ + + +
+ ) +} diff --git a/src/components/lp/pages/LP23VIPAccess.tsx b/src/components/lp/pages/LP23VIPAccess.tsx new file mode 100644 index 0000000..e672c3a --- /dev/null +++ b/src/components/lp/pages/LP23VIPAccess.tsx @@ -0,0 +1,462 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { + Crown, + Lock, + Shield, + Star, + Clock, + Check, + ArrowRight, + Gem, + MapPin, + Gift, + Zap, + KeyRound, + Sparkles, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const BLACK = '#0A0A0F' +const GOLD = '#D4AF37' +const CREAM = '#F5F0E8' +const DARK_SURFACE = '#14141A' + +const VIP_PERKS = [ + { + icon: Crown, + title: 'Priority Resort Selection', + description: 'VIP certificate holders get first pick of available dates and rooms', + }, + { + icon: Gem, + title: 'Premium Room Upgrade', + description: 'Complimentary upgrade to ocean view when available', + }, + { + icon: Gift, + title: 'Welcome Package', + description: 'Exclusive amenities basket delivered to your room on arrival', + }, + { + icon: Star, + title: '4-5 Star All-Inclusive', + description: 'Every meal, drink, and activity included for 5 days', + }, + { + icon: MapPin, + title: '4 Luxury Destinations', + description: 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta', + }, + { + icon: Shield, + title: 'VIP Money-Back Guarantee', + description: '30-day full refund with no questions asked', + }, +] + +function InvitationCode() { + const code = useMemo(() => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const segments = [] + for (let s = 0; s < 3; s++) { + let segment = '' + for (let i = 0; i < 4; i++) { + segment += chars[Math.floor(Math.random() * chars.length)] + } + segments.push(segment) + } + return segments.join('-') + }, []) + + return ( +
+ +
+

+ Your Invitation Code +

+

+ {code} +

+
+
+ ) +} + +function GoldDivider() { + return ( +
+
+ +
+
+ ) +} + +function ExclusivityCounter() { + const [remaining, setRemaining] = useState(8) + + useEffect(() => { + const interval = setInterval(() => { + setRemaining(prev => { + if (prev <= 2) return prev + return Math.random() > 0.9 ? prev - 1 : prev + }) + }, 12000) + return () => clearInterval(interval) + }, []) + + return ( +
+ + + + + + Only {remaining} VIP certificates remaining at this price + +
+ ) +} + +export default function LP23VIPAccess() { + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + {/* ========== HERO ========== */} +
+ {/* Gold ambient glow */} +
+ {/* Gold border lines */} +
+ +
+ {/* VIP badge */} +
+ + + Private Invitation + +
+ +

+ You've Been{' '} + + Personally Selected + +
+ For VIP Access +

+ +

+ This private invitation grants you exclusive access to our lowest-ever pricing on a + 5-day, 4-night all-inclusive Mexico vacation at a luxury resort. +

+ + + + {/* Invitation Code */} +
+ +
+ + {/* Timer */} +
+

+ This private link expires in: +

+ +
+ + + + {/* Price */} +
+

+ Standard rate: $59/mo +

+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ {PAYMENT_CONFIG.totalMonths} months = ${PAYMENT_CONFIG.totalPrice} total | Or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + {/* CTA 1 */} + + +

+ Invitation valid only while the timer is running +

+
+
+ + {/* ========== VIP PERKS ========== */} +
+
+
+

+ Your VIP Certificate Includes +

+

+ Don't lose these exclusive benefits when the timer runs out +

+
+ +
+ {VIP_PERKS.map((perk) => ( +
+ +

+ {perk.title} +

+

+ {perk.description} +

+
+ ))} +
+ + {/* CTA 2 */} +
+ +
+
+
+ + {/* ========== DESTINATIONS ========== */} +
+
+

+ Select Your Destination +

+

+ Four of Mexico's most exclusive resort destinations +

+ +
+
+ + {/* ========== GOLD COUNTDOWN STRIP ========== */} +
+
+ +

+ Your private invitation expires in: +

+ +
+
+ + {/* ========== TESTIMONIALS ========== */} +
+
+

+ What Our VIP Travelers Say +

+ +
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* ========== SIGNUP FORM ========== */} +
+
+
+ +

+ Accept Your VIP Invitation +

+

+ Don't lose the exclusive{' '} + $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo VIP rate. +

+
+ + + Invitation expires in + +
+
+ +
+ +
+ + +
+
+ + {/* ========== FAQ ========== */} + + +
+
+

+ Questions About Your Invitation +

+ +
+
+ + {/* ========== FINAL CTA ========== */} +
+
+
+ +

+ This Invitation Won't Wait +

+

+ Once the timer expires, this VIP pricing is permanently gone. + Don't lose your spot. +

+ + + +
+ +
+ +

+ 5 days / 4 nights all-inclusive at a luxury resort. + ${PAYMENT_CONFIG.monthlyPrice}/mo x {PAYMENT_CONFIG.totalMonths} months or ${PAYMENT_CONFIG.oneTimePrice} one-time. + 30-day money-back guarantee. +

+
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP24OneTap.tsx b/src/components/lp/pages/LP24OneTap.tsx new file mode 100644 index 0000000..7925b47 --- /dev/null +++ b/src/components/lp/pages/LP24OneTap.tsx @@ -0,0 +1,393 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { + ArrowRight, + Check, + Shield, + Star, + Clock, + Lock, + ChevronDown, + Zap, + MapPin, + BadgeCheck, + Sparkles, + X, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS, DESTINATIONS } from '@/app/lp/_config/types' + +const BLUE = '#2563EB' + +const QUICK_FACTS = [ + { icon: MapPin, text: 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta' }, + { icon: Clock, text: '5 days / 4 nights all-inclusive' }, + { icon: Star, text: '4-5 star luxury resorts' }, + { icon: Shield, text: '30-day money-back guarantee' }, +] + +function SlideUpForm({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const formRef = useRef(null) + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Slide-up panel */} +
+
+
+

Lock in your price

+

Takes 60 seconds. Seriously.

+
+ +
+ +
+ {/* Timer */} +
+ + + $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo expires in + + +
+ + +
+
+
+ ) +} + +function SpeedBadge() { + return ( +
+ + 60-second signup +
+ ) +} + +function MinimalTestimonial({ quote, name }: { quote: string; name: string }) { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+

“{quote}”

+

{name}

+
+ ) +} + +function DestinationPill({ name, image }: { name: string; image: string }) { + return ( +
+ {name} +
+

{name}

+

All-inclusive resort

+
+
+ ) +} + +export default function LP24OneTap() { + const [formOpen, setFormOpen] = useState(false) + const [claimedCount] = useState(() => Math.floor(Math.random() * 15) + 18) + + const scrollToForm = () => { + setFormOpen(true) + } + + return ( +
+ + + {/* ========== HERO — Mobile-first, minimal ========== */} +
+
+ {/* Speed badge */} +
+ +
+ + {/* Beach image */} +
+ Mexico beach paradise +
+
+

$59/mo

+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ 5 days / 4 nights all-inclusive Mexico vacation +

+
+
+ + {/* Timer */} +
+ + + Don't lose this price — expires in + + +
+ + {/* ONE BIG BUTTON — CTA 1 */} + + +

+ Takes 60 seconds. Seriously. +

+ + {/* Scarcity */} +
+

+ + + {claimedCount} people claimed this in the last hour + +

+
+ + {/* Trust badges — compact */} + +
+
+ + {/* ========== QUICK FACTS ========== */} +
+
+

+ What you get +

+
+ {QUICK_FACTS.map((fact) => ( +
+
+ +
+

{fact.text}

+
+ ))} +
+ + {/* CTA 2 */} + +
+
+ + {/* ========== DESTINATIONS ========== */} +
+
+

+ Choose your destination +

+
+ {DESTINATIONS.map((d) => ( + + ))} +
+
+
+ + {/* ========== MINI COUNTDOWN ========== */} +
+
+ + $59/mo ${PAYMENT_CONFIG.monthlyPrice}/mo + price expires in + + +
+
+ + {/* ========== TESTIMONIALS ========== */} +
+
+

+ Real travelers, real reviews +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+ +
+

+ + 4.8/5 from 2,847 reviews +

+
+
+
+ + {/* ========== PRICE BREAKDOWN ========== */} +
+
+

+ Two ways to pay +

+ +
+ {/* Monthly */} +
+

Most popular

+

$59/mo

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

x {PAYMENT_CONFIG.totalMonths} months

+
+ + {/* One-time */} +
+

Save more

+

$590

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+

one payment

+
+
+ + {/* CTA 3 */} + + +
+ SSL Encrypted + 30-Day Guarantee +
+
+
+ + + + {/* ========== FAQ ========== */} +
+
+

+ Quick answers +

+ +
+
+ + {/* ========== FINAL CTA ========== */} +
+
+

+ Don't lose this price +

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo for a 5-day all-inclusive Mexico vacation. + This price won't last. +

+ +
+ +
+ + + +

+ Takes 60 seconds. 30-day money-back guarantee. +

+
+
+ + {/* Slide-up form panel */} + setFormOpen(false)} /> + + +
+ ) +} diff --git a/src/components/lp/pages/LP25FOMOFeed.tsx b/src/components/lp/pages/LP25FOMOFeed.tsx new file mode 100644 index 0000000..f2ab83c --- /dev/null +++ b/src/components/lp/pages/LP25FOMOFeed.tsx @@ -0,0 +1,504 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + ArrowRight, + Clock, + Users, + Star, + Check, + Flame, + Heart, + MapPin, + Shield, + Zap, + TrendingUp, + Eye, + Sparkles, +} from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS, DESTINATIONS } from '@/app/lp/_config/types' + +const GRADIENT_FROM = '#EC4899' // pink-500 +const GRADIENT_VIA = '#F97316' // orange-500 +const GRADIENT_TO = '#EAB308' // yellow-500 + +const FEED_NAMES = [ + 'Maria T. from Houston, TX', + 'James & Linda from Miami, FL', + 'Sarah K. from San Diego, CA', + 'David M. from Brooklyn, NY', + 'Jennifer W. from Chicago, IL', + 'Chris P. from Phoenix, AZ', + 'Amanda R. from Denver, CO', + 'Robert J. from Atlanta, GA', + 'Nicole S. from Seattle, WA', + 'Brian L. from Dallas, TX', + 'Emily H. from Nashville, TN', + 'Kevin D. from Boston, MA', + 'Rachel G. from Portland, OR', + 'Mike & Julie from Austin, TX', + 'Lisa F. from Charlotte, NC', +] + +const FEED_ACTIONS = [ + 'just claimed their vacation certificate', + 'locked in the $29/mo price', + 'is heading to Cancun!', + 'booked Cabo San Lucas', + 'chose Riviera Maya', + 'signed up 2 minutes ago', + 'just started their payment plan', + 'is going to Puerto Vallarta!', +] + +function LiveActivityFeed() { + const [entries, setEntries] = useState>([]) + const [nextId, setNextId] = useState(0) + + useEffect(() => { + // Seed initial entries + const initial = Array.from({ length: 5 }, (_, i) => ({ + name: FEED_NAMES[i % FEED_NAMES.length], + action: FEED_ACTIONS[i % FEED_ACTIONS.length], + id: i, + })) + setEntries(initial) + setNextId(5) + + const interval = setInterval(() => { + setNextId(prev => { + const newId = prev + 1 + const newEntry = { + name: FEED_NAMES[newId % FEED_NAMES.length], + action: FEED_ACTIONS[newId % FEED_ACTIONS.length], + id: newId, + } + setEntries(prevEntries => [newEntry, ...prevEntries.slice(0, 6)]) + return newId + }) + }, 4000) + + return () => clearInterval(interval) + }, []) + + return ( +
+ + {entries.map((entry) => ( + + +
+

+ {entry.name} +

+

{entry.action}

+
+ just now +
+ ))} +
+ {/* Fade overlay at bottom */} +
+
+ ) +} + +function GradientButton({ onClick, children, className = '' }: { onClick: () => void; children: React.ReactNode; className?: string }) { + return ( + + ) +} + +function FOMOCounter() { + const [viewers, setViewers] = useState(89) + const [claimed, setClaimed] = useState(34) + + useEffect(() => { + const interval = setInterval(() => { + setViewers(prev => prev + (Math.random() > 0.5 ? 1 : Math.random() > 0.3 ? 0 : -1)) + if (Math.random() > 0.7) setClaimed(prev => prev + 1) + }, 3000) + return () => clearInterval(interval) + }, []) + + return ( +
+
+ + + {viewers} viewing now + +
+
+ + + {claimed} claimed today + +
+
+ + + Only 9 left at this price + +
+
+ ) +} + +function DestinationCard({ name, image, tagline }: { name: string; image: string; tagline: string }) { + return ( +
+ {name} +
+
+

{name}

+

{tagline}

+
+ + {Math.floor(Math.random() * 500) + 800} saves +
+
+
+ ) +} + +export default function LP25FOMOFeed() { + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + {/* ========== HERO ========== */} +
+ {/* Gradient background */} +
+ +
+ {/* Trending badge */} +
+ + Trending — 2,847 certificates claimed this month +
+ +

+ Everyone's Going to Mexico. +
+ + Here's Why. + +

+ +

+ 5 days, 4 nights, all-inclusive at a luxury resort — for just{' '} + $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo. + Don't be the last to know. +

+ + {/* Timer */} +
+ + + Don't lose this price — expires in + + +
+ + {/* FOMO counters */} +
+ +
+ + {/* CTA 1 */} + + Don't Miss Out — Claim Yours Now + + + +

+ 30-day money-back guarantee. Cancel anytime. +

+
+
+ + {/* ========== TIKTOK SECTION ========== */} +
+ +
+ + {/* ========== LIVE ACTIVITY FEED ========== */} +
+
+
+
+ + LIVE +
+

+ Happening Right Now +

+

+ Watch as people claim their certificates in real time +

+
+ + + +
+ + Don't Be the Only One Missing Out + + +
+
+
+ + {/* ========== GRADIENT COUNTDOWN STRIP ========== */} +
+
+

+ $59/mo ${PAYMENT_CONFIG.monthlyPrice}/mo pricing ends in: +

+ +
+
+ + {/* ========== DESTINATIONS ========== */} +
+
+
+

+ Where Will You Go? +

+

Four stunning destinations. One unbeatable price.

+
+
+ {DESTINATIONS.map((d) => ( + + ))} +
+
+
+ + {/* ========== TESTIMONIALS ========== */} +
+
+
+

+ They Went. They Loved It. +

+

+ Real reviews from travelers who didn't miss out +

+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + 4.8/5 (2,847 reviews) +
+
+ +
+ {TESTIMONIALS.slice(0, 6).map((t, i) => ( + + ))} +
+ + {/* CTA 3 */} +
+ + Join 2,847 Happy Travelers + + +
+
+
+ + {/* ========== WHAT'S INCLUDED ========== */} +
+
+

+ Everything's Included +

+
+ {[ + '5 days / 4 nights accommodation', + 'All meals — breakfast, lunch, dinner', + 'Unlimited drinks (alcoholic & non)', + 'Resort pools, beach, & amenities', + '4-5 star luxury resort', + 'Your choice of 4 destinations', + 'Flexible dates within 18 months', + '30-day money-back guarantee', + ].map((item) => ( +
+
+ +
+ {item} +
+ ))} +
+
+
+ + {/* ========== SIGNUP FORM ========== */} +
+
+
+
+ + Don't miss out +
+ +

+ Everyone Else Is Going. +
+ + Are You? + +

+ +

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + — only available for{' '} + +

+
+ +
+ +
+ + +
+
+ + + + {/* ========== FAQ ========== */} +
+
+

+ Got Questions? +

+ +
+
+ + {/* ========== FINAL CTA ========== */} +
+
+

+ Don't Be the Only One Missing Out +

+

+ 2,847 people have already claimed their Mexico vacation certificate. + The ${PAYMENT_CONFIG.monthlyPrice}/mo price disappears when the timer hits zero. +

+ +
+ +
+ + + +

+ 5 days / 4 nights all-inclusive. ${PAYMENT_CONFIG.monthlyPrice}/mo x{' '} + {PAYMENT_CONFIG.totalMonths} months or ${PAYMENT_CONFIG.oneTimePrice} one-time. + 30-day money-back guarantee. +

+
+
+ + + +
+ ) +} diff --git a/src/components/lp/pages/LP26PriceLock.tsx b/src/components/lp/pages/LP26PriceLock.tsx new file mode 100644 index 0000000..cca1e09 --- /dev/null +++ b/src/components/lp/pages/LP26PriceLock.tsx @@ -0,0 +1,500 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Lock, Shield, ShieldCheck, Clock, CheckCircle2, + ArrowRight, Zap, Users, Star, KeyRound, + LockKeyhole, BadgeCheck, Gift, TrendingUp, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const NAVY = '#1A237E' +const GOLD = '#FFD700' +const DARK_NAVY = '#0D1347' + +const steps = [ + { + icon: LockKeyhole, + title: 'Lock Your Price', + description: 'Secure today\'s $29/mo rate before the timer expires. Your price is frozen the moment you sign up.', + }, + { + icon: KeyRound, + title: 'Choose Your Dates', + description: 'Pick any available dates within 18 months. Cancun, Cabo, Riviera Maya, or Puerto Vallarta.', + }, + { + icon: Gift, + title: 'Enjoy Paradise', + description: '5 days and 4 nights all-inclusive. Meals, drinks, resort amenities — everything covered.', + }, +] + +const securityFeatures = [ + { icon: Shield, text: '256-bit SSL encryption on all transactions' }, + { icon: ShieldCheck, text: '30-day money-back guarantee — no questions asked' }, + { icon: BadgeCheck, text: 'BBB-accredited business with 4.8-star rating' }, + { icon: Lock, text: 'Price locked for 10 months — zero increases, ever' }, +] + +export default function LP26PriceLock() { + const [claimedPercent, setClaimedPercent] = useState(84) + const [viewerCount] = useState(() => Math.floor(Math.random() * 18) + 12) + + useEffect(() => { + const interval = setInterval(() => { + setClaimedPercent(prev => { + if (prev >= 96) return prev + return prev + (Math.random() > 0.6 ? 1 : 0) + }) + }, 8000) + return () => clearInterval(interval) + }, []) + + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + + {/* ===== HERO ===== */} +
+ {/* Vault pattern overlay */} +
+
+
+ +
+ {/* Viewers badge */} +
+ + + {viewerCount} people viewing this offer right now + +
+ + {/* Vault lock icon */} +
+
+ +
+
+ $29 +
+
+ +

+ PRICE LOCK{' '} + GUARANTEE +

+ +

+ $29/mo locked for the next{' '} + 30 minutes +

+ +

+ 5 days & 4 nights all-inclusive Mexico vacation.{' '} + $59/mo{' '} + $29/mo — save $200 +

+ + {/* Countdown block */} +
+

+ After the timer expires, price increases to $59/mo +

+ +
+ +
+ +
+ + +
+
+ + {/* ===== PROGRESS BAR — CERTIFICATES CLAIMED ===== */} +
+
+
+ + + Certificates Claimed at This Price + + + {claimedPercent}% claimed + +
+
+
+
+
+
+

+ Only {100 - claimedPercent}% of discounted certificates remain — don't lose yours +

+
+
+ + {/* ===== LOSS AVERSION SECTION ===== */} +
+
+

+ Don't Lose This Price +

+

+ Every minute that passes, someone else claims a certificate at this rate. + Once they're gone, the price goes back to{' '} + $59/mo. +

+ +
+ {/* What you lose */} +
+

+ + If You Wait... +

+
    + {[ + 'Price jumps to $59/mo ($200 more total)', + 'Your spot may be taken by someone else', + 'No guarantee this promotion will return', + 'You\'ll regret not acting when you had the chance', + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ + {/* What you keep */} +
+

+ + If You Lock In Now... +

+
    + {[ + '$29/mo locked — price NEVER increases', + '5 days/4 nights all-inclusive Mexico vacation', + 'Choose from 4 stunning destinations', + '30-day money-back guarantee if you change your mind', + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+ + +
+
+ + {/* ===== HOW IT WORKS — 3 STEPS ===== */} +
+
+

+ How It Works +

+

+ Three simple steps to your locked-in paradise vacation +

+ +
+ {steps.map((step, i) => ( +
+
+ {i + 1} +
+
+ +
+

+ {step.title} +

+

{step.description}

+
+ ))} +
+
+
+ + {/* ===== SECURITY FEATURES ===== */} +
+
+

+ Your Investment Is Protected +

+

+ We take your security as seriously as we take your vacation experience +

+ +
+ {securityFeatures.map((feat, i) => ( +
+
+ +
+

{feat.text}

+
+ ))} +
+
+
+ + {/* ===== TIKTOK CAROUSEL ===== */} + + + {/* ===== COUNTDOWN REMINDER ===== */} +
+
+

+ Your Price Lock Expires In +

+ +

+ After the timer runs out, the price increases to{' '} + $59/mo.{' '} + Don't lose your spot. +

+
+
+ + {/* ===== TESTIMONIALS ===== */} +
+
+

+ Travelers Who Locked In Their Price +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* ===== PRICING ANCHOR ===== */} +
+
+

+ The Numbers Don't Lie +

+

Similar vacations cost $3,000+. You pay a fraction.

+ +
+
+ $59/mo + regular price +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ {PAYMENT_CONFIG.totalMonths} months × ${PAYMENT_CONFIG.monthlyPrice} = ${PAYMENT_CONFIG.totalPrice} +

+

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time payment +

+ +
+ + You save $200 vs. regular price +
+ + +
+
+
+ + {/* ===== FORM ===== */} +
+
+
+ +

+ Lock In Your Price Now +

+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + — 5 days/4 nights all-inclusive +

+
+ + + +
+ +
+
+
+ + {/* ===== FAQ ===== */} + + +
+
+

+ Common Questions +

+ +
+
+ + {/* ===== FINAL CTA ===== */} +
+
+ +

+ Time Is Running Out — Lock In $29/mo Before It's Gone +

+ +
+
+
+ ) +} diff --git a/src/components/lp/pages/LP27BeforeAfter.tsx b/src/components/lp/pages/LP27BeforeAfter.tsx new file mode 100644 index 0000000..4e94776 --- /dev/null +++ b/src/components/lp/pages/LP27BeforeAfter.tsx @@ -0,0 +1,443 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Clock, Coffee, Monitor, Car, Frown, CloudRain, + Sun, Waves, UtensilsCrossed, Palmtree, Smile, Music, + ArrowRight, ArrowDown, CheckCircle2, Star, Heart, + Sparkles, Camera, MapPin, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const GRAY = '#6B7280' +const TEAL = '#0D9488' +const DARK_TEAL = '#065F53' +const LIGHT_TEAL = '#F0FDFA' + +const beforeStats = [ + { icon: Clock, stat: '8.5 hrs', label: 'Average workday' }, + { icon: Car, stat: '52 min', label: 'Daily commute' }, + { icon: Monitor, stat: '11 hrs', label: 'Screen time' }, + { icon: Coffee, stat: '3.5 cups', label: 'Coffee to survive' }, +] + +const afterStats = [ + { icon: Sun, stat: '0', label: 'Alarms set' }, + { icon: Waves, stat: '∞', label: 'Beach hours' }, + { icon: UtensilsCrossed, stat: 'All', label: 'Meals included' }, + { icon: Smile, stat: '100%', label: 'Relaxation' }, +] + +const beforeItems = [ + { icon: Frown, text: 'Staring at the same four walls every day' }, + { icon: CloudRain, text: 'Weather that matches your mood — gray' }, + { icon: Monitor, text: 'Endless emails, meetings, and deadlines' }, + { icon: Car, text: 'Sitting in traffic, wasting your life' }, + { icon: Coffee, text: 'Running on caffeine and fumes' }, + { icon: Clock, text: 'Counting down to Friday... again' }, +] + +const afterItems = [ + { icon: Palmtree, text: 'Waking up to ocean views and warm breeze' }, + { icon: Sun, text: 'Sun on your skin, sand between your toes' }, + { icon: UtensilsCrossed, text: 'World-class cuisine — every meal, every day' }, + { icon: Music, text: 'Live music, cocktails, and sunset magic' }, + { icon: Heart, text: 'Quality time with the person who matters most' }, + { icon: Camera, text: 'Photos that make everyone jealous' }, +] + +const transformations = [ + { + before: 'Fluorescent office lights', + after: 'Golden hour sunsets over the Pacific', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + }, + { + before: 'Sad desk lunch', + after: 'Oceanfront dining with unlimited cocktails', + image: '/images/cdn/photo-1544551763-46a013bb70d5.jpg', + }, + { + before: 'Scrolling through vacation photos', + after: 'Being IN the vacation photos', + image: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + }, +] + +export default function LP27BeforeAfter() { + const [activeTransform, setActiveTransform] = useState(0) + + useEffect(() => { + const interval = setInterval(() => { + setActiveTransform(prev => (prev + 1) % transformations.length) + }, 5000) + return () => clearInterval(interval) + }, []) + + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + + {/* ===== HERO — SPLIT SCREEN ===== */} +
+
+ {/* BEFORE — Gray, desaturated */} +
+
+
+

+ Your Monday +

+

+ Same Desk.{' '} + Same Grind. +

+

+ Another day, another dollar. Another year, no vacation. + You're reading this because you know you deserve better. +

+ +
+
+ + {/* AFTER — Vibrant, full color */} +
+
+
+
+

+ Your Vacation +

+

+ Ocean Views.{' '} + Pure Bliss. +

+

+ 5 days and 4 nights of all-inclusive paradise. Meals, drinks, sunshine — all yours. +

+ +
+
+
+ + {/* Center divider with CTA */} +
+

+ You deserve more than a{' '} + screensaver of a beach +

+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + for the real thing +

+ +
+
+ + {/* ===== BEFORE STATS VS AFTER STATS ===== */} +
+
+

+ Your Life By the Numbers +

+ +
+ {/* Before stats */} +
+

+ Your Average Week +

+
+ {beforeStats.map((s, i) => ( +
+ +

{s.stat}

+

{s.label}

+
+ ))} +
+
+ + {/* After stats */} +
+

+ Your Vacation Week +

+
+ {afterStats.map((s, i) => ( +
+ +

{s.stat}

+

{s.label}

+
+ ))} +
+
+
+
+
+ + {/* ===== BEFORE / AFTER LIST COMPARISON ===== */} +
+
+
+ {/* Before column */} +
+

+ + Right Now +

+
+ {beforeItems.map((item, i) => ( +
+ + {item.text} +
+ ))} +
+
+ + {/* After column */} +
+

+ + On Vacation +

+
+ {afterItems.map((item, i) => ( +
+ + {item.text} +
+ ))} +
+
+
+ +
+ +
+
+
+ + {/* ===== COUNTDOWN DIVIDER ===== */} +
+
+

+ This pricing disappears in +

+ +

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo — only while the timer lasts +

+
+
+ + {/* ===== TRANSFORMATION SHOWCASE ===== */} +
+
+

+ The Transformation Is{' '} + Real +

+ +
+ {transformations.map((t, i) => ( +
+
+

Before

+

{t.before}

+ +
+
+ {t.after} +
+
+

After

+

{t.after}

+
+
+
+
+ ))} +
+
+
+ + {/* ===== TESTIMONIALS ===== */} +
+
+

+ They Made the Switch +

+

+ Real people who stopped scrolling and started living +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* ===== DESTINATIONS ===== */} +
+
+

+ Choose Your "After" +

+

+ 4 stunning destinations — all included in your certificate +

+ +
+
+ + {/* ===== FORM ===== */} +
+
+
+ +

+ Stop Dreaming. Start Living. +

+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + — 5 days/4 nights all-inclusive +

+
+ + + +
+ +
+
+
+ + {/* ===== FAQ ===== */} + + +
+
+

+ Questions? We've Got Answers +

+ +
+
+ + {/* ===== FINAL CTA ===== */} +
+
+ +

+ Your "Before" Story Ends Today +

+

+ Don't lose this price. Don't lose this moment. +

+ +
+
+
+ ) +} diff --git a/src/components/lp/pages/LP28RiskFree.tsx b/src/components/lp/pages/LP28RiskFree.tsx new file mode 100644 index 0000000..803e21a --- /dev/null +++ b/src/components/lp/pages/LP28RiskFree.tsx @@ -0,0 +1,438 @@ +'use client' + +import { useState } from 'react' +import { + Shield, ShieldCheck, ShieldQuestion, CheckCircle2, + ArrowRight, Calendar, CreditCard, HelpCircle, + ThumbsUp, MessageCircle, Star, BadgeCheck, + RefreshCw, Lock, Heart, Users, + ChevronDown, ChevronUp, Clock, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const GREEN = '#059669' +const DARK_GREEN = '#047857' +const LIGHT_GREEN = '#ECFDF5' +const LIGHTER_GREEN = '#F0FDF4' + +const objections = [ + { + icon: HelpCircle, + question: 'What if I don\'t like it?', + answer: 'Full refund within 30 days — no questions asked. If the vacation doesn\'t meet your expectations, we give you every penny back. We\'ve been doing this for years, and our refund rate is less than 2%.', + badge: '30-Day Money-Back', + }, + { + icon: Calendar, + question: 'What if the dates don\'t work for me?', + answer: 'You have 18 months of flexible scheduling. Choose any available dates that work for your calendar. Need to reschedule? No problem — we make it easy.', + badge: 'Flexible Scheduling', + }, + { + icon: ShieldQuestion, + question: 'Is this actually legit?', + answer: 'We\'re a verified, BBB-accredited business with thousands of happy travelers. Real reviews, real people, real vacations. We\'ve been featured on TikTok with millions of views.', + badge: 'BBB Accredited', + }, + { + icon: CreditCard, + question: 'Is my payment information safe?', + answer: '256-bit SSL encryption protects every transaction. We use the same security technology as major banks. Your card details are never stored on our servers.', + badge: 'Bank-Level Security', + }, + { + icon: RefreshCw, + question: 'What if I need to cancel?', + answer: 'Cancel anytime within 30 days for a full refund. After 30 days, your certificate is still transferable — give it to a friend or family member.', + badge: 'Easy Cancellation', + }, + { + icon: Users, + question: 'Can I really bring a guest for free?', + answer: 'Yes! Your certificate covers a family of four — 2 adults and 2 kids under 12. The all-inclusive package — meals, drinks, resort amenities — applies to everyone. Additional guests can be added at a discounted rate.', + badge: 'Bring a Guest Free', + }, +] + +const skepticTestimonials = [ + { + quote: "I was SO skeptical — I almost didn't sign up. Best decision I ever made. The resort was incredible and everything was exactly as described.", + name: "Jennifer & Tom", + location: "New York, NY", + photo: "/images/cdn/photo-1494790108377-be9c29b29330.jpg", + }, + { + quote: "My husband thought it was a scam. We booked anyway. Now he's the one telling everyone about it. Cabo was AMAZING.", + name: "Maria G.", + location: "Houston, TX", + photo: "/images/cdn/photo-1438761681033-6461ffad8d80.jpg", + }, + { + quote: "I did so much research before signing up. Read every review. Turns out it's 100% real — and the vacation exceeded every expectation.", + name: "David & Lisa", + location: "Denver, CO", + photo: "/images/cdn/photo-1472099645785-5658abf4ff4e.jpg", + }, +] + +const trustPoints = [ + { value: '12,000+', label: 'Happy Travelers' }, + { value: '4.8/5', label: 'Average Rating' }, + { value: '<2%', label: 'Refund Rate' }, + { value: '30 Days', label: 'Money-Back Guarantee' }, +] + +export default function LP28RiskFree() { + const [expandedObjection, setExpandedObjection] = useState(0) + + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + + {/* ===== HERO ===== */} +
+
+ {/* Giant shield */} +
+
+ +
+
+ +
+
+ +

+ 100% RISK-FREE +

+

+ 30-Day Money-Back Guarantee on your 5-day/4-night all-inclusive Mexico vacation +

+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + — if you don't love it, you don't pay +

+ + + + {/* Trust stats row */} +
+ {trustPoints.map((tp, i) => ( +
+

{tp.value}

+

{tp.label}

+
+ ))} +
+
+
+ + {/* ===== RISK-FREE COUNTDOWN ===== */} +
+
+

+ Risk-free offer available for +

+ +

+ After the timer, the price increases to $59/mo +

+
+
+ + {/* ===== OBJECTION HANDLING ===== */} +
+
+

+ Every Worry, Addressed +

+

+ We know you have questions. Here are honest answers to every one. +

+ +
+ {objections.map((obj, i) => ( +
+ + + {expandedObjection === i && ( +
+
+

{obj.answer}

+
+ + + Your concern is fully covered + +
+
+
+ )} +
+ ))} +
+ +
+ +
+
+
+ + {/* ===== GUARANTEE BANNER ===== */} +
+
+
+
+
+
+ +
+
+
+

+ Our Iron-Clad 30-Day Guarantee +

+

+ Book your vacation certificate today. If for ANY reason you're not completely satisfied within 30 days, + contact us and we'll refund every penny. No hoops. No hassle. No hard feelings. + We can offer this because{' '} + 98% of our travelers are thrilled with their experience. +

+
+
+
+
+
+ + {/* ===== COMPARISON TABLE ===== */} +
+
+

+ See How We Compare +

+

+ Same vacation, fraction of the price — plus protections others don't offer +

+ +
+
+ + {/* ===== SKEPTIC TESTIMONIALS ===== */} +
+
+

+ "I Almost Didn't Sign Up..." +

+

+ Hear from travelers who were skeptical — and pleasantly surprised +

+ +
+ {skepticTestimonials.map((t, i) => ( +
+
+ + Former Skeptic + +
+ +
+ ))} +
+
+
+ + {/* ===== WHAT'S INCLUDED ===== */} +
+
+

+ Everything Included, Zero Risk +

+ +
+ {[ + '5 days / 4 nights accommodation', + 'All meals — breakfast, lunch, dinner', + 'Unlimited drinks (alcoholic & non)', + 'Resort pools, beaches, amenities', + 'Choose from 4 Mexico destinations', + '18 months of flexible scheduling', + 'Bring a guest at no extra charge', + '30-day full money-back guarantee', + ].map((item, i) => ( +
+ + {item} +
+ ))} +
+ +
+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+
+
+ + {/* ===== FORM ===== */} +
+
+
+ +

+ Try It Completely Risk-Free +

+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + — 30-day money-back guarantee +

+
+ + + +
+

+ + 256-bit SSL encryption • 30-day guarantee • Cancel anytime +

+
+ +
+ +
+
+
+ + + + {/* ===== FAQ ===== */} +
+
+

+ Still Have Questions? +

+ +
+
+ + {/* ===== FINAL CTA ===== */} +
+
+ +

+ Zero Risk. Full Refund If You're Not Amazed. +

+

+ Don't lose this price — the guarantee won't last forever +

+ +
+
+
+ ) +} diff --git a/src/components/lp/pages/LP29SpeedDeal.tsx b/src/components/lp/pages/LP29SpeedDeal.tsx new file mode 100644 index 0000000..92b42a0 --- /dev/null +++ b/src/components/lp/pages/LP29SpeedDeal.tsx @@ -0,0 +1,408 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Zap, ArrowRight, Clock, Shield, Lock, + CheckCircle2, Star, Users, TrendingUp, + AlertTriangle, ChevronRight, Flame, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +const YELLOW = '#FFC107' +const RED = '#FF1744' +const BLACK = '#000000' +const DARK_GRAY = '#111111' + +export default function LP29SpeedDeal() { + const [claimedCount, setClaimedCount] = useState(47) + const [showPulse, setShowPulse] = useState(true) + const [recentBuyer, setRecentBuyer] = useState('') + const [showBuyer, setShowBuyer] = useState(false) + + const recentBuyers = [ + 'Maria from TX', 'James from FL', 'Sarah from CA', + 'David from NY', 'Jennifer from IL', 'Robert from AZ', + 'Lisa from CO', 'Michael from GA', 'Rachel from PA', + ] + + useEffect(() => { + // Increment claimed count + const claimInterval = setInterval(() => { + setClaimedCount(prev => { + if (prev >= 63) return prev + return prev + (Math.random() > 0.5 ? 1 : 0) + }) + }, 12000) + + // Show recent buyers + let buyerIdx = 0 + const buyerInterval = setInterval(() => { + setRecentBuyer(recentBuyers[buyerIdx % recentBuyers.length]) + setShowBuyer(true) + buyerIdx++ + setTimeout(() => setShowBuyer(false), 3500) + }, 7000) + + // Pulse animation + const pulseInterval = setInterval(() => { + setShowPulse(false) + setTimeout(() => setShowPulse(true), 200) + }, 3000) + + return () => { + clearInterval(claimInterval) + clearInterval(buyerInterval) + clearInterval(pulseInterval) + } + }, []) + + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + + {/* ===== RECENT BUYER NOTIFICATION ===== */} +
+
+
+ +
+
+

{recentBuyer}

+

just grabbed this deal

+
+
+
+ + {/* ===== HERO ===== */} +
+ {/* Lightning bolt background pattern */} +
+ {[...Array(12)].map((_, i) => ( + + ))} +
+ +
+ {/* Flash deal badge */} +
+ + FLASH DEAL — LIMITED TIME + +
+ + {/* Lightning icon */} +
+ +
+ +

+ FLASH DEAL +

+ +

+ This deal self-destructs in +

+ + {/* Giant countdown */} +
+ +
+ + {/* Price display */} +
+
+ + $59/mo + + + -34% + +
+
+ + ${PAYMENT_CONFIG.monthlyPrice} + + /mo +
+

+ {PAYMENT_CONFIG.totalMonths} months = ${PAYMENT_CONFIG.totalPrice} total{' '} + |{' '} + or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+ + {/* CTA Button */} + + + {/* Claimed counter */} +
+ + + {claimedCount} people grabbed this deal in the last hour + +
+
+
+ + {/* ===== WHAT YOU GET — MINIMAL ===== */} +
+
+
+ {[ + '5 days / 4 nights all-inclusive', + 'Cancun, Cabo, Riviera Maya, or PV', + 'All meals + unlimited drinks', + 'Resort pools, beaches, amenities', + 'Bring a guest — included free', + '18 months to choose your dates', + ].map((item, i) => ( +
+ + {item} +
+ ))} +
+
+
+ + {/* ===== URGENCY STRIP ===== */} +
+
+ +

+ When the timer hits zero, this deal is gone forever +

+ +
+
+ + {/* ===== SECOND COUNTDOWN + PRICE ===== */} +
+
+

+ Time Is Running Out +

+ + +
+
+

34%

+

Savings

+
+
+

$200

+

You Save

+
+
+

+ {100 - claimedCount} +

+

Deals Left

+
+
+ + +
+
+ + {/* ===== TRUST — MINIMAL ===== */} +
+
+
+ {[ + { icon: Shield, label: '30-Day Guarantee' }, + { icon: Lock, label: 'SSL Encrypted' }, + { icon: Star, label: '4.8/5 Rating' }, + { icon: Users, label: '12,000+ Travelers' }, + ].map((item, i) => ( +
+ +

{item.label}

+
+ ))} +
+
+
+ + {/* ===== FORM ===== */} +
+ {/* Animated border */} +
+ + + +
+
+ +

+ GRAB THIS DEAL +

+
+ $59/mo + + + ${PAYMENT_CONFIG.monthlyPrice}/mo + +
+

+ 5 days/4 nights all-inclusive Mexico vacation +

+
+ + + +
+

+ + Secure checkout • 30-day money-back guarantee +

+
+
+
+ + + + {/* ===== FINAL COUNTDOWN ===== */} +
+
+

+ Last Chance — Deal Expires When Timer Hits Zero +

+ + +
+
+
+ ) +} diff --git a/src/components/lp/pages/LP30Influencer.tsx b/src/components/lp/pages/LP30Influencer.tsx new file mode 100644 index 0000000..beb0c3e --- /dev/null +++ b/src/components/lp/pages/LP30Influencer.tsx @@ -0,0 +1,538 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Heart, MessageCircle, Share2, Bookmark, Play, + Eye, Users, TrendingUp, Star, ArrowRight, + CheckCircle2, Shield, Lock, Sparkles, + Music, Flame, Send, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const TIKTOK_PINK = '#FF0050' +const TIKTOK_CYAN = '#00F2EA' +const DARK_BG = '#121212' +const DARKER_BG = '#0A0A0A' + +const engagementMetrics = [ + { icon: Eye, value: '2.4M+', label: 'Views' }, + { icon: Heart, value: '340K+', label: 'Likes' }, + { icon: Share2, value: '89K+', label: 'Shares' }, + { icon: Bookmark, value: '156K+', label: 'Saves' }, +] + +const creatorCards = [ + { + handle: '@jessicatravels', + followers: '890K', + avatar: '/images/cdn/photo-1494790108377-be9c29b29330.jpg', + caption: 'OMG this Mexico deal is INSANE. $29/mo for all-inclusive?! I had to check for myself...', + likes: '45.2K', + comments: '3.1K', + shares: '12.8K', + }, + { + handle: '@couplesgetaway', + followers: '1.2M', + avatar: '/images/cdn/photo-1472099645785-5658abf4ff4e.jpg', + caption: 'We booked the $29/mo Mexico vacation everyone on TikTok is talking about. Here is what happened...', + likes: '78.9K', + comments: '5.6K', + shares: '21.3K', + }, + { + handle: '@budgetqueen', + followers: '2.1M', + avatar: '/images/cdn/photo-1438761681033-6461ffad8d80.jpg', + caption: 'STOP SCROLLING. This is not a drill. All-inclusive Mexico for $29/mo. I am literally shaking.', + likes: '124K', + comments: '8.9K', + shares: '34.7K', + }, +] + +const viralComments = [ + { user: '@sunshinevibes', text: 'Just booked!! Cannot wait omg', likes: 342 }, + { user: '@wanderlust.maya', text: 'Is this for REAL?! $29/mo??', likes: 891 }, + { user: '@travelwithmark', text: 'Did this last month. It is 100% legit. Cabo was incredible', likes: 1204 }, + { user: '@beachbum_sarah', text: 'My friend went and said it was the best vacation ever', likes: 567 }, + { user: '@deals.daily', text: 'This is the best travel deal on TikTok rn no cap', likes: 2341 }, + { user: '@vacay.mode', text: 'Just sent this to everyone I know lol', likes: 445 }, +] + +export default function LP30Influencer() { + const [activeComment, setActiveComment] = useState(0) + const [likeCount, setLikeCount] = useState(340892) + + useEffect(() => { + // Rotate comments + const commentInterval = setInterval(() => { + setActiveComment(prev => (prev + 1) % viralComments.length) + }, 3000) + + // Increment like count + const likeInterval = setInterval(() => { + setLikeCount(prev => prev + Math.floor(Math.random() * 5) + 1) + }, 2000) + + return () => { + clearInterval(commentInterval) + clearInterval(likeInterval) + } + }, []) + + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + const TikTokLogo = ({ className = 'w-6 h-6' }: { className?: string }) => ( + + + + ) + + return ( +
+ + + + {/* ===== HERO ===== */} +
+ {/* Gradient orbs */} +
+
+ +
+ {/* TikTok badge */} +
+ + AS SEEN ON TIKTOK + +
+ + {/* TikTok icon */} +
+
+ +
+
+ LIVE +
+
+ +

+ The Vacation{' '} + + Everyone's Talking About + +

+ +

+ 2.4 million views. 340K likes. And counting. +

+

+ $59/mo{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/mo + {' '} + — 5 days/4 nights all-inclusive Mexico +

+ + +
+
+ + {/* ===== ENGAGEMENT METRICS ===== */} +
+
+ {engagementMetrics.map((m, i) => ( +
+ +

{m.value}

+

{m.label}

+
+ ))} +
+
+ + {/* ===== SOCIAL PROOF TICKER ===== */} + + + {/* ===== TIKTOK CAROUSEL — MAIN CONTENT ===== */} +
+ +
+ + {/* ===== VIRAL PRICING COUNTDOWN ===== */} +
+
+

+ Viral pricing available for +

+
+ +
+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo{' '} + — only while the timer lasts +

+
+
+ + {/* ===== CREATOR PROFILE CARDS ===== */} +
+
+

+ Creators Are{' '} + + Obsessed + +

+

+ Here's what influencers are posting about Mexico Paradise +

+ +
+ {creatorCards.map((creator, i) => ( +
+ {/* Creator header */} +
+ {creator.handle} +
+

{creator.handle}

+

{creator.followers} followers

+
+ +
+ + {/* Video thumbnail placeholder */} +
+ Vacation +
+
+ +
+
+ {/* Caption overlay */} +
+

{creator.caption}

+
+
+ + {/* Engagement bar */} +
+ + + {creator.likes} + + + + {creator.comments} + + + + {creator.shares} + + + + Save + +
+
+ ))} +
+
+
+ + {/* ===== VIRAL COMMENTS ===== */} +
+
+

+ + Comments Going Wild +

+ +
+ {viralComments.map((comment, i) => ( +
+
+ {comment.user.charAt(1).toUpperCase()} +
+
+

{comment.user}

+

{comment.text}

+
+
+ + {comment.likes.toLocaleString()} +
+
+ ))} +
+
+
+ + {/* ===== WHAT'S INCLUDED ===== */} +
+
+

+ What You Get for{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/mo + +

+ +
+ {[ + '5 days / 4 nights all-inclusive', + 'Cancun, Cabo, Riviera Maya, or PV', + 'All meals + unlimited drinks', + 'Resort pools, beaches, amenities', + 'Bring a guest — included free', + '18 months to choose your dates', + '30-day money-back guarantee', + 'Flexible rescheduling', + ].map((item, i) => ( +
+ + {item} +
+ ))} +
+
+
+ + {/* ===== TESTIMONIALS ===== */} +
+
+

+ Real Travelers. Real Stories. +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* ===== FORM ===== */} +
+ {/* Gradient orbs */} +
+
+ +
+
+
+ + VIRAL PRICING — LIMITED TIME +
+

+ Book the Vacation Everyone's Talking About +

+

+ $59/mo{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/mo + {' '} + — 5 days/4 nights all-inclusive +

+
+ + + +
+

+ + Secure checkout • 30-day money-back guarantee +

+
+ +
+ +
+
+
+ + {/* ===== FAQ ===== */} +
+
+

+ FAQ +

+ +
+
+ + {/* ===== FINAL CTA ===== */} +
+
+
+ +

+ Don't Just Watch the Videos — Live the Experience +

+

+ Viral pricing expires when the timer hits zero. Don't lose this. +

+ +
+
+
+ ) +} diff --git a/src/components/lp/pages/LP31BucketList.tsx b/src/components/lp/pages/LP31BucketList.tsx new file mode 100644 index 0000000..2ceca62 --- /dev/null +++ b/src/components/lp/pages/LP31BucketList.tsx @@ -0,0 +1,470 @@ +'use client' + +import { useState } from 'react' +import { + Check, MapPin, Palmtree, Sun, Waves, Camera, + GlassWater, Compass, Star, Clock, ArrowRight, + Sparkles, Heart, Shield, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const ORANGE = '#F97316' +const DARK = '#1C1917' +const WARM_WHITE = '#FFFBEB' + +const bucketListItems = [ + { icon: Sun, text: 'Watch a Mexican sunset from your private balcony', checked: false }, + { icon: Waves, text: 'Swim in an ancient cenote surrounded by jungle', checked: false }, + { icon: GlassWater, text: 'Sip unlimited all-inclusive cocktails on the beach', checked: false }, + { icon: Camera, text: 'Take photos at ancient Mayan ruins', checked: false }, + { icon: Compass, text: 'Explore hidden beaches only locals know about', checked: false }, + { icon: Palmtree, text: 'Fall asleep to the sound of ocean waves', checked: false }, + { icon: Heart, text: 'Create memories that last a lifetime', checked: false }, + { icon: Star, text: 'Stay at a 5-star all-inclusive resort', checked: false }, +] + +const dailyCosts = [ + { label: 'Morning coffee', cost: '$5.50' }, + { label: 'Lunch out', cost: '$14.00' }, + { label: 'Streaming service', cost: '$1.80' }, + { label: 'This vacation', cost: '$1.30', highlight: true }, +] + +export default function LP31BucketList() { + const [checkedItems, setCheckedItems] = useState( + new Array(bucketListItems.length).fill(false) + ) + + const toggleItem = (index: number) => { + setCheckedItems(prev => { + const next = [...prev] + next[index] = !next[index] + return next + }) + } + + const checkedCount = checkedItems.filter(Boolean).length + + return ( +
+ + + {/* Hero */} +
+
+ Dramatic Mexican landscape with ancient ruins +
+
+ +
+
+ + YOUR ADVENTURE AWAITS +
+ +

+ Life's Too Short +
+ for “Someday” +

+ +

+ Stop scrolling through travel photos wishing you were there. + It's time to check off your Mexico bucket list. +

+ +

+ 5 days, 4 nights, all-inclusive — starting at just{' '} + $59/mo{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/mo + +

+ + + Start Checking Off Your List + +
+ +
+ + + +
+
+ + + + {/* Interactive Bucket List */} +
+
+
+

+ Your Mexico Bucket List +

+

+ Tap to check off your dream experiences — then make them all happen +

+
+ +
+ {bucketListItems.map((item, index) => ( + + ))} +
+ + {/* Progress Indicator */} +
+
+ + {checkedCount}/{bucketListItems.length} checked + +
+
+
+
+
+ +
+

+ Check them ALL off for{' '} + $1.30/day +

+

+ That's less than a cup of coffee. Every. Single. Day. +

+

+ $59/mo →{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/mo x {PAYMENT_CONFIG.totalMonths} months + {' '} + or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+
+
+ + {/* Destination Carousel */} +
+
+
+

+ Choose Your Adventure +

+

+ Four stunning destinations, one unbeatable price +

+
+ +
+
+ + {/* Daily Cost Comparison */} +
+
+

+ What Does $1.30/Day Look Like? +

+ +
+ {dailyCosts.map((item) => ( +
+

+ {item.cost} +

+

+ {item.label} +

+ {item.highlight && ( +

PER DAY

+ )} +
+ ))} +
+ +

+ If you can afford a coffee, you can afford paradise. The question isn't + “can I?” — it's “why haven't I yet?” +

+
+
+ + {/* Countdown + Ebook Capture */} +
+
+ + +

+ Get the Complete Bucket List Guide +

+

+ Free e-book: “Budget Luxury Travel” — insider tips, packing lists, + and the best-kept secrets of Mexico +

+

+ Free guide + special pricing available for: +

+ +
+ +
+ +
+ +
+ +

+ We never share your email. Unsubscribe anytime. +

+
+
+ + {/* Pay Now */} +
+
+
+

+ Ready to Check Off Your List? +

+

+ Don't let “someday” turn into “never” +

+
+ +
+
+

+ $59/mo +

+ + ${PAYMENT_CONFIG.monthlyPrice} + + + /mo x {PAYMENT_CONFIG.totalMonths} months + +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day money-back guarantee +

+
+ + +
+ + +
+
+ + {/* Testimonials */} +
+
+

+ They Checked Off Their List +

+
+ {TESTIMONIALS.slice(0, 3).map((t) => ( + + ))} +
+
+
+ + {/* FAQ */} + + +
+
+

+ Questions? We've Got Answers +

+ +
+
+ + {/* Final CTA */} +
+ +

+ “Someday” Is Today +

+

+ This price disappears when the timer hits zero. Don't miss it. +

+
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP32DealBreaker.tsx b/src/components/lp/pages/LP32DealBreaker.tsx new file mode 100644 index 0000000..e7b432a --- /dev/null +++ b/src/components/lp/pages/LP32DealBreaker.tsx @@ -0,0 +1,507 @@ +'use client' + +import { useState } from 'react' +import { + Check, X, TrendingDown, Shield, Zap, Award, + ArrowRight, Calculator, BadgeDollarSign, Star, + ThumbsUp, ChevronDown, AlertTriangle, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const BLUE = '#1E40AF' +const RED = '#DC2626' +const GREEN = '#16A34A' +const LIGHT_BLUE = '#EFF6FF' +const LIGHT_GREEN = '#F0FDF4' + +interface CompetitorPrice { + name: string + price: number + allInclusive: boolean + paymentPlan: boolean + color: string +} + +const competitors: CompetitorPrice[] = [ + { name: 'Expedia', price: 2800, allInclusive: false, paymentPlan: false, color: '#F59E0B' }, + { name: 'Hotels.com', price: 2600, allInclusive: false, paymentPlan: false, color: RED }, + { name: 'Booking.com', price: 3100, allInclusive: false, paymentPlan: false, color: '#2563EB' }, + { name: 'Direct Booking', price: 3200, allInclusive: false, paymentPlan: false, color: '#7C3AED' }, +] + +const savingsFeatures = [ + { label: '5 Days / 4 Nights', us: true, them: true }, + { label: 'All Meals Included', us: true, them: false }, + { label: 'Unlimited Drinks', us: true, them: false }, + { label: 'Resort Amenities', us: true, them: true }, + { label: 'Payment Plan ($29/mo)', us: true, them: false }, + { label: '30-Day Money Back', us: true, them: false }, + { label: 'Flexible Dates (18 mo)', us: true, them: false }, + { label: 'Price Guarantee', us: true, them: false }, +] + +export default function LP32DealBreaker() { + const [showCalculator, setShowCalculator] = useState(false) + const averageCompetitorPrice = Math.round( + competitors.reduce((sum, c) => sum + c.price, 0) / competitors.length + ) + const savings = averageCompetitorPrice - PAYMENT_CONFIG.oneTimePrice + + return ( +
+ + + {/* Hero */} +
+
+
+
+ +
+
+ + LOWEST PRICE GUARANTEE +
+ +

+ We Dare You to Find +
+ a Better Deal +

+ +

+ We compared our price to every major booking platform. + The result? It's not even close. +

+ +
+
+

Average OTA Price

+

+ ${averageCompetitorPrice.toLocaleString()} +

+
+ +
+

Our Price

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+
+
+ + + See the Full Comparison + +
+
+ + + + {/* Price Comparison Bars */} +
+
+
+

+ Side-by-Side Price Comparison +

+

+ Same destination. Same dates. Same quality. Wildly different prices. +

+
+ + {/* Visual Bar Chart */} +
+ {competitors.map((comp) => { + const percentage = (comp.price / 3500) * 100 + return ( +
+
+ + {comp.name} + +
+
+
+ + ${comp.price.toLocaleString()} + +
+
+ +
+ ) + })} + + {/* Us */} +
+
+ + Mexico Paradise + +
+
+
+ + ${PAYMENT_CONFIG.oneTimePrice} + +
+
+ +
+
+ + {/* Savings callout */} +
+ +

+ You Save ${savings.toLocaleString()} on Average +

+

+ That's a + {Math.round((savings / averageCompetitorPrice) * 100)}% discount + {' '} + compared to major booking platforms. Same resort. Same dates. Fraction of the price. +

+
+
+
+ + {/* Feature Comparison */} +
+
+
+

+ It's Not Just Cheaper — It's Better +

+

+ More features, better value, lower price. That's the trifecta. +

+
+ + + +
+
+

+ What's included that others charge extra for: +

+
+ {savingsFeatures.map((f) => ( +
+
+ {f.us ? ( + + ) : ( + + )} +
+ + {f.label} + +
+ ))} +
+
+
+
+
+ + {/* Savings Calculator */} +
+
+
+ +

+ Let's Break Down the Value +

+
+ +
+
+
+

+ What you'd pay separately: +

+
+ {[ + { item: '4 nights at resort', cost: '$1,200' }, + { item: 'All meals (5 days)', cost: '$750' }, + { item: 'Unlimited drinks', cost: '$400' }, + { item: 'Resort amenities', cost: '$350' }, + { item: 'Airport transfers', cost: '$150' }, + { item: 'Activities & entertainment', cost: '$200' }, + ].map((row) => ( +
+ {row.item} + + {row.cost} + +
+ ))} +
+ Total Value + $3,050 +
+
+
+ +
+

+ You pay +

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+

+ or just ${PAYMENT_CONFIG.monthlyPrice}/mo x {PAYMENT_CONFIG.totalMonths} +

+
+ + + Save $2,651 (87% off) + +
+
+
+
+
+
+ + {/* Price Match Guarantee */} +
+
+ +

+ Show Us a Better Price — We'll Match It +

+

+ We're so confident this is the best deal you'll find that we guarantee it. + Find a comparable all-inclusive package for less, and we'll match their price. +

+
+ + Best Price Guarantee + + + 30-Day Refund Policy + +
+
+
+ + {/* Ebook Capture */} +
+
+ +

+ Get the Full Price Comparison Report +

+

+ Free e-book: “Budget Luxury Travel” with detailed price breakdowns + across all major booking platforms +

+

+ Special pricing expires in: +

+ +
+ +
+ +
+ +
+
+
+ + {/* Pay Now */} +
+
+
+

+ Lock In the Best Price +

+

+ You won't find this deal anywhere else. We guarantee it. +

+
+ +
+
+
+ BEST VALUE +
+

+ $59/mo +

+ + ${PAYMENT_CONFIG.monthlyPrice} + + + /mo x {PAYMENT_CONFIG.totalMonths} months + +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day money-back guarantee +

+
+ + +
+ + +
+
+ + {/* Testimonials */} +
+
+

+ Smart Travelers Who Found the Best Deal +

+
+ {TESTIMONIALS.slice(0, 3).map((t) => ( + + ))} +
+
+
+ + + + {/* FAQ */} +
+
+

+ Frequently Asked Questions +

+ +
+
+ + {/* Final CTA */} +
+ +

+ The Numbers Don't Lie +

+

+ ${PAYMENT_CONFIG.oneTimePrice} for what others charge $3,000+. + This promotional price ends when the timer hits zero. +

+
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP33EscapePlan.tsx b/src/components/lp/pages/LP33EscapePlan.tsx new file mode 100644 index 0000000..2881a9c --- /dev/null +++ b/src/components/lp/pages/LP33EscapePlan.tsx @@ -0,0 +1,518 @@ +'use client' + +import { useState } from 'react' +import { + Shield, Target, MapPin, Clock, CheckCircle2, + ChevronRight, Lock, Crosshair, Radio, + Plane, Palmtree, Sun, Waves, Eye, Zap, + ArrowRight, AlertTriangle, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, DESTINATIONS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const DARK = '#111827' +const GREEN = '#22C55E' +const DARK_GREEN = '#15803D' +const TERMINAL_BG = '#0A0F1A' + +const missionSteps = [ + { + phase: 'PHASE 01', + title: 'INTELLIGENCE GATHERING', + description: 'Download the free classified travel dossier. Contains insider intel on luxury Mexico vacations at deep-cover prices.', + icon: Eye, + status: 'READY', + }, + { + phase: 'PHASE 02', + title: 'SELECT YOUR DESTINATION', + description: 'Choose from 4 confirmed safe houses: Cancun, Cabo, Riviera Maya, or Puerto Vallarta. All 5-star. All all-inclusive.', + icon: MapPin, + status: 'PENDING', + }, + { + phase: 'PHASE 03', + title: 'SECURE YOUR CERTIFICATE', + description: 'Lock in the $29/mo payment plan or deploy $249 in a single strike. 30-day extraction guarantee if the mission doesn\'t meet expectations.', + icon: Lock, + status: 'PENDING', + }, + { + phase: 'PHASE 04', + title: 'EXECUTE THE ESCAPE', + description: 'Book your dates within 18 months. Pack your bags. Leave the office behind. Mission complete.', + icon: Plane, + status: 'PENDING', + }, +] + +const missionBriefing = [ + { label: 'MISSION', value: 'Get out of your office and onto a beach' }, + { label: 'OBJECTIVE', value: 'All-inclusive paradise for $1.30/day' }, + { label: 'DURATION', value: '5 days / 4 nights' }, + { label: 'COVER', value: 'Luxury resort guest' }, + { label: 'CLEARANCE', value: 'All-inclusive (meals, drinks, amenities)' }, + { label: 'BUDGET', value: `$${PAYMENT_CONFIG.monthlyPrice}/mo x ${PAYMENT_CONFIG.totalMonths} or $${PAYMENT_CONFIG.oneTimePrice} total` }, +] + +const targetLocations = DESTINATIONS.map((d) => ({ + name: d.name, + image: d.images[0], + tagline: d.tagline, + codename: d.name.toUpperCase().replace(/\s+/g, '-'), +})) + +export default function LP33EscapePlan() { + const [activeStep, setActiveStep] = useState(0) + + return ( +
+ + + {/* Hero */} +
+
+ Beach paradise escape destination +
+ {/* Scan lines effect */} +
+
+ +
+
+ + CLASSIFIED // TOP SECRET // EYES ONLY +
+ +

+ Your Escape Plan +

+ +
+

+ > INITIATING ESCAPE SEQUENCE... +

+

+ > TARGET: All-inclusive Mexico resort +

+

+ > COST: $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo x {PAYMENT_CONFIG.totalMonths} +

+

+ > DURATION: 5 days / 4 nights +

+

+ > STATUS: AWAITING YOUR COMMAND_ +

+
+ + + Begin Mission + +
+
+ + + + {/* Mission Briefing */} +
+
+
+ +

+ Mission Briefing +

+

+ // CLASSIFICATION: FOR YOUR EYES ONLY +

+
+ +
+ {missionBriefing.map((item, i) => ( +
+ + {item.label}: + + {item.value} +
+ ))} +
+
+
+ + {/* Mission Steps */} +
+
+
+

+ Operation Paradise +

+

+ Follow the mission objectives to secure your escape +

+
+ +
+ {missionSteps.map((step, index) => ( + + ))} +
+
+
+ + {/* Target Locations */} +
+
+
+

+ Target Locations +

+

+ // SELECT YOUR EXTRACTION POINT +

+
+ +
+ {targetLocations.map((loc) => ( +
+
+ {loc.name} +
+
+

+ CODENAME: {loc.codename} +

+

+ {loc.name} +

+

{loc.tagline}

+
+
+
+ ))} +
+
+
+ + {/* Countdown + Ebook */} +
+
+ + +

+ Download Your Escape Plan +

+

+ Free classified dossier: “Budget Luxury Travel” — everything you need + to execute your escape to paradise +

+

+ MISSION WINDOW CLOSES IN: +

+ +
+ +
+ +
+ +
+
+
+ + {/* Pay Now */} +
+
+
+

+ Execute Mission +

+

+ // SECURE YOUR VACATION CERTIFICATE NOW +

+
+ +
+
+

$59/mo

+ + ${PAYMENT_CONFIG.monthlyPrice} + + + /mo x {PAYMENT_CONFIG.totalMonths} months + +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day extraction guarantee +

+
+ + +
+ + +
+
+ + {/* Testimonials */} +
+
+

+ Successful Operatives +

+
+ {TESTIMONIALS.slice(0, 3).map((t) => ( + + ))} +
+
+
+ + {/* FAQ */} + + +
+
+

+ Mission Intel +

+ +
+
+ + {/* Final CTA */} +
+ +

+ The Clock Is Ticking, Agent +

+

+ MISSION WINDOW CLOSING IN: +

+
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP34TikTokVibes.tsx b/src/components/lp/pages/LP34TikTokVibes.tsx new file mode 100644 index 0000000..2175cec --- /dev/null +++ b/src/components/lp/pages/LP34TikTokVibes.tsx @@ -0,0 +1,471 @@ +'use client' + +import { + Heart, MessageCircle, Share2, Bookmark, Music2, + ArrowRight, Sparkles, Eye, Flame, TrendingUp, + Shield, Star, Play, Users, Zap, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const BLACK = '#000000' +const CYAN = '#25F4EE' +const PINK = '#FE2C55' +const DARK_BG = '#121212' + +const engagementStats = [ + { icon: Eye, label: 'views', value: '12.3M', color: 'white' }, + { icon: Heart, label: 'likes', value: '847K', color: PINK }, + { icon: MessageCircle, label: 'comments', value: '156K', color: CYAN }, + { icon: Share2, label: 'shares', value: '23K', color: 'white' }, +] + +const creatorReactions = [ + { + handle: '@travelwithsoph', + reaction: 'NO WAY this is only $29/mo for ALL INCLUSIVE??', + likes: '234K', + avatar: '/images/cdn/photo-1494790108377-be9c29b29330.jpg', + }, + { + handle: '@budgetluxury_', + reaction: 'I literally booked this after seeing one TikTok. Best decision ever.', + likes: '189K', + avatar: '/images/cdn/photo-1472099645785-5658abf4ff4e.jpg', + }, + { + handle: '@mexicovibes.co', + reaction: 'The cenote photos are INSANE. $1.30/day for 5-star? STOPPPP.', + likes: '312K', + avatar: '/images/cdn/photo-1438761681033-6461ffad8d80.jpg', + }, + { + handle: '@thecheaptraveler', + reaction: 'POV: You find the Mexico vacation deal that ACTUALLY delivers', + likes: '445K', + avatar: '/images/cdn/photo-1500648767791-00dcc994a43e.jpg', + }, +] + +const trendingHashtags = [ + '#MexicoParadise', '#AllInclusive', '#TravelTikTok', + '#BudgetLuxury', '#VacationDeal', '#CancunTikTok', + '#ResortLife', '#TravelHack', +] + +export default function LP34TikTokVibes() { + return ( +
+ + + {/* Hero */} +
+
+ Stunning Mexico beach resort +
+
+ +
+ {/* TikTok-style badge */} +
+ + TRENDING NOW + +
+ +

+ The Mexico deal +
+ that broke{' '} + TikTok +

+ +

+ 12.3 million views. 847K likes. One vacation deal that's going viral + for a reason. +

+ + {/* Engagement Stats */} +
+ {engagementStats.map((stat) => ( +
+
+ + + {stat.value} + +
+

{stat.label}

+
+ ))} +
+ +
+ Swipe through real TikToks below + 👇 +
+ + + Get the Insider Guide + +
+
+ + {/* TikTok Carousel - Main Feature */} + + + {/* Creator Reactions */} +
+
+
+

+ Creator Reactions +

+

+ What TikTok creators are saying about this deal +

+
+ +
+ {creatorReactions.map((creator) => ( +
+ {creator.handle} +
+

+ {creator.handle} +

+

+ “{creator.reaction}” +

+
+ + {creator.likes} + +
+
+
+ ))} +
+ + {/* Trending Hashtags */} +
+ {trendingHashtags.map((tag) => ( + + {tag} + + ))} +
+
+
+ + {/* The Deal Section */} +
+
+

+ Why 12 Million People +
+ Can't Look Away +

+ +
+ {[ + { label: '5 Days / 4 Nights', icon: '🏖️' }, + { label: 'All-Inclusive', icon: '🍹' }, + { label: '5-Star Resort', icon: '⭐' }, + { label: '$1.30/Day', icon: '🤯' }, + ].map((item) => ( +
+ {item.icon} +

{item.label}

+
+ ))} +
+ +
+

+ The math that made this go viral: +

+
+
+

Regular price

+

+ $3,000+ +

+
+ +
+

Our price

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+
+ +
+

Per day

+

+ $1.30 +

+
+
+

+ $59/mo →{' '} + + ${PAYMENT_CONFIG.monthlyPrice}/mo + {' '} + x {PAYMENT_CONFIG.totalMonths} months (promotional pricing) +

+
+
+
+ + {/* Ebook Capture */} +
+
+ + +

+ Want the Insider Tips +
+ These Creators Used? +

+

+ Free e-book: “Budget Luxury Travel” — the TikTok travel guide + that's been shared 23K+ times +

+

+ Free guide + promotional pricing expires in: +

+ +
+ +
+ +
+ +
+
+
+ + {/* Pay Now */} +
+
+
+

+ Ready to Go Viral on Vacation? +

+

+ Join 23K+ people who already claimed this deal +

+
+ +
+
+
+ VIRAL DEAL +
+

$59/mo

+ + ${PAYMENT_CONFIG.monthlyPrice} + + + /mo x {PAYMENT_CONFIG.totalMonths} months + +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day money-back guarantee +

+
+ + +
+ + +
+
+ + {/* Testimonials */} +
+
+

+ Real Reviews from Real Travelers +

+
+ {TESTIMONIALS.slice(0, 3).map((t) => ( + + ))} +
+
+
+ + {/* FAQ */} + + +
+
+

+ The FAQ Section +

+ +
+
+ + {/* Final CTA */} +
+ +

+ Don't Just Watch the TikToks — Live It +

+

+ This deal is going fast. Promotional pricing ends when the timer hits zero. +

+
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP35NoBrainer.tsx b/src/components/lp/pages/LP35NoBrainer.tsx new file mode 100644 index 0000000..746f596 --- /dev/null +++ b/src/components/lp/pages/LP35NoBrainer.tsx @@ -0,0 +1,569 @@ +'use client' + +import { + Calculator, Check, ArrowRight, TrendingDown, + Shield, Sparkles, Coffee, Tv, UtensilsCrossed, + Palmtree, Star, Zap, Brain, ChevronRight, + DollarSign, Equal, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const WHITE = '#FFFFFF' +const INDIGO = '#4F46E5' +const GREEN = '#10B981' +const LIGHT_INDIGO = '#EEF2FF' +const LIGHT_GREEN = '#ECFDF5' + +const dailyCostComparisons = [ + { + label: 'Morning Coffee', + cost: 5.50, + icon: Coffee, + frequency: 'daily', + color: '#92400E', + bgColor: '#FEF3C7', + }, + { + label: 'Lunch Out', + cost: 12.00, + icon: UtensilsCrossed, + frequency: 'daily', + color: '#9F1239', + bgColor: '#FFE4E6', + }, + { + label: 'Streaming Services', + cost: 1.80, + icon: Tv, + frequency: 'daily', + color: '#5B21B6', + bgColor: '#EDE9FE', + }, + { + label: 'THIS VACATION', + cost: 1.30, + icon: Palmtree, + frequency: 'daily', + color: WHITE, + bgColor: GREEN, + highlight: true, + }, +] + +const mathBreakdown = [ + { label: '$1.30/day', sublabel: 'daily cost' }, + { label: '300 days', sublabel: 'payment period' }, + { label: '$290', sublabel: 'total investment' }, + { label: '5-day luxury vacation', sublabel: 'what you get' }, +] + +const whatYouGet = [ + '5 days / 4 nights at a 5-star resort', + 'All meals included (breakfast, lunch, dinner)', + 'Unlimited drinks (including alcohol)', + 'Resort pools, beach, and amenities', + 'Evening entertainment and live shows', + '18 months to book your travel dates', + '30-day money-back guarantee', + 'Flexible payment plan available', +] + +export default function LP35NoBrainer() { + return ( +
+ + + {/* Hero */} +
+
+
+
+ +
+
+ + SIMPLE MATH. INCREDIBLE VALUE. +
+ +

+ Let's Do the Math. +
+ It's a No-Brainer. +

+ +

+ We're about to show you why this is the easiest decision you'll make all year. + No tricks. Just math. +

+ + {/* Hero Math */} +
+
+ {mathBreakdown.map((item, index) => ( +
+
+

+ {item.label} +

+

+ {item.sublabel} +

+
+ {index < mathBreakdown.length - 1 && ( + + {index === mathBreakdown.length - 2 ? '=' : '\u00D7'} + + )} +
+ ))} +
+
+ + + See the Full Breakdown + +
+
+ + + + {/* Daily Cost Comparison - Big Bold Cards */} +
+
+
+

+ If You Can Afford a Coffee, +
+ You Can Afford Paradise +

+

+ Here's what $1.30/day looks like compared to things you already spend on +

+
+ +
+ {dailyCostComparisons.map((item) => ( +
+ {item.highlight && ( +
+ BEST VALUE +
+ )} +
+ +
+

+ ${item.cost.toFixed(2)} +

+

+ {item.label} +

+

+ per day +

+
+ ))} +
+ + {/* Visual Bar Chart */} +
+

+ Daily Cost Comparison +

+
+ {dailyCostComparisons.map((item) => { + const maxCost = 12 + const widthPercent = (item.cost / maxCost) * 100 + return ( +
+
+ + {item.label} + +
+
+
+ + ${item.cost.toFixed(2)} + +
+
+
+ ) + })} +
+
+
+
+ + {/* The Math Proof */} +
+
+
+ +

+ The Logic Is Simple +

+

+ Here's the value breakdown that makes this a no-brainer +

+
+ +
+ {/* What you pay */} +
+

+ What You Pay +

+
+
+

Option A: Payment Plan

+

$59/mo

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

+ x {PAYMENT_CONFIG.totalMonths} months = ${PAYMENT_CONFIG.totalPrice} +

+
+
+

or

+
+
+

Option B: One-Time

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+
+
+
+ + {/* What you get */} +
+

+ What You Get ($3,000+ Value) +

+
+ {whatYouGet.map((item) => ( +
+ + {item} +
+ ))} +
+
+
+ + {/* Savings callout */} +
+

+ You Save Over $2,600 +

+

+ That's an 87% discount on a $3,000+ vacation. The math doesn't lie. +

+
+
+
+ + {/* The "Can I Afford It" Section */} +
+
+

+ “But Can I Really Afford It?” +

+ +
+

+ Let's check. Do you spend money on any of these? +

+ +
+ {[ + { text: 'Coffee 3x/week', savings: '$9/wk' }, + { text: 'Fast food once/week', savings: '$12/wk' }, + { text: 'Streaming subscriptions', savings: '$15/mo' }, + { text: 'Gas station snacks', savings: '$5/wk' }, + { text: 'Impulse Amazon buys', savings: '$20/mo' }, + { text: 'Uber Eats delivery fees', savings: '$8/wk' }, + ].map((item) => ( +
+

+ {item.text} +

+

+ ~ {item.savings} +

+
+ ))} +
+ +

+ If you checked even ONE — you can afford this vacation. +

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo is less than what most people spend on coffee in a week. +

+
+
+
+ + {/* Ebook Capture */} +
+
+ + +

+ Get the Full Cost Breakdown +

+

+ Free e-book: “Budget Luxury Travel” — detailed pricing analysis, + savings tips, and the complete math behind the deal +

+

+ Free guide + promotional pricing available for: +

+ +
+ +
+ +
+ +
+
+
+ + {/* Pay Now */} +
+
+
+

+ The Math Checks Out +

+

+ $1.30/day. 5-star resort. All-inclusive. It really is that simple. +

+
+ +
+
+
+ NO-BRAINER DEAL +
+

+ $59/mo +

+ + ${PAYMENT_CONFIG.monthlyPrice} + + + /mo x {PAYMENT_CONFIG.totalMonths} months + +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 30-day money-back guarantee +

+

+ = $1.30/day for a luxury Mexico vacation +

+
+ + +
+ + +
+
+ + {/* Testimonials */} +
+
+

+ Smart People Who Did the Math +

+
+ {TESTIMONIALS.slice(0, 3).map((t) => ( + + ))} +
+
+
+ + + + {/* FAQ */} +
+
+

+ Frequently Asked Questions +

+ +
+
+ + {/* Final CTA */} +
+ +

+ $1.30/Day. 5-Star Resort. All-Inclusive. +

+

+ The math is clear. The deal expires when the timer hits zero. +

+
+ +
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP36WeekendEscape.tsx b/src/components/lp/pages/LP36WeekendEscape.tsx new file mode 100644 index 0000000..baf707c --- /dev/null +++ b/src/components/lp/pages/LP36WeekendEscape.tsx @@ -0,0 +1,493 @@ +'use client' + +import { useState } from 'react' +import { + Calendar, Sun, Plane, MapPin, Clock, CheckCircle2, + ArrowRight, Sparkles, Star, ChevronRight, Heart, + CalendarDays, CalendarCheck, Palmtree, Umbrella, + Coffee, Sunset, PartyPopper, Shield, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import DestinationCarousel from '@/components/lp/shared/DestinationCarousel' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const SKY_BLUE = '#0EA5E9' +const ORANGE = '#F97316' +const WHITE = '#FFFFFF' + +const calendarDays = [ + { day: 'Mon', date: 12, type: 'normal' }, + { day: 'Tue', date: 13, type: 'normal' }, + { day: 'Wed', date: 14, type: 'vacation' }, + { day: 'Thu', date: 15, type: 'vacation' }, + { day: 'Fri', date: 16, type: 'vacation' }, + { day: 'Sat', date: 17, type: 'vacation' }, + { day: 'Sun', date: 18, type: 'vacation' }, + { day: 'Mon', date: 19, type: 'normal' }, + { day: 'Tue', date: 20, type: 'normal' }, +] + +const flexFeatures = [ + { + icon: CalendarDays, + title: 'Pick Any 5 Days', + description: 'No fixed dates. Choose when YOU want to go within 18 months.', + }, + { + icon: Plane, + title: 'Any Airport, Any Airline', + description: 'Fly from wherever is most convenient. We handle the resort.', + }, + { + icon: MapPin, + title: '4 Stunning Destinations', + description: 'Cancun, Cabo, Riviera Maya, or Puerto Vallarta — your pick.', + }, + { + icon: Umbrella, + title: 'All-Inclusive Everything', + description: 'Food, drinks, activities, pools, beach — all included.', + }, +] + +const weekendIdeas = [ + { + title: 'The Long Weekend', + dates: 'Thu–Mon', + description: 'Take 2 days off, get a 5-day paradise escape. Back by Tuesday.', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + }, + { + title: 'The Mid-Week Reset', + dates: 'Mon–Fri', + description: 'Skip one work week. Come back completely recharged.', + image: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + }, + { + title: 'The Holiday Extension', + dates: 'Around any holiday', + description: 'Attach your trip to a long weekend. Maximize your PTO.', + image: '/images/cdn/photo-1512100356356-de1b84283e18.jpg', + }, + { + title: 'The Celebration Trip', + dates: 'Any special date', + description: 'Birthday? Anniversary? Make it unforgettable in Mexico.', + image: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg', + }, +] + +const dailyBreakdown = [ + { icon: Coffee, label: 'Morning', text: 'Wake up to ocean views, gourmet breakfast buffet' }, + { icon: Sun, label: 'Afternoon', text: 'Pool, beach, snorkeling, spa — your choice' }, + { icon: Sunset, label: 'Evening', text: 'Sunset cocktails, fine dining, live entertainment' }, + { icon: PartyPopper, label: 'Night', text: 'Dance, stargaze, or just listen to the waves' }, +] + +export default function LP36WeekendEscape() { + const [selectedDay, setSelectedDay] = useState(null) + + return ( +
+ + + {/* Hero Section */} +
+
+
+
+
+ +
+
+ + Limited Time: Special Weekend Pricing +
+ +

+ Turn Any Week Into +
+ + Paradise + + +

+ +

+ 5 days & 4 nights all-inclusive in Mexico. +
+ Pick any 5 days. We handle the rest. +

+ +
+ $59/mo + ${PAYMENT_CONFIG.monthlyPrice}/mo + + SAVE 34% + +
+ + {/* Calendar Visual */} +
+
+

+ Your Calendar +

+ March 2026 +
+
+ {calendarDays.map((d, i) => ( + + ))} +
+
+ + Paradise days + + + Regular days + +
+
+ + + Plan My Escape + +
+
+ + + + {/* Flexibility Features */} +
+
+

+ Total Flexibility. Zero Stress. +

+

+ Stop waiting for the “perfect time.” With our flexible certificates, + any time becomes the perfect time. +

+ +
+ {flexFeatures.map((f, i) => ( +
+
+ +
+

+ {f.title} +

+

{f.description}

+
+ ))} +
+
+
+ + {/* Weekend Ideas */} +
+
+

+ 4 Ways to Plan Your Escape +

+

+ However you slice it, paradise fits into your schedule. +

+ +
+ {weekendIdeas.map((idea, i) => ( +
+
+ {idea.title} +
+ {idea.dates} +
+
+
+

+ {idea.title} +

+

{idea.description}

+
+
+ ))} +
+
+
+ + {/* Daily Breakdown */} +
+
+

+ A Day in Paradise +

+

+ Every day is designed for pure enjoyment. Here's a taste. +

+ +
+ {dailyBreakdown.map((item, i) => ( +
+
+ +
+
+

+ {item.label} +

+

{item.text}

+
+
+ ))} +
+
+
+ + {/* Pricing Anchor */} +
+
+

+ Less Than Your Daily Coffee +

+

+ At ${PAYMENT_CONFIG.monthlyPrice}/mo, that's just $1.30/day for 5 days of all-inclusive paradise. +

+ +
+
+

$3,200+

+

Typical all-inclusive vacation

+
+
+

$59/mo

+

Regular certificate price

+
+
+

${PAYMENT_CONFIG.monthlyPrice}/mo

+

Your price today

+
+
+ + +
+
+ + {/* Destination Carousel */} +
+
+

+ Choose Your Destination +

+

+ Four incredible Mexican destinations. All yours to explore. +

+ +
+
+ + {/* Testimonials */} +
+
+

+ Travelers Who Took the Leap +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* Primary CTA - Ebook */} +
+
+ +

+ Get the Date Planning Guide +

+

+ Our free “Budget Luxury Travel” ebook shows you exactly how to plan + the perfect getaway around your schedule. Download it now before this offer expires. +

+ + + +

+ Free instant download. No spam, ever. +

+
+
+ + {/* Secondary CTA - Pay Now */} +
+
+

+ Ready to Book? Lock In Your Rate +

+

+ Don't let this price slip away. Once the timer hits zero, the rate goes back to $59/mo. +

+

+ 30-day money-back guarantee. Cancel anytime. +

+ + +
+
+ + {/* Trust Badges */} +
+ +
+ + {/* FAQ */} + + +
+
+

+ Questions? We've Got Answers +

+ +
+
+ + {/* Final CTA Banner */} +
+

+ Your Calendar Deserves Some Color +

+

+ Stop scrolling. Start planning. 5 days of paradise are waiting for you. +

+ + Claim My Spot + +
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP37TrustFall.tsx b/src/components/lp/pages/LP37TrustFall.tsx new file mode 100644 index 0000000..1b54363 --- /dev/null +++ b/src/components/lp/pages/LP37TrustFall.tsx @@ -0,0 +1,459 @@ +'use client' + +import { + Star, Shield, CheckCircle2, Award, Users, ThumbsUp, + Quote, ArrowRight, BadgeCheck, MessageSquare, Eye, + Lock, Heart, TrendingUp, Verified, Clock, + ShieldCheck, Sparkles, ChevronRight, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const DARK_BLUE = '#1E3A5F' +const GOLD = '#F59E0B' +const WHITE = '#FFFFFF' + +const stats = [ + { value: '4.8', suffix: '★', label: 'Average Rating', icon: Star }, + { value: '2,847', suffix: '', label: 'Happy Travelers', icon: Users }, + { value: '98', suffix: '%', label: 'Satisfaction Rate', icon: ThumbsUp }, + { value: '30', suffix: '-day', label: 'Money-Back Guarantee', icon: Shield }, +] + +const trustPoints = [ + { + icon: ShieldCheck, + title: '30-Day Full Refund', + description: 'Not satisfied? Get 100% of your money back within 30 days. No questions, no hassle.', + }, + { + icon: Lock, + title: 'Secure Payment', + description: 'Bank-level 256-bit SSL encryption. Your financial data is always protected.', + }, + { + icon: BadgeCheck, + title: 'Verified Resorts', + description: 'Every resort is personally inspected and rated 4+ stars by our team.', + }, + { + icon: Verified, + title: 'Real Reviews Only', + description: 'Every review is from a verified guest. Zero fake testimonials, ever.', + }, + { + icon: Award, + title: 'BBB Accredited', + description: 'A+ rating with the Better Business Bureau since 2019.', + }, + { + icon: Heart, + title: 'Family-Owned', + description: 'Not a faceless corporation. Real people who care about your experience.', + }, +] + +const reviewHighlights = [ + { text: 'Best vacation deal we\'ve ever found', count: 847, stars: 5 }, + { text: 'The resort was even better than the photos', count: 623, stars: 5 }, + { text: 'Worth every penny — exceeded expectations', count: 512, stars: 5 }, + { text: 'Already planning our second trip', count: 489, stars: 5 }, + { text: 'Customer service was phenomenal', count: 394, stars: 5 }, + { text: 'All-inclusive really means ALL inclusive', count: 371, stars: 4 }, +] + +const ratingDistribution = [ + { stars: 5, percentage: 78, count: 2221 }, + { stars: 4, percentage: 16, count: 455 }, + { stars: 3, percentage: 4, count: 114 }, + { stars: 2, percentage: 1, count: 28 }, + { stars: 1, percentage: 1, count: 29 }, +] + +export default function LP37TrustFall() { + return ( +
+ + + {/* Hero Section */} +
+
+
+
+ +
+
+ {[1, 2, 3, 4, 5].map((s) => ( + + ))} +
+ +

+ Don't Trust Us. +
+ Trust 2,847 Travelers. +

+ +

+ 5 days & 4 nights all-inclusive in Mexico. See why thousands of travelers + rate us 4.8 out of 5 stars. +

+ +
+ $59/mo + ${PAYMENT_CONFIG.monthlyPrice}/mo + + SAVE 34% + +
+ + {/* Stats Bar */} +
+ {stats.map((s, i) => ( +
+ +

+ {s.value}{s.suffix} +

+

{s.label}

+
+ ))} +
+ + + See All Reviews + +
+
+ + + + {/* Rating Distribution */} +
+
+

+ The Numbers Don't Lie +

+

+ Based on 2,847 verified traveler reviews +

+ +
+ {/* Overall Rating */} +
+

4.8

+
+ {[1, 2, 3, 4, 5].map((s) => ( + + ))} +
+

2,847 reviews

+
+ + {/* Distribution Bars */} +
+ {ratingDistribution.map((r) => ( +
+
+ {r.stars} + +
+
+
+
+ {r.count} +
+ ))} +
+
+
+
+ + {/* Review Highlights */} +
+
+

+ What Travelers Say Most +

+

+ The most common themes from verified reviews +

+ +
+ {reviewHighlights.map((r, i) => ( +
+
+ {Array.from({ length: r.stars }).map((_, j) => ( + + ))} +
+

“{r.text}”

+

+ + Mentioned in {r.count} reviews +

+
+ ))} +
+
+
+ + {/* Full Testimonial Grid */} +
+
+

+ Real Stories From Real Travelers +

+

+ Every review is verified. Every traveler is real. +

+ +
+ {TESTIMONIALS.map((t, i) => ( + + ))} +
+
+
+ + {/* TikTok Videos */} +
+
+

+ Watch Real Travelers at Our Resorts +

+

+ Don't just take our word for it. See it for yourself. +

+ +
+
+ + {/* Trust Points */} +
+
+

+ Your Trust Is Everything +

+

+ We know you're careful with your money. Here's why we've earned the trust of thousands. +

+ +
+ {trustPoints.map((tp, i) => ( +
+
+ +
+

+ {tp.title} +

+

{tp.description}

+
+ ))} +
+
+
+ + {/* Trust Badges - Larger */} +
+
+ +
+
+ + {/* Comparison Table */} +
+
+

+ See How We Compare +

+

+ Side-by-side with traditional booking options +

+ +
+
+ + {/* Pricing + Countdown */} +
+
+

+ The Price Won't Last +

+

+ Regular price: $59/mo. Today only: + ${PAYMENT_CONFIG.monthlyPrice}/mo or + ${PAYMENT_CONFIG.oneTimePrice} one-time. +

+

+ 2,847 travelers trusted us. You can too. 30-day money-back guarantee. +

+ + +
+
+ + {/* FAQ */} +
+
+ + +

+ Still Have Questions? +

+

+ We believe in total transparency. Here are the most common questions. +

+ +
+
+ + {/* Primary CTA - Ebook */} +
+
+ +

+ See All 500+ Reviews +

+

+ Download our free “Budget Luxury Travel” guide and get access to our full + review collection. See exactly what 2,847 travelers experienced. +

+ + + +

+ Free instant download. Your data is protected. +

+
+
+ + {/* Secondary CTA - Pay Now */} +
+
+

+ Ready to Join 2,847 Happy Travelers? +

+

+ This price disappears when the timer hits zero. Don't lose your spot. +

+

+ 30-day full refund guarantee. Zero risk. +

+ + +
+
+ + {/* Final CTA */} +
+
+
+ {[1, 2, 3, 4, 5].map((s) => ( + + ))} +
+

+ 4.8 Stars. 2,847 Travelers. Your Turn. +

+ + Get Started Today + +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP38Sunrise.tsx b/src/components/lp/pages/LP38Sunrise.tsx new file mode 100644 index 0000000..8f60273 --- /dev/null +++ b/src/components/lp/pages/LP38Sunrise.tsx @@ -0,0 +1,513 @@ +'use client' + +import { + Sun, Sunrise, Waves, Wind, Music, Utensils, + Heart, Sparkles, Eye, Palette, ArrowRight, + Star, CloudSun, Shell, Flower2, Coffee, + GlassWater, TreePalm, Camera, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const SUNRISE_ORANGE = '#FB923C' +const WARM_PINK = '#F472B6' +const GOLD = '#FBBF24' +const CREAM = '#FFFBEB' + +const sensoryExperiences = [ + { + sense: 'See', + icon: Eye, + title: 'Sunrises That Take Your Breath Away', + description: 'Watch the sky paint itself in shades of gold, coral, and lavender as the sun rises over the Caribbean.', + image: '/images/cdn/photo-1506929562872-bb421503ef21.jpg', + }, + { + sense: 'Hear', + icon: Waves, + title: 'The Rhythm of the Ocean', + description: 'Fall asleep to gentle waves. Wake up to birdsong. No alarm clocks. No traffic. Just nature.', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + }, + { + sense: 'Taste', + icon: Utensils, + title: 'Flavors You\'ll Dream About', + description: 'Fresh ceviche by the pool. Authentic mole at dinner. Exotic cocktails at sunset. All included.', + image: '/images/cdn/photo-1504674900247-0877df9cc836.jpg', + }, + { + sense: 'Feel', + icon: Wind, + title: 'Warm Sand Between Your Toes', + description: 'Sink into powder-soft sand. Feel the warm breeze on your skin. Let every muscle relax.', + image: '/images/cdn/photo-1520454974749-611b7248ffdb.jpg', + }, +] + +const morningMoments = [ + { + time: '6:00 AM', + icon: Sunrise, + title: 'The Sunrise', + text: 'Step onto your private balcony. The sky is painted in impossible colors.', + }, + { + time: '7:30 AM', + icon: Coffee, + title: 'Coffee with a View', + text: 'Rich Mexican coffee, delivered to your terrace. The ocean stretches forever.', + }, + { + time: '8:30 AM', + icon: Utensils, + title: 'Breakfast Paradise', + text: 'Fresh tropical fruits, made-to-order omelets, pastries still warm from the oven.', + }, + { + time: '10:00 AM', + icon: TreePalm, + title: 'Your Day Begins', + text: 'Beach, pool, spa, adventure — the whole day is yours. No plans required.', + }, +] + +const dreamScenes = [ + { + title: 'Crystal Clear Waters', + image: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg', + caption: 'Water so clear you can see the ocean floor', + }, + { + title: 'Sunset Cocktails', + image: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg', + caption: 'Every evening ends with a masterpiece sky', + }, + { + title: 'Infinity Pool', + image: '/images/cdn/photo-1540541338287-41700207dee6.jpg', + caption: 'Where the pool meets the horizon', + }, + { + title: 'Tropical Gardens', + image: '/images/cdn/photo-1512100356356-de1b84283e18.jpg', + caption: 'Lush, vibrant beauty at every turn', + }, +] + +const feelings = [ + { icon: Heart, text: 'Pure relaxation without guilt' }, + { icon: Sparkles, text: 'Wonder at nature\'s beauty' }, + { icon: Music, text: 'Joy in every moment' }, + { icon: Shell, text: 'Connection with someone special' }, + { icon: CloudSun, text: 'Freedom from daily stress' }, + { icon: Flower2, text: 'Peace you haven\'t felt in years' }, +] + +export default function LP38Sunrise() { + return ( +
+ + + {/* Hero Section */} +
+
+
+ Sunrise over Mexican beach +
+ +
+ + +

+ Imagine Waking Up +
+ + To This + +

+ +

+ Close your eyes. Feel the warm sand. Hear the waves. +
+ Smell the salt air. This is your morning in Mexico. +

+ +
+ $59/mo + ${PAYMENT_CONFIG.monthlyPrice}/mo + + SAVE 34% + +
+ +

+ 5 days & 4 nights all-inclusive. Just $1.30/day for paradise. +

+ + + Start My Paradise Morning + +
+
+ + + + {/* Sensory Section */} +
+
+

+ Experience Paradise With Every Sense +

+

+ This isn't just a vacation. It's a feeling. One you'll carry with you forever. +

+ +
+ {sensoryExperiences.map((exp, i) => ( +
+
+
+ {exp.title} +
+ {exp.sense} +
+
+
+
+
+ +
+

+ {exp.title} +

+

{exp.description}

+
+
+ ))} +
+
+
+ + {/* Morning Timeline */} +
+
+

+ Your Morning in Paradise +

+

+ Every sunrise is an invitation to fall in love with life again. +

+ +
+
+ + {morningMoments.map((moment, i) => ( +
+
+ +
+
+ {moment.time} +

+ {moment.title} +

+

{moment.text}

+
+
+ ))} +
+
+
+ + {/* Dream Gallery */} +
+
+

+ Scenes From Your Future Vacation +

+

+ Let yourself dream. These could be your photos in a few months. +

+ +
+ {dreamScenes.map((scene, i) => ( +
+ {scene.title} +
+
+

{scene.title}

+

{scene.caption}

+
+
+ ))} +
+
+
+ + {/* Feelings Grid */} +
+
+

+ What You'll Feel +

+

+ More than a trip. A transformation. +

+ +
+ {feelings.map((f, i) => ( +
+ +

{f.text}

+
+ ))} +
+
+
+ + {/* Pricing */} +
+
+

+ This Feeling Costs Less Than You Think +

+

+ $59/mo{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo or{' '} + ${PAYMENT_CONFIG.oneTimePrice} one-time +

+

+ That's just $1.30/day. Less than your morning coffee. +

+ +
+
+ + {/* Testimonials */} +
+
+

+ They Found Their Sunrise +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* Primary CTA - Ebook */} +
+
+ +

+ Start Planning Your Paradise Morning +

+

+ Download our free “Budget Luxury Travel” guide and start + imagining your first sunrise in Mexico. Don't wait — this feeling is closer than you think. +

+ + + +

+ Free instant download. Pure inspiration inside. +

+
+
+ + {/* Secondary CTA - Pay Now */} +
+
+

+ Don't Let This Feeling Fade +

+

+ You felt something reading this page. That's your heart telling you it's time. + Lock in this special price before it disappears. +

+

+ 30-day money-back guarantee. Zero risk. +

+ + +
+
+ + {/* Trust Badges */} +
+ +
+ + {/* FAQ */} + + +
+
+

+ Common Questions +

+ +
+
+ + {/* Final CTA */} +
+

+ Your Sunrise Is Waiting +

+

+ Stop dreaming. Start living. Paradise is only ${PAYMENT_CONFIG.monthlyPrice}/mo away. +

+ + Begin My Journey + +
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP39Adrenaline.tsx b/src/components/lp/pages/LP39Adrenaline.tsx new file mode 100644 index 0000000..1bb24a9 --- /dev/null +++ b/src/components/lp/pages/LP39Adrenaline.tsx @@ -0,0 +1,452 @@ +'use client' + +import { + Zap, Mountain, Waves, Wind, ArrowRight, Star, + Shield, Clock, Flame, Target, Trophy, Compass, + ChevronRight, Swords, Bike, Anchor, + Eye, Sparkles, Users, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import ComparisonTable from '@/components/lp/shared/ComparisonTable' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const LIME = '#84CC16' +const BLACK = '#0A0A0A' +const ELECTRIC_BLUE = '#3B82F6' + +const adventures = [ + { + title: 'Zip-Lining', + description: 'Soar 200 feet above the jungle canopy at 45mph. Feel the wind rip past as the rainforest blurs below.', + image: '/images/cdn/photo-1530866495561-507c83010e82.jpg', + icon: Wind, + intensity: 'HIGH', + }, + { + title: 'Snorkeling', + description: 'Dive into crystal-clear cenotes. Swim alongside sea turtles and tropical fish in water so clear it feels unreal.', + image: '/images/cdn/photo-1544551763-46a013bb70d5.jpg', + icon: Waves, + intensity: 'MEDIUM', + }, + { + title: 'ATV Tours', + description: 'Tear through jungle trails and coastal paths on all-terrain vehicles. Mud, dust, and pure adrenaline.', + image: '/images/cdn/photo-1558618666-fcd25c85f82e.jpg', + icon: Bike, + intensity: 'HIGH', + }, + { + title: 'Cenote Diving', + description: 'Descend into ancient underground caves filled with impossibly blue water. Otherworldly and unforgettable.', + image: '/images/cdn/photo-1518638150340-f706e86654de.jpg', + icon: Compass, + intensity: 'EXTREME', + }, + { + title: 'Cliff Jumping', + description: 'Stand at the edge. Look down at turquoise water 30 feet below. Three... two... one... JUMP.', + image: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg', + icon: Mountain, + intensity: 'EXTREME', + }, + { + title: 'Parasailing', + description: 'Float 500 feet above the coastline. The entire Mexican Riviera stretches out below you like a painting.', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + icon: Anchor, + intensity: 'MEDIUM', + }, +] + +const dailyCost = [ + { item: 'A large coffee', cost: '$6.50' }, + { item: 'A fast food combo', cost: '$12.00' }, + { item: 'A movie ticket', cost: '$15.00' }, + { item: '5 DAYS IN MEXICO', cost: '$1.30/day', highlight: true }, +] + +const adventureStats = [ + { value: '6+', label: 'Adventure Activities', icon: Flame }, + { value: '4', label: 'Epic Destinations', icon: Target }, + { value: '5', label: 'Days of Adrenaline', icon: Trophy }, + { value: '∞', label: 'Food & Drinks', icon: Sparkles }, +] + +const intensityColors: Record = { + 'MEDIUM': ELECTRIC_BLUE, + 'HIGH': LIME, + 'EXTREME': '#EF4444', +} + +export default function LP39Adrenaline() { + return ( +
+ + + {/* Hero Section */} +
+
+ Adventure in Mexico +
+
+ +
+
+ + ADVENTURE AWAITS +
+ +

+ Mexico Isn't Just Beaches. +
+ It's{' '} + + ADVENTURE. + +

+ +

+ Zip-lining. Cenote diving. ATV tours. Cliff jumping. +
+ All this + unlimited food & drinks. +

+ +
+ $59/mo + ${PAYMENT_CONFIG.monthlyPrice}/mo + + SAVE 34% + +
+ + + Get the Adventure Guide + +
+
+ + + + {/* Adventure Stats */} +
+
+
+ {adventureStats.map((s, i) => ( +
+ +

+ {s.value} +

+

{s.label}

+
+ ))} +
+
+
+ + {/* Adventure Activities Grid */} +
+
+

+ Your Adrenaline Menu +

+

+ Six heart-pumping activities included with your all-inclusive certificate. +

+ +
+ {adventures.map((adv, i) => ( +
+
+ {adv.title} +
+
+ {adv.intensity} +
+
+
+
+
+ +
+

+ {adv.title} +

+
+

{adv.description}

+
+
+ ))} +
+
+
+ + {/* Cost Comparison */} +
+
+

+ All This For{' '} + ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

+ That's $1.30/day for unlimited food, + drinks, resort access, AND adventure activities. Perspective check: +

+ +
+ {dailyCost.map((item, i) => ( +
+ + {item.item} + + + {item.cost} + +
+ ))} +
+ +
+ +
+
+
+ + {/* TikTok Section */} +
+
+

+ Watch The Action +

+

+ Real travelers. Real adventures. Real Mexico. +

+ +
+
+ + {/* Comparison Table */} +
+
+

+ Us vs. Everyone Else +

+

+ Adventure + all-inclusive at a price that doesn't exist anywhere else +

+ +
+
+ + {/* Testimonials */} +
+
+

+ Adrenaline Junkies Approve +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* Primary CTA - Ebook */} +
+
+ +

+ Get the Adventure Activities Guide +

+

+ Our free “Budget Luxury Travel” ebook includes a complete adventure + activities guide for all four destinations. Download it now — this price won't last. +

+ + + +

+ Free instant download. Fuel your adventure. +

+
+
+ + {/* Secondary CTA - Pay Now */} +
+
+

+ Lock In Your Adventure +

+

+ Every second you wait, someone else claims your spot. The price goes back to $59/mo when the timer hits zero. +

+

+ 30-day money-back guarantee. Zero risk. Maximum adrenaline. +

+ + +
+
+ + {/* Trust Badges */} +
+ +
+ + {/* FAQ */} + + +
+
+

+ Quick Answers +

+ +
+
+ + {/* Final CTA */} +
+
+ +

+ Your Adventure Starts Now +

+

+ Stop watching. Start doing. Mexico is calling. +

+ + Let's Go + +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP40GoldenTicket.tsx b/src/components/lp/pages/LP40GoldenTicket.tsx new file mode 100644 index 0000000..55816a2 --- /dev/null +++ b/src/components/lp/pages/LP40GoldenTicket.tsx @@ -0,0 +1,519 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Ticket, Crown, Star, Sparkles, Gift, Lock, + ArrowRight, Shield, Award, Clock, Heart, + Gem, Trophy, Eye, Users, ChevronRight, + Zap, PartyPopper, BadgeCheck, +} from 'lucide-react' +import EbookCaptureForm from '@/components/lp/shared/EbookCaptureForm' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TikTokCarousel from '@/components/lp/shared/TikTokCarousel' +import SocialProofTicker from '@/components/lp/shared/SocialProofTicker' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import PricingDisplay from '@/components/lp/shared/PricingDisplay' +import UrgencyBanner from '@/components/lp/shared/UrgencyBanner' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG, TESTIMONIALS, FAQ_ITEMS } from '@/app/lp/_config/types' + +const GOLD = '#F59E0B' +const RICH_BROWN = '#78350F' +const CREAM = '#FEF3C7' +const DARK_GOLD = '#B45309' + +const goldenPerks = [ + { + icon: Crown, + title: 'VIP Resort Access', + description: '5 days & 4 nights at a luxury all-inclusive resort. Food, drinks, activities — everything included.', + }, + { + icon: Gift, + title: 'Exclusive Pricing', + description: `Only $${PAYMENT_CONFIG.monthlyPrice}/mo (regular $59/mo). This golden ticket rate is not available anywhere else.`, + }, + { + icon: Gem, + title: '4 Premium Destinations', + description: 'Cancun, Cabo San Lucas, Riviera Maya, or Puerto Vallarta. Your choice.', + }, + { + icon: Star, + title: '18-Month Flexibility', + description: 'Book your paradise trip anytime within 18 months. No rush, no pressure.', + }, + { + icon: Shield, + title: '30-Day Guarantee', + description: 'Full refund within 30 days if you change your mind. Zero risk.', + }, + { + icon: Heart, + title: 'Bring a Guest', + description: 'Your golden ticket covers 2 adults + 2 kids under 12. The whole family flies to paradise.', + }, +] + +const scarcityMilestones = [ + { claimed: 38, total: 50, label: 'Golden Tickets Claimed' }, + { claimed: 847, total: 1000, label: 'Views Today' }, +] + +const exclusiveReasons = [ + 'This link was shared privately — it is not publicly available', + 'The golden ticket rate of $29/mo is 34% below standard pricing', + 'Only 50 golden tickets exist in this batch', + 'Each ticket is limited to one per household', + 'This page will expire when the countdown reaches zero', +] + +const ticketInclusions = [ + { item: '5 Days / 4 Nights', included: true }, + { item: 'All Meals & Drinks', included: true }, + { item: 'Resort Pools & Beach', included: true }, + { item: 'Entertainment & Activities', included: true }, + { item: 'Airport Shuttle', included: true }, + { item: '24/7 Concierge', included: true }, +] + +export default function LP40GoldenTicket() { + const [ticketsLeft, setTicketsLeft] = useState(12) + + useEffect(() => { + const interval = setInterval(() => { + setTicketsLeft((prev) => { + if (prev <= 3) return 3 + return Math.random() > 0.7 ? prev - 1 : prev + }) + }, 45000) + return () => clearInterval(interval) + }, []) + + return ( +
+ + + {/* Hero Section */} +
+
+ {/* Decorative golden particles */} +
+ {Array.from({ length: 20 }).map((_, i) => ( +
+ ))} +
+ +
+ {/* Golden Ticket Frame */} +
+
+ {/* Perforation edges */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ + + +

+ You Found the +
+ Golden Ticket +

+ +
+ +

+ This exclusive link was shared with you personally. +
+ Only 50 golden tickets available. +

+ +
+ $59/mo + + ${PAYMENT_CONFIG.monthlyPrice}/mo + +
+ +

+ 5 days & 4 nights all-inclusive in Mexico +

+
+
+ + + Claim My Golden Ticket + +
+
+ + + + {/* Scarcity Counter */} +
+
+
+ +

+ Only {ticketsLeft} Golden Tickets Remaining +

+

+ 38 of 50 tickets have already been claimed. Once they're gone, this offer disappears forever. +

+ +
+
+
+
+

{50 - ticketsLeft} of 50 claimed

+
+
+
+
+ + {/* Why This Is Exclusive */} +
+
+

+ Why This Golden Ticket Is Special +

+

+ This isn't a regular promotion. Here's what makes it different. +

+ +
+ {exclusiveReasons.map((reason, i) => ( +
+
+ {i + 1} +
+

{reason}

+
+ ))} +
+
+
+ + {/* What Your Golden Ticket Includes */} +
+
+

+ Your Golden Ticket Includes +

+

+ Everything you need for the vacation of a lifetime. +

+ +
+ {goldenPerks.map((perk, i) => ( +
+
+ +
+

+ {perk.title} +

+

{perk.description}

+
+ ))} +
+
+
+ + {/* Ticket Checklist */} +
+
+
+
+

+ What's Included +

+ +
+ {ticketInclusions.map((item, i) => ( +
+ + {item.item} +
+ ))} +
+ +
+

Total value: $3,200+

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo × {PAYMENT_CONFIG.totalMonths} +

+

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time +

+
+
+
+
+
+ + {/* Countdown */} +
+
+

+ This Golden Ticket Expires In +

+

+ When the timer reaches zero, this exclusive pricing disappears. + Don't lose your golden opportunity. +

+ + +
+
+ + {/* TikTok */} +
+
+

+ See What Awaits Golden Ticket Holders +

+

+ Real guests. Real resorts. Real paradise. +

+ +
+
+ + {/* Testimonials */} +
+
+

+ Previous Golden Ticket Winners +

+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( + + ))} +
+
+
+ + {/* Primary CTA - Ebook */} +
+
+
+ +
+

+ Claim Your Golden Guide +

+

+ Download our free “Budget Luxury Travel” guide. See exactly what + your golden ticket unlocks. This exclusive guide is only available through golden ticket links. +

+ + + +

+ Free instant download. Exclusive golden ticket content. +

+
+
+ + {/* Secondary CTA - Pay Now */} +
+
+

+ Ready to Redeem Your Ticket? +

+

+ Only {ticketsLeft} golden tickets remain. When they're gone, the price returns to $59/mo. + This is your moment. +

+

+ 30-day money-back guarantee. Zero risk. Pure golden opportunity. +

+ + +
+
+ + {/* Trust Badges */} +
+ +
+ + {/* FAQ */} + + +
+
+

+ Golden Ticket FAQ +

+ +
+
+ + {/* Final CTA */} +
+
+
+ {[1, 2, 3, 4, 5].map((s) => ( + + ))} +
+

+ Don't Let Your Golden Ticket Expire +

+

+ {ticketsLeft} tickets left. This page disappears when they're gone. +

+ + Claim My Golden Ticket + +
+
+ + +
+ ) +} diff --git a/src/components/lp/pages/LP41SeatReserved.tsx b/src/components/lp/pages/LP41SeatReserved.tsx new file mode 100644 index 0000000..aa16b41 --- /dev/null +++ b/src/components/lp/pages/LP41SeatReserved.tsx @@ -0,0 +1,549 @@ +'use client' + +import { + CheckCircle2, Sparkles, Clock, Lock, ShieldCheck, + ArrowRight, Star, Gift, PlayCircle, Users, + Ticket, BookOpen, MapPin, PartyPopper, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +const ORANGE = '#E8651A' +const DARK = '#0F172A' +const GOLD = '#F59E0B' +const CREAM = '#FEF7ED' + +const transformations = [ + { + name: 'Todd & Jessica Dudley', + location: 'Chicago, IL', + photo: '/images/cdn/photo-1522529599102-193c0d76b5b6.jpg', + before: 'We\u2019d been pricing Cancun for months. Every site wanted $2,800+ per couple.', + moment: 'When I saw the $29/mo certificate I almost scrolled past \u2014 I thought it had to be fake.', + after: 'We booked Riviera Maya, flew out 5 weeks later. Oceanfront suite, unlimited everything. 95% of the puzzle we were missing was just this deal.', + }, + { + name: 'Octavia Taylor', + location: 'Atlanta, GA', + photo: '/images/cdn/photo-1494790108377-be9c29b29330.jpg', + before: 'I kept putting off our anniversary trip \u2014 couldn\u2019t justify spending $3K.', + moment: 'A friend sent me the link. I locked in the $249 one-time option in under 10 minutes.', + after: 'Cabo San Lucas. Sunset catamaran, swim-up bar, the works. The only tweak I needed was letting myself book it.', + }, + { + name: 'Debbie Dussler', + location: 'Phoenix, AZ', + photo: '/images/cdn/photo-1438761681033-6461ffad8d80.jpg', + before: 'My husband thought it was a scam \u2014 we almost cancelled after signing up.', + moment: 'Customer service walked us through everything. We kept the certificate and picked Puerto Vallarta.', + after: 'Best 5 days we\u2019ve had in a decade. We\u2019re already planning year two at Cancun.', + }, +] + +const benefits = [ + { + icon: Ticket, + title: 'Your All-Inclusive Certificate', + desc: '5 days / 4 nights for two at a 4-5\u2605 Mexico resort \u2014 meals, drinks, pools, beaches included.', + }, + { + icon: MapPin, + title: '4 Destinations to Choose From', + desc: 'Cancun, Cabo San Lucas, Riviera Maya, or Puerto Vallarta \u2014 book whichever fits your mood.', + }, + { + icon: BookOpen, + title: 'The Paradise Planning Vault', + desc: 'Our 11-page insider guide: packing lists, excursion picks, flight hacks, what to skip.', + }, + { + icon: PartyPopper, + title: 'Bring a Guest \u2014 Free', + desc: 'Family of 4 — 2 adults + 2 kids under 12, all covered at no extra charge.', + }, +] + +const socialProof = [ + { + quote: 'Booked Cancun for our 10th anniversary. Ocean view suite, infinity pool, and we paid less than one night would cost elsewhere.', + name: 'Sarah & Mike', + location: 'Chicago, IL', + photo: '/images/cdn/photo-1522529599102-193c0d76b5b6.jpg', + }, + { + quote: 'I\u2019m a travel agent. This is the single best certificate I\u2019ve seen in 14 years of booking Mexico trips.', + name: 'Rachel T.', + location: 'Phoenix, AZ', + photo: '/images/cdn/photo-1544005313-94ddf0286df2.jpg', + }, + { + quote: 'Split it into $29/mo payments and barely felt it. Riviera Maya was unreal \u2014 cenote tour, swim-up bars, all included.', + name: 'James & Patricia', + location: 'Miami, FL', + photo: '/images/cdn/photo-1500648767791-00dcc994a43e.jpg', + }, + { + quote: 'My husband thought it was a scam. I booked anyway. He spent the whole flight home apologizing.', + name: 'Maria G.', + location: 'Houston, TX', + photo: '/images/cdn/photo-1438761681033-6461ffad8d80.jpg', + }, + { + quote: 'We paid $249 total. For 5 days all-inclusive. I\u2019ve done the math three times \u2014 it still works out.', + name: 'David & Lisa', + location: 'Denver, CO', + photo: '/images/cdn/photo-1472099645785-5658abf4ff4e.jpg', + }, +] + +export default function LP41SeatReserved() { + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + {/* ===== HEADER CONFIRMATION ===== */} +
+
+
+ + + Your Spot Is Being Held + +
+ +

+ Your Paradise Seat Is +
Reserved — For the Next 30 Minutes +

+

+ Where guesswork ends and a real Mexico vacation begins. Private resort access, lifetime booking window, and + the insider documents no travel agent will send you. +

+ +
+ + This price expires in + +
+ +
+ +

or ${PAYMENT_CONFIG.oneTimePrice} one-time · 100% refund within 30 days

+
+
+
+ + {/* ===== TRANSFORMATION STORIES ===== */} +
+
+
+ Real Travelers +

+ The Missing Piece Most Couples Never Find +

+

+ Not information. Not another listicle. A real certificate, redeemable at real resorts, for a price + that stops making sense the moment you see it. +

+
+ +
+ {transformations.map((t, i) => ( +
+
+
+ {t.name} +
+

{t.name}

+

{t.location}

+
+ {[...Array(5)].map((_, j) => )} +
+
+
+
+
+

Before

+

{t.before}

+
+
+

The Moment

+

“{t.moment}”

+
+
+

After

+

{t.after}

+
+
+
+
+ ))} +
+
+
+ + {/* ===== CORE BENEFITS ===== */} +
+
+
+ What's Included +

+ A Certificate — Not Just Another Travel Membership +

+

+ Four things stack together to make this work. Take any one away and the math breaks. +

+
+ +
+ {benefits.map((b, i) => ( +
+
+
+ +
+
+

{b.title}

+

{b.desc}

+
+
+
+ ))} +
+ +
+
+ + Lock-in price expires in + +
+
+
+
+ + {/* ===== PRICING COMPARISON ===== */} +
+
+
+

+ Pick Your Paradise Tier +

+

+ Both options include the same resort. The difference is how you pay — and what you save. +

+
+ +
+ {/* Monthly */} +
+
+ Payment Plan + POPULAR +
+
+

$59/mo

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

× {PAYMENT_CONFIG.totalMonths} months · ${PAYMENT_CONFIG.totalPrice} total

+
+
    + {[ + '5 days / 4 nights all-inclusive', + 'Choose from 4 Mexico destinations', + 'Bring a guest at no extra cost', + '18 months flexible booking', + 'Book immediately after first payment', + 'Paradise Planning Vault ebook', + ].map((f, i) => ( +
  • + + {f} +
  • + ))} +
+ +
+ + {/* One-time */} +
+
+ + BEST VALUE — SAVE ${PAYMENT_CONFIG.totalPrice - PAYMENT_CONFIG.oneTimePrice} + +
+
+ Pay Once & Done + +
+
+

$599

+

+ ${PAYMENT_CONFIG.oneTimePrice} +

+

+ Save ${PAYMENT_CONFIG.totalPrice - PAYMENT_CONFIG.oneTimePrice} vs. monthly plan +

+
+
    + {[ + 'Everything in the monthly plan', + 'No recurring charges, ever', + 'Priority resort assignment', + 'Extra guest upgrade voucher', + 'Concierge booking hotline', + 'VIP check-in at resort front desk', + ].map((f, i) => ( +
  • + + {f} +
  • + ))} +
+ +
+
+ +

+ Both options include the full 30-day money-back guarantee. Cancel the monthly plan anytime. +

+
+
+ + {/* ===== SOCIAL PROOF ===== */} +
+
+
+
+ + Over 2,847 certificates claimed this month +
+

+ Real Travelers. Real Resorts. Real Receipts. +

+
+ +
+ {socialProof.slice(0, 3).map((t, i) => )} +
+
+ {socialProof.slice(3).map((t, i) => )} +
+
+
+ + {/* ===== MESSAGING REINFORCEMENT ===== */} +
+
+ +

+ The Booking Window Is Closing +

+

+ Resort allotments are released weekly. The current batch expires at midnight, and the same certificate will + reprice at $59/mo for the next cohort. +

+

+ Later almost always turns into never. Lock it in while the discount is still on the table. +

+ +
+
+ + {/* ===== RECAP ===== */} +
+
+

+ Here's Everything You Get Today +

+
+
    + {[ + { label: 'All-Inclusive Mexico Certificate (5D/4N)', value: '$1,499' }, + { label: 'Bring-a-Guest Upgrade (included)', value: '$699' }, + { label: 'Paradise Planning Vault (11-page ebook)', value: '$97' }, + { label: 'Concierge Booking Hotline', value: '$149' }, + { label: '18-Month Flexible Booking Window', value: '$199' }, + { label: '30-Day Money-Back Guarantee', value: 'Priceless' }, + ].map((row, i) => ( +
  • + + + {row.label} + + {row.value} +
  • + ))} +
+
+ Total real-world value + $2,643+ +
+
+ Your price today + + ${PAYMENT_CONFIG.monthlyPrice}/mo + +
+
+
+
+ + {/* ===== ORDER FORM ===== */} +
+
+
+ {/* Top banner with countdown */} +
+
+ + Price locks in + +
+
+ +
+
+ + Step 1 of 2 +
+

+ Reserve Your Discounted Seat +

+

+ Starts at ${PAYMENT_CONFIG.monthlyPrice}/mo · the + price rises after this countdown hits zero. +

+ + + +
+
+ + 100% Money-Back Within 30 Days +
+

+ If the resort doesn't meet your expectations, we refund every penny. No hoops, no hard feelings. +

+
+ +
+ +
+
+
+ +

+ 256-bit SSL · PCI compliant · Cards charged via NMI +

+
+
+ + + + {/* ===== FAQ ===== */} +
+
+

+ Questions Before You Reserve? +

+ +
+
+ + {/* ===== FINAL CTA ===== */} +
+
+ +

+ The Discount Closes When the Clock Hits Zero +

+

+ Same resort. Same certificate. Same 5 days of unlimited everything. Just not at this price. +

+ +

+ Starts at ${PAYMENT_CONFIG.monthlyPrice}/mo · 30-day money-back · Cancel anytime +

+
+
+ + {/* ===== COMPLIANCE FOOTER ===== */} +
+
+

Earnings & Travel Disclaimer

+

+ Travel certificates are subject to availability, blackout dates, and resort terms. Results shown reflect + individual traveler experiences and are not guaranteed. You are solely responsible for any flights, + transfers, and tips not covered by the all-inclusive package. +

+

+ Mexico Paradise Vacations has been connecting travelers to certified Mexico resorts since 2008. For + questions, call (888) 602-2424 or email{' '} + support@724vacation.com. +

+

+ © {new Date().getFullYear()} Mexico Paradise Vacations ·{' '} + Privacy Policy ·{' '} + Terms +

+
+
+
+ ) +} diff --git a/src/components/lp/pages/LP42VIPPass.tsx b/src/components/lp/pages/LP42VIPPass.tsx new file mode 100644 index 0000000..f6bb285 --- /dev/null +++ b/src/components/lp/pages/LP42VIPPass.tsx @@ -0,0 +1,529 @@ +'use client' + +import { + Crown, Lock, Clock, CheckCircle2, XCircle, ArrowRight, + Gem, Gift, Sparkles, ShieldCheck, Star, Ticket, + PhoneCall, BookOpen, Plane, Users, MapPin, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import TestimonialCard from '@/components/lp/shared/TestimonialCard' +import InfluencerBuzz from '@/components/lp/shared/InfluencerBuzz' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +const PURPLE = '#6D28D9' +const DEEP = '#1E1B4B' +const GOLD = '#F59E0B' +const GOLD_DEEP = '#B45309' + +const vipBonuses = [ + { + icon: PhoneCall, + title: 'Private Concierge Line', + desc: 'Direct access to our Mexico booking desk. No call centers, no waits.', + value: '$149', + }, + { + icon: BookOpen, + title: 'Paradise Planning Vault', + desc: '11-page insider guide: packing, excursions, flight hacks, what to skip.', + value: '$97', + }, + { + icon: Plane, + title: 'Free Guest Upgrade Voucher', + desc: 'Bring a third traveler at 50% off \u2014 only available to VIP holders.', + value: '$299', + }, + { + icon: Sparkles, + title: 'VIP Check-in at Resort', + desc: 'Skip the front-desk line, welcome drink, early room assignment.', + value: '$129', + }, + { + icon: Gift, + title: 'Lifetime Rebooking Rights', + desc: 'If dates fall through, re-use your certificate forever. No expiration.', + value: '$199', + }, +] + +const vipTestimonials = [ + { + quote: "The concierge line alone paid for the VIP. She upgraded us to oceanfront for free and booked our catamaran sunset cruise.", + name: "Jennifer & Tom", + location: "New York, NY", + photo: "/images/cdn/photo-1494790108377-be9c29b29330.jpg", + }, + { + quote: "VIP check-in was unreal. We walked past a 40-minute line, got welcome margaritas, and our suite was ready at 11am.", + name: "David & Lisa", + location: "Denver, CO", + photo: "/images/cdn/photo-1472099645785-5658abf4ff4e.jpg", + }, + { + quote: "My sister joined us last-minute. The VIP guest voucher saved us $600. Cabo was a complete dream.", + name: "Maria G.", + location: "Houston, TX", + photo: "/images/cdn/photo-1438761681033-6461ffad8d80.jpg", + }, +] + +const standardFeatures = [ + { label: '5 days / 4 nights all-inclusive', included: true }, + { label: 'Choose 1 of 4 Mexico destinations', included: true }, + { label: 'Bring a guest at no extra cost', included: true }, + { label: '18-month flexible booking window', included: true }, + { label: 'Paradise Planning Vault ebook', included: false }, + { label: 'Private concierge booking line', included: false }, + { label: 'VIP resort check-in privileges', included: false }, + { label: 'Third-guest upgrade voucher', included: false }, + { label: 'Lifetime rebooking rights', included: false }, +] + +const vipFeatures = [ + { label: '5 days / 4 nights all-inclusive', included: true }, + { label: 'Choose 1 of 4 Mexico destinations', included: true }, + { label: 'Bring a guest at no extra cost', included: true }, + { label: '18-month flexible booking window', included: true }, + { label: 'Paradise Planning Vault ebook', included: true }, + { label: 'Private concierge booking line', included: true }, + { label: 'VIP resort check-in privileges', included: true }, + { label: 'Third-guest upgrade voucher', included: true }, + { label: 'Lifetime rebooking rights', included: true }, +] + +export default function LP42VIPPass() { + const scrollToForm = () => { + document.getElementById('signup-form')?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+ + + {/* ===== HERO ===== */} +
+
+
+ + + Invitation-Only VIP Access + +
+ +

+ Upgrade Your Certificate to +
{' '} + VIP Platinum +

+

+ Private concierge, lifetime rebooking, guest upgrades, and skip-the-line check-in. + Only the first 100 seats each month unlock at this price. +

+ +
+ + VIP pricing locks in for + +
+ +
+ +

+ or ${PAYMENT_CONFIG.oneTimePrice} one-time · 100% money-back for 30 days +

+
+
+
+ + {/* ===== BONUS STACK ===== */} +
+
+
+ The VIP Stack +

+ 5 Bonuses That Come Free With VIP +

+

+ None of these are sold separately. They only unlock when you upgrade today. +

+
+ +
+ {vipBonuses.map((b, i) => ( +
+
+ +
+
+

{b.title}

+

{b.desc}

+
+
+

Value

+

{b.value}

+
+
+ ))} +
+ +
+
+

Total bonus value

+

$873

+
+
+
+

Your price

+

FREE with VIP

+
+ +
+
+
+ + {/* ===== TIER COMPARISON ===== */} +
+
+
+

+ VIP Basic vs. VIP Platinum +

+

+ Same resort. Wildly different experience. +

+
+ +
+ {/* Basic tier */} +
+
+
+ + VIP Basic +
+ AVAILABLE +
+
+

$299

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo +

+

for 10 months, total ${PAYMENT_CONFIG.totalPrice}

+
+
    + {standardFeatures.map((f, i) => ( +
  • + {f.included ? ( + + ) : ( + + )} + {f.label} +
  • + ))} +
+ +
+ + {/* VIP Platinum tier */} +
+
+ +
+
+
+ + + VIP Platinum + +
+ + RECOMMENDED + +
+
+

$599

+

+ ${PAYMENT_CONFIG.oneTimePrice}/one-time +

+

+ Save ${PAYMENT_CONFIG.totalPrice - PAYMENT_CONFIG.oneTimePrice} vs. monthly · No recurring charges +

+
+
    + {vipFeatures.map((f, i) => ( +
  • + + {f.label} +
  • + ))} +
+ +

+ Only 100 seats released monthly · this cohort expires at midnight +

+
+
+
+ + {/* Sold Out tier callout */} +
+
+
+ +
+
+
+

VIP Black Tier

+ + SOLD OUT + +
+

+ Private villa, personal butler, $997 tier — all 12 seats claimed for this cycle. Waitlist opens next month. +

+
+ +
+
+
+
+ + {/* ===== VIP TESTIMONIALS ===== */} +
+
+
+
+ + Verified VIP travelers +
+

+ Why They Upgraded — And Would Again +

+
+ +
+ {vipTestimonials.map((t, i) => ( +
+
+ + VIP Platinum + +
+ +
+ ))} +
+
+
+ + {/* ===== DESTINATIONS STRIP ===== */} +
+
+
+

Redeemable At 4 Flagship Resorts

+

VIP members get priority on all four locations, even during peak weeks.

+
+
+ {[ + { name: 'Cancun', img: '/images/cdn/photo-1510097467424-192d713fd8b2.jpg' }, + { name: 'Cabo San Lucas', img: '/images/cdn/photo-1593655600619-a88c11180241.jpg' }, + { name: 'Riviera Maya', img: '/images/cdn/photo-1581710862235-eb6e05d8783f.jpg' }, + { name: 'Puerto Vallarta', img: '/images/cdn/photo-1585793753011-397e6e4668d6.jpg' }, + ].map((d, i) => ( +
+ {d.name} +
+
+ + {d.name} +
+
+ ))} +
+
+
+ + {/* ===== GUARANTEE ===== */} +
+
+
+
+
+
+ +
+
+
+

+ The 30-Day VIP Guarantee +

+

+ Claim VIP today. If you don't feel it's worth every penny within 30 days, we refund you in full + and you keep the Paradise Planning Vault as a parting gift. No hoops. No phone tag. +

+
+
+
+
+
+ + {/* ===== ORDER FORM ===== */} +
+
+
+ {/* Top banner with countdown */} +
+
+ + VIP cohort closes in + +
+
+ +
+
+ + VIP Platinum Enrollment +
+

+ Lock In Your VIP Access +

+

+ Starts at ${PAYMENT_CONFIG.monthlyPrice}/mo or{' '} + ${PAYMENT_CONFIG.oneTimePrice} once. All 5 bonuses unlock immediately. +

+ + + +
+
+ + 30-Day Full Refund Guarantee +
+

+ Don't love it? Email us for a 100% refund — keep the Planning Vault as our gift. +

+
+ +
+ +
+
+
+ +
+ 256-bit SSL + 2,847+ VIP members + PCI compliant +
+
+
+ + + + {/* ===== FINAL CTA ===== */} +
+
+ + +

+ VIP Access Closes at Midnight +

+

+ After this cohort fills, the next VIP enrollment reprices to $299/mo. Same resort, same trip, + different price. +

+ +

+ Starts at ${PAYMENT_CONFIG.monthlyPrice}/mo · 30-day money-back · Cancel anytime +

+
+
+ + {/* ===== COMPLIANCE FOOTER ===== */} +
+
+

Earnings & Travel Disclaimer

+

+ VIP bonuses are delivered digitally and at the resort front desk upon arrival. Certificates are subject to + availability, blackout dates, and resort terms. Individual traveler experiences vary. VIP tier caps reset + the 1st of each month. +

+

+ Mexico Paradise Vacations has connected travelers to certified Mexico resorts since 2008. Support line:{' '} + (888) 602-2424. Email:{' '} + support@724vacation.com. +

+

+ © {new Date().getFullYear()} Mexico Paradise Vacations ·{' '} + Privacy Policy ·{' '} + Terms +

+
+
+
+ ) +} diff --git a/src/components/lp/pages/LP43RealTraveler.tsx b/src/components/lp/pages/LP43RealTraveler.tsx new file mode 100644 index 0000000..5b24fee --- /dev/null +++ b/src/components/lp/pages/LP43RealTraveler.tsx @@ -0,0 +1,304 @@ +'use client' + +import { + CheckCircle, Sparkles, Clock, Lock, ShieldCheck, Star, Users, + Sun, Heart, Waves, Moon, Sandwich, Wind, Coffee, Eye, Smile, + Plane, MapPin, Phone, X, +} from 'lucide-react' +import PayNowForm from '@/components/lp/shared/PayNowForm' +import CountdownTimer from '@/components/lp/shared/CountdownTimer' +import FAQAccordion from '@/components/lp/shared/FAQAccordion' +import TrustBadges from '@/components/lp/shared/TrustBadges' +import StickyMobileCTA from '@/components/lp/shared/StickyMobileCTA' +import { PAYMENT_CONFIG, TESTIMONIALS } from '@/app/lp/_config/types' + +const SLUG = 'real-traveler' +const ORANGE = '#E8651A' +const SUPPORT_PHONE = '888-602-2424' + +export default function LP43RealTraveler() { + return ( +
+ + {/* Top trust strip */} +
+
+
+ + Secure 256-bit SSL · 30-Day Money-Back Guarantee +
+ + + {SUPPORT_PHONE} + +
+
+ + {/* ───── 1. HEADLINE — bold promise ───── */} +
+
+
+ + Real traveler · Day 4 in Mexico +
+

+ She Paid $290 Total
+ For This 5-Star Mexico Vacation +

+

+ Watch her 20-second story below. Same resort, same dates, same 5 days — + last year cost her $2,800. +

+
+ + {/* ───── HERO VIDEO — the testimonial ───── */} +
+
+ +
+ + Real traveler +
+
+

+ Filmed on her iPhone · No script · No edit +

+
+
+ + {/* ───── 2. LEAD — set the stage ───── */} +
+

+ You've been pricing Cancun all week. Every site quotes $2,400, $3,200, $3,800. + You close the tab, promise yourself “next year.” +

+

+ Here's what nobody told you: the resorts are the same. You've just been buying from the wrong place. +

+
+ + {/* ───── 3. STORY — the OLD way ───── */} +
+
+
+

The Old Way

+

Why your last vacation cost $3,000+

+
+
+ {[ + { x: 'Booking the day-of through Expedia or hotel direct', why: 'You pay retail. Retail is brutal.' }, + { x: 'No volume discounts because you book once a year', why: 'Resorts price-discriminate by frequency.' }, + { x: 'Rigid date windows: "We need it for spring break"', why: 'Peak dates = peak prices, every time.' }, + { x: '"Resort fees" + drinks + tips not in the upfront price', why: '$300 a day adds up before you even unpack.' }, + ].map((row, i) => ( +
+

+ {row.x} +

+

{row.why}

+
+ ))} +
+
+
+ + {/* ───── 4. PITCH — the NEW way ───── */} +
+
+
+

The New Way

+

A pre-paid certificate that locks in resort prices

+

+ You pay $29 a month for 10 months (or $249 one-time). + That gets you a certificate for 5 days / 4 nights all-inclusive at any of 4 luxury Mexican resorts — + redeemable any time within 18 months. +

+
+ +
+ {[ + { i: , t: '5 days / 4 nights', d: 'All-inclusive luxury resort' }, + { i: , t: 'Whole family covered', d: '2 adults + 2 kids under 12' }, + { i: , t: 'Unlimited everything', d: 'Food, drinks, premium liquor' }, + { i: , t: '4 destinations', d: 'Cancun · Cabo · Riviera Maya · Puerto Vallarta' }, + { i: , t: '18-month window', d: 'Use whenever your calendar works' }, + { i: , t: '30-day refund', d: 'Full money-back guarantee' }, + ].map((b, i) => ( +
+
{b.i}
+
+

{b.t}

+

{b.d}

+
+
+ ))} +
+
+
+ + {/* ───── Body + Mind reset ───── */} +
+
+
+

+ It's not just a vacation.
+ It's a reset for your body and mind. +

+
+
+
+
+
+

For your body

+
+
    +
  • 5 days of real sunshine resets your circadian rhythm
  • +
  • Ocean swims that don't feel like exercise
  • +
  • Fresh grilled fish, fruit, real meals — inflammation drops
  • +
  • Sleep without alarms. No Sunday-night dread.
  • +
+
+
+
+
+

For your mind

+
+
    +
  • 5 days off the grid. Work email locked in a vault.
  • +
  • Looking at the horizon for 5 days makes problems look small
  • +
  • No phones at dinner. Actually hear who you love.
  • +
  • Memories you'll replay on your worst Tuesdays
  • +
+
+
+
+
+ + {/* ───── 5. EVIDENCE — testimonials + stars + ticker ───── */} +
+
+
+
+ {[1,2,3,4,5].map(i => )} + 4.9 from 2,847 reviews +
+

She's not the only one

+

Real travelers. Real photos. Real trips.

+
+
+ {TESTIMONIALS.slice(0, 3).map((t, i) => ( +
+
+ {[1,2,3,4,5].map(j => )} +
+

“{t.quote}”

+
+ {t.photo && {t.name}} +
+

{t.name}

+

{t.location}

+
+
+
+ ))} +
+
+
+ + {/* ───── 6. OFFER + 7. CLOSE — pricing + CTA + urgency ───── */} +
+
+
+
+ + Founders pricing — locks soon +
+

+ Lock in $29/mo before this rate ends. +

+
+ +
+
+ +
+ {/* Pricing anchor */} +
+

Regular: $39/mo

+

+ ${PAYMENT_CONFIG.monthlyPrice}/mo × {PAYMENT_CONFIG.totalMonths} +

+

+ Save ${(39 - PAYMENT_CONFIG.monthlyPrice) * PAYMENT_CONFIG.totalMonths} vs regular · or one-time ${PAYMENT_CONFIG.oneTimePrice} +

+
+ + + + {/* Risk reversal stack */} +
+
+ +
+

30-day money-back, no questions

+

If you're not 100% satisfied, every penny back. Just email.

+
+
+
+ +
+

Questions? Talk to a human

+

+ Call {SUPPORT_PHONE} · Mon-Sun 8am-10pm CT +

+
+
+
+
+ + +
+
+ + {/* ───── Honest truth callout (Sam-style close) ───── */} +
+
+

The honest truth

+

+ You're not really buying a vacation.
+ You're buying the version of yourself that comes back from it. +

+

+ Calmer. Lighter. With photos of people you love laughing on a beach.
+ For less than what most people spend on Saturday-night dinners in a month. +

+ + Claim My Certificate + +

+ 30-day money-back · No long-term commitment · Cancel anytime +

+
+
+ + {/* ───── FAQ ───── */} +
+
+

Quick answers

+ +
+
+ + +
+ ) +} diff --git a/src/components/lp/shared/ComparisonTable.tsx b/src/components/lp/shared/ComparisonTable.tsx new file mode 100644 index 0000000..b54c1d7 --- /dev/null +++ b/src/components/lp/shared/ComparisonTable.tsx @@ -0,0 +1,68 @@ +'use client' + +import { Check, X } from 'lucide-react' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +interface ComparisonTableProps { + className?: string + accentColor?: string + variant?: 'default' | 'dark' +} + +export default function ComparisonTable({ + className = '', + accentColor = '#4CAF50', + variant = 'default', +}: ComparisonTableProps) { + const isDark = variant === 'dark' + + const features = [ + { feature: '5 Days / 4 Nights', us: true, expedia: true, direct: true }, + { feature: 'All-Inclusive (meals & drinks)', us: true, expedia: false, direct: false }, + { feature: 'Resort Amenities', us: true, expedia: true, direct: true }, + { feature: 'Flexible Dates', us: true, expedia: true, direct: true }, + { feature: 'Payment Plan Available', us: true, expedia: false, direct: false }, + { feature: 'Price Guarantee', us: true, expedia: false, direct: false }, + ] + + return ( +
+ + + + + + + + + + + {features.map((row) => ( + + + + + + + ))} + + + + + + + +
Feature + Mexico Paradise + ExpediaDirect Booking
{row.feature} + {row.us ? : } + + {row.expedia ? : } + + {row.direct ? : } +
Total Price + ${PAYMENT_CONFIG.oneTimePrice} + $2,500+$3,000+
+
+ ) +} diff --git a/src/components/lp/shared/CountdownTimer.tsx b/src/components/lp/shared/CountdownTimer.tsx new file mode 100644 index 0000000..c962e1e --- /dev/null +++ b/src/components/lp/shared/CountdownTimer.tsx @@ -0,0 +1,110 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface CountdownTimerProps { + minutes?: number + variant?: 'flip' | 'digital' | 'minimal' | 'banner' + className?: string + textColor?: string + bgColor?: string + accentColor?: string + message?: string +} + +export default function CountdownTimer({ + minutes = 30, + variant = 'digital', + className = '', + textColor = '#FFFFFF', + bgColor = '#000000', + accentColor = '#F44336', + message, +}: CountdownTimerProps) { + const [timeLeft, setTimeLeft] = useState(() => { + // Session-based 30-min countdown using sessionStorage + if (typeof window !== 'undefined') { + const stored = sessionStorage.getItem('lp_countdown_end') + if (stored) { + const diff = parseInt(stored) - Date.now() + if (diff > 0) return diff + } + const end = Date.now() + minutes * 60 * 1000 + sessionStorage.setItem('lp_countdown_end', end.toString()) + return minutes * 60 * 1000 + } + return minutes * 60 * 1000 + }) + + useEffect(() => { + const timer = setInterval(() => { + setTimeLeft(prev => Math.max(0, prev - 1000)) + }, 1000) + return () => clearInterval(timer) + }, []) + + const totalSeconds = Math.floor(timeLeft / 1000) + const m = Math.floor(totalSeconds / 60) + const s = totalSeconds % 60 + + const pad = (n: number) => n.toString().padStart(2, '0') + + const isUrgent = totalSeconds < 300 // Last 5 minutes + + if (variant === 'banner') { + return ( +
+

+ {message || '⚠️ LIMITED TIME — This promotional price expires in'}{' '} + + {pad(m)}:{pad(s)} + + {' '}— Act now or lose this deal forever +

+
+ ) + } + + if (variant === 'flip') { + const FlipUnit = ({ value, label }: { value: string; label: string }) => ( +
+
+
+ {value} +
+ {label} +
+ ) + + return ( +
+ + : + +
+ ) + } + + if (variant === 'minimal') { + return ( + + {pad(m)}:{pad(s)} + + ) + } + + // Digital variant + return ( +
+ {pad(m)} + : + {pad(s)} +
+ ) +} diff --git a/src/components/lp/shared/DestinationCarousel.tsx b/src/components/lp/shared/DestinationCarousel.tsx new file mode 100644 index 0000000..acc7c97 --- /dev/null +++ b/src/components/lp/shared/DestinationCarousel.tsx @@ -0,0 +1,85 @@ +'use client' + +import useEmblaCarousel from 'embla-carousel-react' +import { useCallback, useEffect, useState } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { DESTINATIONS } from '@/app/lp/_config/types' + +interface DestinationCarouselProps { + className?: string + variant?: 'default' | 'dark' | 'compact' +} + +export default function DestinationCarousel({ + className = '', + variant = 'default', +}: DestinationCarouselProps) { + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }) + const [selectedIndex, setSelectedIndex] = useState(0) + + const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]) + const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]) + + useEffect(() => { + if (!emblaApi) return + const onSelect = () => setSelectedIndex(emblaApi.selectedScrollSnap()) + emblaApi.on('select', onSelect) + return () => { emblaApi.off('select', onSelect) } + }, [emblaApi]) + + const isDark = variant === 'dark' + + return ( +
+
+
+ {DESTINATIONS.map((dest) => ( +
+
+ {dest.name} +
+
+

{dest.name}

+

{dest.tagline}

+
+
+
+ ))} +
+
+ + + + +
+ {DESTINATIONS.map((_, i) => ( +
+
+ ) +} diff --git a/src/components/lp/shared/EbookCaptureForm.tsx b/src/components/lp/shared/EbookCaptureForm.tsx new file mode 100644 index 0000000..fdd6334 --- /dev/null +++ b/src/components/lp/shared/EbookCaptureForm.tsx @@ -0,0 +1,534 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Loader2, Download, BookOpen, Lock, Shield, CreditCard, CheckCircle, + ShieldCheck, Star, Clock, Undo2, Plane, Sparkles, ArrowRight, X, Check, +} from 'lucide-react' +import { toast } from 'sonner' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' +import CountdownTimer from './CountdownTimer' +import { useTrackingParams } from '@/hooks/useTrackingParams' +import { useEarlyLead } from '@/hooks/useEarlyLead' +import { ttqTrack, ttqIdentify } from '@/lib/tiktok-pixel' + +interface EbookCaptureFormProps { + variant?: 'inline' | 'card' | 'minimal' + accentColor?: string + buttonText?: string + sourceLp: string + className?: string + showIcon?: boolean +} + +export default function EbookCaptureForm({ + variant = 'inline', + accentColor = '#FF9800', + buttonText = 'Get My Free Guide', + sourceLp, + className = '', + showIcon = true, +}: EbookCaptureFormProps) { + const tracking = useTrackingParams(sourceLp) + const [email, setEmail] = useState('') + const [name, setName] = useState('') + const [phone, setPhone] = useState('') + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [cardNumber, setCardNumber] = useState('') + const [cardExp, setCardExp] = useState('') + const [cardCvv, setCardCvv] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isPaymentLoading, setIsPaymentLoading] = useState(false) + const [ebookSent, setEbookSent] = useState(false) + const [showPaymentModal, setShowPaymentModal] = useState(false) + const [paymentSuccess, setPaymentSuccess] = useState(false) + const [paymentError, setPaymentError] = useState(null) + const [paymentType, setPaymentType] = useState<'monthly' | 'one-time'>('monthly') + + const amount = paymentType === 'monthly' ? PAYMENT_CONFIG.monthlyPrice : PAYMENT_CONFIG.oneTimePrice + + const { captured: earlyCaptured, capture: captureNow } = useEarlyLead({ + email, phone, name: name || `${firstName} ${lastName}`.trim() || undefined, + source_lp: sourceLp, + referral_code: tracking.ref, + utm_source: tracking.utm_source, + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }) + + // Lock body scroll when modal open + useEffect(() => { + if (showPaymentModal) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { document.body.style.overflow = '' } + }, [showPaymentModal]) + + const formatCardNumber = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, 16) + return digits.replace(/(\d{4})(?=\d)/g, '$1 ') + } + + const formatExp = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, 4) + if (digits.length >= 3) return `${digits.slice(0, 2)}/${digits.slice(2)}` + return digits + } + + const handleEbookSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!email) { + toast.error('Please enter your email') + return + } + + setIsLoading(true) + try { + const res = await fetch('/api/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, name, phone, + source_lp: sourceLp, + referral_code: tracking.ref, + utm_source: tracking.utm_source, + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }), + }) + + const data = await res.json() + const downloadUrl = data.pdfUrl || data.downloadUrl + if (data.success && downloadUrl) { + setEbookSent(true) + // TikTok: lead captured (free-guide opt-in) + ttqIdentify({ email, phone }) + ttqTrack('SubmitForm', { content_name: 'free_guide', description: sourceLp }) + // Trigger download in background + const a = document.createElement('a') + a.href = downloadUrl + a.download = 'budget-luxury-travel.pdf' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + toast.success('Your guide is downloading!') + + // POP UP PAYMENT MODAL immediately after ebook + setTimeout(() => { + setShowPaymentModal(true) + // TikTok: checkout started + ttqTrack('InitiateCheckout', { + value: amount, + currency: 'USD', + content_name: 'Mexico Vacation Certificate', + content_id: paymentType, + }) + }, 800) + } else { + throw new Error('Failed to get download') + } + } catch (error) { + console.error('Ebook error:', error) + toast.error('Something went wrong. Please try again.') + } finally { + setIsLoading(false) + } + } + + const handlePayment = async (e: React.FormEvent) => { + e.preventDefault() + if (!firstName || !lastName || !cardNumber || !cardExp || !cardCvv) { + toast.error('Please fill in all fields') + return + } + + setIsPaymentLoading(true) + setPaymentError(null) + try { + const signupRes = await fetch('/api/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + full_name: `${firstName} ${lastName}`, + phone, + amount: paymentType === 'monthly' ? PAYMENT_CONFIG.totalPrice : PAYMENT_CONFIG.oneTimePrice, + monthly_payment: PAYMENT_CONFIG.monthlyPrice, + payment_plan_months: paymentType === 'monthly' ? PAYMENT_CONFIG.totalMonths : 1, + source_lp: tracking.source_lp, referral_code: tracking.ref, + utm_source: tracking.utm_source, utm_medium: tracking.utm_medium, utm_campaign: tracking.utm_campaign, + }), + }) + const signupData = await signupRes.json() + if (!signupRes.ok) throw new Error(signupData.error || 'Signup failed') + + const paymentRes = await fetch('/api/payment/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firstName, lastName, email, phone, + cardNumber: cardNumber.replace(/\s/g, ''), + cardExp: cardExp.replace('/', ''), + cardCvv, + paymentType, + signupId: signupData.id, + }), + }) + const paymentData = await paymentRes.json() + if (!paymentRes.ok) { + setPaymentError(paymentData.error || 'Payment declined. Please check your card details and try again.') + return + } + + // TikTok: purchase completed — fire before navigation + ttqIdentify({ email, phone }) + ttqTrack('CompletePayment', { + value: amount, + currency: 'USD', + content_type: 'product', + content_name: 'Mexico Vacation Certificate', + content_id: paymentType, + }) + + setPaymentSuccess(true) + setShowPaymentModal(false) + // Redirect to payment success page + window.location.href = '/dashboard/login' + } catch (error) { + setPaymentError(error instanceof Error ? error.message : 'Something went wrong. Please try again.') + } finally { + setIsPaymentLoading(false) + } + } + + // After payment success + if (paymentSuccess) { + return ( +
+ +

Welcome to Paradise!

+

+ Your guide was downloaded and your vacation certificate is confirmed! + {paymentType === 'monthly' && ` Next payment of $${PAYMENT_CONFIG.monthlyPrice} in 30 days.`} +

+
+ ) + } + + // After ebook sent but no payment yet + if (ebookSent && !showPaymentModal) { + return ( +
+ +

Your guide is downloading!

+

Check your downloads folder.

+ + Click here if download didn't start + +
+

Pay for your vacation

+ +
+ +
+
+
+ ) + } + + // ── EBOOK CAPTURE FORM (card variant) ── + const formInputs = ( +
+ {variant === 'card' && ( + setName(e.target.value)} + className="h-11 bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + /> + )} +
+ setEmail(e.target.value)} + onBlur={captureNow} + className={`${variant === 'inline' ? 'h-12 flex-1 text-base' : 'h-11'} bg-white text-gray-900 border-gray-300 placeholder:text-gray-400 ${earlyCaptured ? 'pr-9' : ''}`} + required + /> + {earlyCaptured && ( + + )} +
+ + {variant === 'card' &&

No spam. Unsubscribe anytime.

} +
+ ) + + if (variant === 'card') { + return ( + <> +
+ {showIcon && } +

Free E-book: Budget Luxury Travel

+

5 secrets to luxury Mexico vacations on a budget

+ {formInputs} +
+ {renderPaymentModal()} + + ) + } + + // Inline variant + return ( + <> +
+
+ setEmail(e.target.value)} + onBlur={captureNow} + className={`h-12 text-base bg-white text-gray-900 border-gray-300 placeholder:text-gray-400 ${earlyCaptured ? 'pr-9' : ''}`} + required + /> + {earlyCaptured && ( + + )} +
+ +
+ {renderPaymentModal()} + + ) + + // ── PAYMENT MODAL (same design as PayNowForm) ── + function renderPaymentModal() { + if (!showPaymentModal) return null + + return ( +
+
setShowPaymentModal(false)} /> + +
+ {/* Close */} + + + {/* Hero */} +
+
+ SPECIAL OFFER — JUST FOR YOU +
+

+ Wait! Your Guide is Downloading... +

+

+ Want to actually GO to Mexico? Here's an insane deal: +

+
+ + This offer expires in + +
+
+ + {/* 100% Money Back */} +
+
+ +
+

100% MONEY-BACK GUARANTEE

+

30 days. No questions. Full refund.

+
+
+
+ +
+ {/* Pricing */} +
+ + +
+ + {/* Book immediately */} +
+ +

Book your vacation RIGHT AFTER first payment!

+
+ + {/* Includes */} +
+ {['5 Days / 4 Nights', 'All-Inclusive', 'Unlimited Food', '4 Destinations', 'Flexible Dates', 'Book Immediately'].map(item => ( + + {item} + + ))} +
+ + {/* Form */} +
+
+ Booking: {email} +
+ +
+
+ + setFirstName(e.target.value)} + className="h-10 text-sm bg-white text-gray-900 border-gray-300" required /> +
+
+ + setLastName(e.target.value)} + className="h-10 text-sm bg-white text-gray-900 border-gray-300" required /> +
+
+ +
+ + setPhone(e.target.value)} + className="h-10 text-sm bg-white text-gray-900 border-gray-300" /> +
+ +
+

+ Card Details +

+
+ setCardNumber(formatCardNumber(e.target.value))} + className="h-10 text-sm font-mono bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + maxLength={19} required /> +
+ setCardExp(formatExp(e.target.value))} + className="h-10 text-sm font-mono bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + maxLength={5} required /> + setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))} + className="h-10 text-sm font-mono bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + maxLength={4} required /> +
+
+
+ + {/* Charge summary */} +
+
+ Today's charge: +
+ {paymentType === 'monthly' ? '$59' : '$599'} + ${amount} +
+
+ {paymentType === 'monthly' && ( +

Then ${PAYMENT_CONFIG.monthlyPrice}/mo × {PAYMENT_CONFIG.totalMonths - 1} more. Cancel anytime.

+ )} +
+ 100% refund within 30 days +
+
+ + {/* Error message */} + {paymentError && ( +
+ +
+

Payment Failed

+

{paymentError}

+

Please check your card details and try again.

+
+
+ )} + + + +
+ SSL + 30-Day Guarantee + 4.8★ +
+ +

+ {paymentType === 'monthly' + ? `By clicking Pay, you authorize $${PAYMENT_CONFIG.monthlyPrice} today and ${PAYMENT_CONFIG.totalMonths - 1} monthly charges of $${PAYMENT_CONFIG.monthlyPrice}. Cancel anytime. 100% refund within 30 days.` + : `By clicking Pay, you authorize $${PAYMENT_CONFIG.oneTimePrice}. 100% refund within 30 days.`} +

+ + +
+
+
+
+ ) + } +} diff --git a/src/components/lp/shared/FAQAccordion.tsx b/src/components/lp/shared/FAQAccordion.tsx new file mode 100644 index 0000000..e92fe26 --- /dev/null +++ b/src/components/lp/shared/FAQAccordion.tsx @@ -0,0 +1,38 @@ +'use client' + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { FAQ_ITEMS } from '@/app/lp/_config/types' + +interface FAQAccordionProps { + items?: { question: string; answer: string }[] + className?: string + variant?: 'default' | 'dark' +} + +export default function FAQAccordion({ + items = FAQ_ITEMS, + className = '', + variant = 'default', +}: FAQAccordionProps) { + const isDark = variant === 'dark' + + return ( + + {items.map((item, i) => ( + + + {item.question} + + + {item.answer} + + + ))} + + ) +} diff --git a/src/components/lp/shared/FloatingPhoneButton.tsx b/src/components/lp/shared/FloatingPhoneButton.tsx new file mode 100644 index 0000000..59c3d6a --- /dev/null +++ b/src/components/lp/shared/FloatingPhoneButton.tsx @@ -0,0 +1,19 @@ +'use client' + +import { Phone } from 'lucide-react' + +const PHONE_NUMBER = '+18886022424' +const PHONE_DISPLAY = '(888) 602-2424' + +export default function FloatingPhoneButton() { + return ( + + + {PHONE_DISPLAY} + + ) +} diff --git a/src/components/lp/shared/InfluencerBuzz.tsx b/src/components/lp/shared/InfluencerBuzz.tsx new file mode 100644 index 0000000..6080fcb --- /dev/null +++ b/src/components/lp/shared/InfluencerBuzz.tsx @@ -0,0 +1,168 @@ +'use client' + +import { Heart, MessageCircle, Share2, Play, Eye } from 'lucide-react' + +const INFLUENCERS = [ + { + handle: '@travel.to.mexico8', + followers: '45.2K', + avatar: '/images/cdn/photo-1494790108377-be9c29b29330.jpg', + caption: 'OMG this Mexico deal is INSANE 🤯 $29/mo for all-inclusive?! I booked immediately...', + likes: '12.8K', + comments: '1.4K', + views: '340K', + image: '/images/cdn/photo-1507525428034-b723cf961d3e.jpg', + }, + { + handle: '@chris724santos', + followers: '89.1K', + avatar: '/images/cdn/photo-1500648767791-00dcc994a43e.jpg', + caption: 'We booked the $29/mo Mexico vacation everyone on TikTok is talking about. NO REGRETS 🌴', + likes: '24.3K', + comments: '3.1K', + views: '892K', + image: '/images/cdn/photo-1552074284-5e88ef1aef18.jpg', + }, + { + handle: '@vacay.deals', + followers: '1.2M', + avatar: '/images/cdn/photo-1438761681033-6461ffad8d80.jpg', + caption: 'STOP SCROLLING. All-inclusive Mexico for $29/mo. This is NOT a drill 🚨🏖️', + likes: '67.5K', + comments: '5.8K', + views: '2.4M', + image: '/images/cdn/photo-1580846629083-02669741360a.jpg', + }, + { + handle: '@budget.luxe', + followers: '312K', + avatar: '/images/cdn/photo-1544005313-94ddf0286df2.jpg', + caption: 'Just got back from Cancun. $29/mo for THIS?? The math is mathing 💅✨', + likes: '31.2K', + comments: '2.7K', + views: '1.1M', + image: '/images/cdn/photo-1519046904884-53103b34b206.jpg', + }, + { + handle: '@couples.getaway', + followers: '567K', + avatar: '/images/cdn/photo-1506794778202-cad84cf45f1d.jpg', + caption: 'My wife found this deal and I did NOT believe her. Cabo for $29/mo?! It was REAL 🤩', + likes: '45.9K', + comments: '4.2K', + views: '1.8M', + image: '/images/cdn/photo-1571896349842-33c89424de2d.jpg', + }, + { + handle: '@travelwithsam', + followers: '203K', + avatar: '/images/cdn/photo-1522529599102-193c0d76b5b6.jpg', + caption: 'Puerto Vallarta sunset from our all-inclusive resort. $29/mo. I still can\'t believe it 🌅', + likes: '18.7K', + comments: '1.9K', + views: '678K', + image: '/images/cdn/photo-1585793753011-397e6e4668d6.jpg', + }, +] + +interface InfluencerBuzzProps { + className?: string + variant?: 'light' | 'dark' + count?: number + title?: string +} + +export default function InfluencerBuzz({ + className = '', + variant = 'light', + count = 3, + title = 'Everyone\'s Talking About Us', +}: InfluencerBuzzProps) { + const isDark = variant === 'dark' + // Pick influencers — rotate based on component render + const displayed = INFLUENCERS.slice(0, count) + + return ( +
+
+ {/* Header */} +
+
+ + GOING VIRAL +
+

+ {title} +

+

+ Real people. Real vacations. Real reactions. +

+
+ + {/* Stats bar */} +
+
+

7.3M+

+

Views

+
+
+
+

242

+

Videos

+
+
+
+

2,847+

+

Certificates Claimed

+
+
+ + {/* Influencer cards */} +
= 3 ? 'sm:grid-cols-3' : count === 2 ? 'sm:grid-cols-2' : ''}`}> + {displayed.map((inf, i) => ( +
+ {/* Creator header */} +
+ {inf.handle} +
+

{inf.handle}

+

{inf.followers} followers

+
+ +
+ + {/* Video thumbnail */} +
+ Vacation +
+
+ +
+
+ {/* View count */} +
+ + {inf.views} +
+ {/* Caption overlay */} +
+

{inf.caption}

+
+
+ + {/* Engagement */} +
+ {inf.likes} + {inf.comments} + Share +
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/lp/shared/PayNowForm.tsx b/src/components/lp/shared/PayNowForm.tsx new file mode 100644 index 0000000..0ad1e89 --- /dev/null +++ b/src/components/lp/shared/PayNowForm.tsx @@ -0,0 +1,492 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Loader2, Lock, Shield, CreditCard, CheckCircle, ShieldCheck, + Star, Clock, Undo2, Plane, Sparkles, ArrowRight, X, Check, +} from 'lucide-react' +import { toast } from 'sonner' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' +import CountdownTimer from './CountdownTimer' +import { useTrackingParams } from '@/hooks/useTrackingParams' +import { useEarlyLead } from '@/hooks/useEarlyLead' +import { ttqTrack, ttqIdentify } from '@/lib/tiktok-pixel' + +interface PayNowFormProps { + variant?: 'default' | 'minimal' | 'dark' | 'inline' + accentColor?: string + buttonText?: string + showToggle?: boolean + className?: string + sourceLp?: string + onSuccess?: () => void +} + +export default function PayNowForm({ + variant = 'default', + accentColor = '#E8651A', + buttonText = 'Get My Vacation Certificate!', + showToggle = true, + className = '', + sourceLp, + onSuccess, +}: PayNowFormProps) { + const tracking = useTrackingParams(sourceLp) + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [cardNumber, setCardNumber] = useState('') + const [cardExp, setCardExp] = useState('') + const [cardCvv, setCardCvv] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const [paymentType, setPaymentType] = useState<'monthly' | 'one-time'>('monthly') + const [showPaymentModal, setShowPaymentModal] = useState(false) + const [paymentError, setPaymentError] = useState(null) + const modalRef = useRef(null) + + const amount = paymentType === 'monthly' ? PAYMENT_CONFIG.monthlyPrice : PAYMENT_CONFIG.oneTimePrice + + const { captured: earlyCaptured, capture: captureNow } = useEarlyLead({ + email, phone, name: `${firstName} ${lastName}`.trim() || undefined, + source_lp: sourceLp, + referral_code: tracking.ref, + utm_source: tracking.utm_source, + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }) + + // Lock body scroll when modal open + useEffect(() => { + if (showPaymentModal) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { document.body.style.overflow = '' } + }, [showPaymentModal]) + + const formatCardNumber = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, 16) + return digits.replace(/(\d{4})(?=\d)/g, '$1 ') + } + + const formatExp = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, 4) + if (digits.length >= 3) return `${digits.slice(0, 2)}/${digits.slice(2)}` + return digits + } + + const handleLeadCapture = async (e: React.FormEvent) => { + e.preventDefault() + if (!email || !phone) { + toast.error('Please enter your email and phone number') + return + } + setIsLoading(true) + try { + // Fire claim — saves to DB, sends PDF email. Don't block payment modal + // on email send failures (rare). + const res = await fetch('/api/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, phone, + source_lp: sourceLp, + referral_code: tracking.ref, + utm_source: tracking.utm_source, + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }), + }) + const data = await res.json().catch(() => ({})) + if (data?.emailSent) { + toast.success('Free guide is on its way to your inbox!') + } + // TikTok: lead captured + ttqIdentify({ email, phone }) + ttqTrack('SubmitForm', { content_name: 'claim_certificate', description: sourceLp }) + } catch (err) { + console.error('claim error:', err) + } finally { + setIsLoading(false) + setShowPaymentModal(true) + // TikTok: checkout started (payment modal opened) + ttqTrack('InitiateCheckout', { + value: amount, + currency: 'USD', + content_name: 'Mexico Vacation Certificate', + content_id: paymentType, + }) + } + } + + const handlePayment = async (e: React.FormEvent) => { + e.preventDefault() + if (!firstName || !lastName || !cardNumber || !cardExp || !cardCvv) { + toast.error('Please fill in all fields') + return + } + + setIsLoading(true) + setPaymentError(null) + try { + const signupRes = await fetch('/api/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + full_name: `${firstName} ${lastName}`, + phone, + amount: paymentType === 'monthly' ? PAYMENT_CONFIG.totalPrice : PAYMENT_CONFIG.oneTimePrice, + monthly_payment: PAYMENT_CONFIG.monthlyPrice, + payment_plan_months: paymentType === 'monthly' ? PAYMENT_CONFIG.totalMonths : 1, + source_lp: tracking.source_lp, + referral_code: tracking.ref, + utm_source: tracking.utm_source, + utm_medium: tracking.utm_medium, + utm_campaign: tracking.utm_campaign, + }), + }) + const signupData = await signupRes.json() + if (!signupRes.ok) throw new Error(signupData.error || 'Failed to create signup') + + const paymentRes = await fetch('/api/payment/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firstName, lastName, email, phone, + cardNumber: cardNumber.replace(/\s/g, ''), + cardExp: cardExp.replace('/', ''), + cardCvv, + paymentType, + signupId: signupData.id, + }), + }) + const paymentData = await paymentRes.json() + if (!paymentRes.ok) { + const errMsg = paymentData.error || 'Payment declined. Please check your card details and try again.' + setPaymentError(errMsg) + return + } + + // TikTok: purchase completed — fire before navigation + ttqIdentify({ email, phone }) + ttqTrack('CompletePayment', { + value: amount, + currency: 'USD', + content_type: 'product', + content_name: 'Mexico Vacation Certificate', + content_id: paymentType, + }) + + setIsSuccess(true) + setShowPaymentModal(false) + onSuccess?.() + // Redirect to payment success page + window.location.href = '/dashboard/login' + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Something went wrong. Please try again.' + setPaymentError(errMsg) + } finally { + setIsLoading(false) + } + } + + if (isSuccess) { + return ( +
+ +

Welcome to Paradise!

+

+ Check your email for your vacation certificate. You can book your travel dates immediately! + {paymentType === 'monthly' && ` Next payment of $${PAYMENT_CONFIG.monthlyPrice} in 30 days.`} +

+
+ ) + } + + return ( + <> + {/* Step 1: Email + Phone inline capture — always light bg inputs */} +
+
+
+ +
+ setEmail(e.target.value)} + onBlur={captureNow} + className={`h-12 text-base bg-white text-gray-900 border-gray-300 placeholder:text-gray-400 ${earlyCaptured ? 'pr-9' : ''}`} + required + /> + {earlyCaptured && ( + + )} +
+
+
+ + setPhone(e.target.value)} + onBlur={captureNow} + className="h-12 text-base bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + required + /> +
+ +
+ Secure + 100% Money-Back +
+
+
+ + {/* Step 2: Full-screen payment modal — custom implementation for mobile */} + {showPaymentModal && ( +
+ {/* Backdrop */} +
setShowPaymentModal(false)} /> + + {/* Modal — full screen on mobile, centered card on desktop */} +
+ {/* Close button */} + + + {/* Hero header — compact on mobile */} +
+
+ EXCLUSIVE OFFER +
+

+ All-Inclusive Mexico Vacation +

+

5 Days • 4 Nights • All Meals & Drinks

+
+ + Expires in + +
+
+ + {/* 100% Money Back — compact */} +
+
+ +
+

100% MONEY-BACK GUARANTEE

+

30 days. No questions. Full refund.

+
+
+
+ +
+ + {/* Pricing toggle — compact */} + {showToggle && ( +
+ + +
+ )} + + {/* Book immediately callout */} +
+ +

Book your vacation RIGHT AFTER first payment!

+
+ + {/* What's included — compact horizontal */} +
+ {['5 Days / 4 Nights', 'All-Inclusive', 'Unlimited Food', '4 Destinations', 'Flexible Dates', 'Book Immediately'].map(item => ( + + {item} + + ))} +
+ + {/* Payment form */} +
+ {/* Booking info */} +
+ Booking: {email} +
+ + {/* Name fields */} +
+
+ + setFirstName(e.target.value)} + className="h-10 text-sm bg-white text-gray-900 border-gray-300" required /> +
+
+ + setLastName(e.target.value)} + className="h-10 text-sm bg-white text-gray-900 border-gray-300" required /> +
+
+ + {/* Card fields — standard HTML inputs, no Collect.js iframe issues */} +
+

+ Card Details +

+
+ setCardNumber(formatCardNumber(e.target.value))} + className="h-10 text-sm font-mono bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + maxLength={19} + required + /> +
+ setCardExp(formatExp(e.target.value))} + className="h-10 text-sm font-mono bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + maxLength={5} + required + /> + setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 4))} + className="h-10 text-sm font-mono bg-white text-gray-900 border-gray-300 placeholder:text-gray-400" + maxLength={4} + required + /> +
+
+
+ + {/* Charge summary — compact */} +
+
+ Today's charge: +
+ {paymentType === 'monthly' ? '$59' : '$599'} + ${amount} +
+
+ {paymentType === 'monthly' && ( +

Then ${PAYMENT_CONFIG.monthlyPrice}/mo × {PAYMENT_CONFIG.totalMonths - 1} more. Cancel anytime.

+ )} +
+ 100% refund within 30 days +
+
+ + {/* Error message */} + {paymentError && ( +
+ +
+

Payment Failed

+

{paymentError}

+

Please check your card details and try again.

+
+
+ )} + + {/* Pay button */} + + + {/* Trust strip */} +
+ SSL + 30-Day Guarantee + 4.8★ +
+ +

+ {paymentType === 'monthly' + ? `By clicking Pay, you authorize $${PAYMENT_CONFIG.monthlyPrice} today and ${PAYMENT_CONFIG.totalMonths - 1} monthly charges of $${PAYMENT_CONFIG.monthlyPrice}. Cancel anytime. 100% refund within 30 days.` + : `By clicking Pay, you authorize $${PAYMENT_CONFIG.oneTimePrice}. 100% refund within 30 days.`} +

+
+
+
+
+ )} + + ) +} diff --git a/src/components/lp/shared/PricingDisplay.tsx b/src/components/lp/shared/PricingDisplay.tsx new file mode 100644 index 0000000..80bce77 --- /dev/null +++ b/src/components/lp/shared/PricingDisplay.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useState } from 'react' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' + +interface PricingDisplayProps { + variant?: 'default' | 'large' | 'compact' | 'dark' + accentColor?: string + className?: string + showComparison?: boolean +} + +export default function PricingDisplay({ + variant = 'default', + accentColor = '#E8651A', + className = '', + showComparison = true, +}: PricingDisplayProps) { + const [selected, setSelected] = useState<'monthly' | 'one-time'>('monthly') + const isDark = variant === 'dark' + + return ( +
+
+ + +
+ +
+ {selected === 'monthly' ? ( + <> +
+ ${PAYMENT_CONFIG.monthlyPrice}/mo +
+

+ for {PAYMENT_CONFIG.totalMonths} months (${PAYMENT_CONFIG.totalPrice} total) +

+ + ) : ( + <> +
+ ${PAYMENT_CONFIG.oneTimePrice} +
+

+ one-time payment — save ${PAYMENT_CONFIG.totalPrice - PAYMENT_CONFIG.oneTimePrice} vs. monthly! +

+ + )} +
+ + {showComparison && ( +
+ Regular price: $1,500+ + + Save over $1,100! + +
+ )} +
+ ) +} diff --git a/src/components/lp/shared/SocialProofTicker.tsx b/src/components/lp/shared/SocialProofTicker.tsx new file mode 100644 index 0000000..163c08f --- /dev/null +++ b/src/components/lp/shared/SocialProofTicker.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState, useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +const NAMES = [ + 'Maria from TX', 'James from FL', 'Sarah from CA', 'David from NY', + 'Jennifer from IL', 'Robert from AZ', 'Lisa from CO', 'Michael from GA', + 'Rachel from PA', 'Chris from WA', 'Amanda from NC', 'Daniel from OH', + 'Emily from VA', 'Brian from NJ', 'Nicole from TN', 'Kevin from MN', +] + +const ACTIONS = [ + 'just claimed their certificate', + 'just signed up', + 'booked Cancun', + 'booked Cabo', + 'chose Riviera Maya', + 'is heading to Puerto Vallarta', +] + +interface SocialProofTickerProps { + className?: string + bgColor?: string + textColor?: string + interval?: number +} + +export default function SocialProofTicker({ + className = '', + bgColor = '#000000', + textColor = '#FFFFFF', + interval = 5000, +}: SocialProofTickerProps) { + const [current, setCurrent] = useState(0) + + useEffect(() => { + const timer = setInterval(() => { + setCurrent(prev => (prev + 1) % NAMES.length) + }, interval) + return () => clearInterval(timer) + }, [interval]) + + const name = NAMES[current % NAMES.length] + const action = ACTIONS[current % ACTIONS.length] + + return ( + + ) +} diff --git a/src/components/lp/shared/StickyMobileCTA.tsx b/src/components/lp/shared/StickyMobileCTA.tsx new file mode 100644 index 0000000..49cfbbe --- /dev/null +++ b/src/components/lp/shared/StickyMobileCTA.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Phone } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { PAYMENT_CONFIG } from '@/app/lp/_config/types' +import CountdownTimer from './CountdownTimer' + +interface StickyMobileCTAProps { + accentColor?: string + buttonText?: string + targetId?: string + className?: string +} + +export default function StickyMobileCTA({ + accentColor = '#E8651A', + buttonText = 'Lock In This Price', + targetId = 'signup-form', + className = '', +}: StickyMobileCTAProps) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const handleScroll = () => { + setVisible(window.scrollY > 400) + } + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + const scrollToForm = () => { + document.getElementById(targetId)?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( +
+
+ {/* Urgency micro-bar */} +
+

+ + OFFER EXPIRES IN + +

+
+
+ + + +
+

$59/mo regular

+

${PAYMENT_CONFIG.monthlyPrice}/mo

+
+ +
+
+
+ ) +} diff --git a/src/components/lp/shared/TestimonialCard.tsx b/src/components/lp/shared/TestimonialCard.tsx new file mode 100644 index 0000000..8e7398e --- /dev/null +++ b/src/components/lp/shared/TestimonialCard.tsx @@ -0,0 +1,53 @@ +'use client' + +import { Star } from 'lucide-react' + +interface TestimonialCardProps { + quote: string + name: string + location: string + photo?: string + variant?: 'default' | 'dark' | 'minimal' + className?: string +} + +export default function TestimonialCard({ + quote, + name, + location, + photo, + variant = 'default', + className = '', +}: TestimonialCardProps) { + const isDark = variant === 'dark' + + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+

+ “{quote}” +

+
+ {photo && ( + {name} + )} +
+

{name}

+

{location}

+
+
+
+ ) +} diff --git a/src/components/lp/shared/TikTokCarousel.tsx b/src/components/lp/shared/TikTokCarousel.tsx new file mode 100644 index 0000000..63897b9 --- /dev/null +++ b/src/components/lp/shared/TikTokCarousel.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import { ChevronLeft, ChevronRight, Play } from 'lucide-react' +import videosRaw from '@/data/tiktok-videos.json' + +interface VideoMeta { + id: string + username: string + title: string +} + +const ALL_VIDEOS: VideoMeta[] = videosRaw as VideoMeta[] +const DEFAULT_USERNAME = 'travel.to.mexico8' + +function shuffle(arr: T[]): T[] { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} + +interface TikTokCarouselProps { + username?: string + className?: string + title?: string + subtitle?: string + count?: number +} + +function TikTokTile({ video }: { video: VideoMeta }) { + const href = `https://www.tiktok.com/@${video.username}/video/${video.id}` + return ( + + {video.title + {/* Dim overlay on hover */} +
+ + {/* TikTok corner badge */} +
+ + + + TikTok +
+ + {/* Center play button */} +
+
+ +
+
+ + {/* Bottom gradient + caption */} +
+

@{video.username}

+ {video.title && ( +

{video.title}

+ )} +
+
+ ) +} + +export default function TikTokCarousel({ + username = DEFAULT_USERNAME, + className = '', + title = 'See Real Vacationers in Paradise', + subtitle = 'Watch what our travelers are experiencing right now', + count = 10, +}: TikTokCarouselProps) { + const videos = useMemo(() => shuffle(ALL_VIDEOS).slice(0, count), [count]) + + const [emblaRef, emblaApi] = useEmblaCarousel({ + loop: false, + align: 'start', + slidesToScroll: 1, + containScroll: 'trimSnaps', + }) + const [canScrollPrev, setCanScrollPrev] = useState(false) + const [canScrollNext, setCanScrollNext] = useState(true) + + useEffect(() => { + if (!emblaApi) return + const onSelect = () => { + setCanScrollPrev(emblaApi.canScrollPrev()) + setCanScrollNext(emblaApi.canScrollNext()) + } + emblaApi.on('select', onSelect) + emblaApi.on('reInit', onSelect) + onSelect() + return () => { emblaApi.off('select', onSelect); emblaApi.off('reInit', onSelect) } + }, [emblaApi]) + + if (videos.length === 0) return null + + return ( +
+
+
+
+ + + + {ALL_VIDEOS.length} Real Videos +
+

{title}

+

{subtitle}

+
+ +
+
+
+ {videos.map((v) => ( +
+ +
+ ))} +
+
+ + {canScrollPrev && ( + + )} + {canScrollNext && ( + + )} +
+ + +
+
+ ) +} diff --git a/src/components/lp/shared/TopPhoneBar.tsx b/src/components/lp/shared/TopPhoneBar.tsx new file mode 100644 index 0000000..f3378f9 --- /dev/null +++ b/src/components/lp/shared/TopPhoneBar.tsx @@ -0,0 +1,20 @@ +'use client' + +import { Phone } from 'lucide-react' + +export default function TopPhoneBar() { + return ( + + ) +} diff --git a/src/components/lp/shared/TrustBadges.tsx b/src/components/lp/shared/TrustBadges.tsx new file mode 100644 index 0000000..40b7eef --- /dev/null +++ b/src/components/lp/shared/TrustBadges.tsx @@ -0,0 +1,60 @@ +'use client' + +import { Shield, Lock, Star, BadgeCheck, CreditCard } from 'lucide-react' + +interface TrustBadgesProps { + variant?: 'horizontal' | 'vertical' | 'compact' + className?: string + textColor?: string +} + +export default function TrustBadges({ + variant = 'horizontal', + className = '', + textColor = '#6B7280', +}: TrustBadgesProps) { + const badges = [ + { icon: Lock, label: '256-bit SSL' }, + { icon: Shield, label: '30-Day Guarantee' }, + { icon: Star, label: '4.8/5 Rating' }, + { icon: BadgeCheck, label: 'Verified Business' }, + { icon: CreditCard, label: 'Secure Payments' }, + ] + + if (variant === 'compact') { + return ( +
+ {badges.slice(0, 3).map((b) => ( + + + {b.label} + + ))} +
+ ) + } + + if (variant === 'vertical') { + return ( +
+ {badges.map((b) => ( +
+ + {b.label} +
+ ))} +
+ ) + } + + return ( +
+ {badges.map((b) => ( +
+ + {b.label} +
+ ))} +
+ ) +} diff --git a/src/components/lp/shared/UrgencyBanner.tsx b/src/components/lp/shared/UrgencyBanner.tsx new file mode 100644 index 0000000..6c3e63e --- /dev/null +++ b/src/components/lp/shared/UrgencyBanner.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useState, useEffect } from 'react' +import { X, AlertTriangle, Clock, Users } from 'lucide-react' +import CountdownTimer from './CountdownTimer' + +interface UrgencyBannerProps { + variant?: 'countdown' | 'scarcity' | 'social' | 'combined' + className?: string + dismissible?: boolean +} + +// Psychological triggers: +// 1. Loss aversion — "Don't miss out" > "Get this deal" +// 2. Social proof — others are buying right now +// 3. Scarcity — limited quantity +// 4. Time pressure — countdown creates urgency +// 5. Anchoring — show the "real" price first + +const SCARCITY_MESSAGES = [ + '🔥 Only 7 certificates left at this price', + '⚡ 14 people are viewing this offer right now', + '🎯 23 certificates claimed in the last hour', + '⏰ Price increases to $59/mo after this promotion ends', +] + +export default function UrgencyBanner({ + variant = 'combined', + className = '', + dismissible = true, +}: UrgencyBannerProps) { + const [dismissed, setDismissed] = useState(false) + const [messageIdx, setMessageIdx] = useState(0) + const [viewerCount] = useState(() => Math.floor(Math.random() * 12) + 8) + + useEffect(() => { + if (variant === 'social' || variant === 'combined') { + const timer = setInterval(() => { + setMessageIdx(prev => (prev + 1) % SCARCITY_MESSAGES.length) + }, 4000) + return () => clearInterval(timer) + } + }, [variant]) + + if (dismissed) return null + + return ( +
+ {/* Main urgency bar */} +
+
+ {variant === 'countdown' || variant === 'combined' ? ( + <> + + ⚠️ PROMOTIONAL PRICING EXPIRES IN + EXPIRES IN + + — Lock in $29/mo before it's gone + + ) : variant === 'scarcity' ? ( + <> + + {SCARCITY_MESSAGES[messageIdx]} + + ) : ( + <> + + {viewerCount} people viewing + + + + left at this price + + )} +
+ + {dismissible && ( + + )} +
+ + {/* Secondary social proof ticker (combined only) */} + {variant === 'combined' && ( +
+

+ {SCARCITY_MESSAGES[messageIdx]} +

+
+ )} +
+ ) +} diff --git a/src/data/tiktok-videos.json b/src/data/tiktok-videos.json new file mode 100644 index 0000000..7603b0e --- /dev/null +++ b/src/data/tiktok-videos.json @@ -0,0 +1,1212 @@ +[ + { + "id": "7618023828597312788", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7618023673798151444", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7618023580994948372", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7618023404637130005", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7618023226857311508", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7616902964158008596", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616902684678917397", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616902503128501524", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616902289504193812", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616902084092480789", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616536467678055701", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616536245102972180", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616536083496455445", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616535801245093141", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616535620449619220", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7616165126718115093", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7616164954739182869", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7616164791706717461", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7616164636920007957", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7616164443701103893", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615793139785288980", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615792939800808725", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615792741502487828", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615792557251071253", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615792335791787285", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615418268869741845", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615418100892192020", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615417898932243732", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615417606446632213", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7615417396140068116", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7614307089007021332", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7614306978164264213", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7614306835511790868", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7614306744541383957", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7614306570301738260", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7613939774008593684", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7613939542713715989", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7613939329387220245", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7613939116627086613", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7613938879388798228", + "username": "travel.to.mexico8", + "title": "🌴 MEXICO FAMILY VACATION FOR ONLY $299! 🇲🇽 5 Days & 4 Nights • ALL-INCLUSIVE ✨ ✔️ 2 Adults + 2 Kids (12 & under) ✔️ No blackout dates — t" + }, + { + "id": "7612825138291166485", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7612825010587077908", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7612824883743001877", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7612824727228271892", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7612824588543626516", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7611713202807754005", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7611713028819537172", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7611712896271240469", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7611712751257292053", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7611712572877851925", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7611334215837256981", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7611334042528533780", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7611333870310460693", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7611333628567571732", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7611333495373204756", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610599550029516053", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610596527475608853", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610593736338197780", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610591010338458901", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610586339880242453", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610229016678796565", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610225854345399573", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610214868418956565", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7610212093278555413", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7606146061446417685", + "username": "travel.to.mexico8", + "title": "🚨 $299 MEXICO VACATION DEAL 🚨 🌴 5 Days / 4 Nights 🍹 All-Inclusive Resort 👨‍👩‍👧‍👦 2 Adults + 2 Kids 📅 NO blackout dates This deal wo" + }, + { + "id": "7606141425520053524", + "username": "travel.to.mexico8", + "title": "🚨 $299 MEXICO VACATION DEAL 🚨 🌴 5 Days / 4 Nights 🍹 All-Inclusive Resort 👨‍👩‍👧‍👦 2 Adults + 2 Kids 📅 NO blackout dates This deal wo" + }, + { + "id": "7606137412191882517", + "username": "travel.to.mexico8", + "title": "🚨 $299 MEXICO VACATION DEAL 🚨 🌴 5 Days / 4 Nights 🍹 All-Inclusive Resort 👨‍👩‍👧‍👦 2 Adults + 2 Kids 📅 NO blackout dates This deal wo" + }, + { + "id": "7606131787672079636", + "username": "travel.to.mexico8", + "title": "🚨 $299 MEXICO VACATION DEAL 🚨 🌴 5 Days / 4 Nights 🍹 All-Inclusive Resort 👨‍👩‍👧‍👦 2 Adults + 2 Kids 📅 NO blackout dates This deal wo" + }, + { + "id": "7606128084961545493", + "username": "travel.to.mexico8", + "title": "🚨 $299 MEXICO VACATION DEAL 🚨 🌴 5 Days / 4 Nights 🍹 All-Inclusive Resort 👨‍👩‍👧‍👦 2 Adults + 2 Kids 📅 NO blackout dates This deal wo" + }, + { + "id": "7605777886037003541", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7605774409177107732", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7605771533738380564", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7605765455831141653", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7605763423334337813", + "username": "travel.to.mexico8", + "title": "Picture this… Your kids laughing in the pool 🌊 You relaxing with a drink in hand 🍹 No stress. No planning headaches. Just paradise. 🏖️ 5 " + }, + { + "id": "7605403420501134613", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7605393075606850836", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7605388929088441621", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7605385078083980565", + "username": "travel.to.mexico8", + "title": "🌴 Escape to Paradise! 5 Days & 4 Nights in Mexico — All-Inclusive for Only $299! 🌞 Imagine sun-soaked beaches, crystal-clear waters, and u" + }, + { + "id": "7605034148218113301", + "username": "travel.to.mexico8", + "title": "Escape the everyday and treat your family to the vacation you’ve all been dreaming of — without breaking the bank! ✨ What’s Included: ✔️ 5 D" + }, + { + "id": "7605031715878341908", + "username": "travel.to.mexico8", + "title": "Escape the everyday and treat your family to the vacation you’ve all been dreaming of — without breaking the bank! ✨ What’s Included: ✔️ 5 D" + }, + { + "id": "7605025108616350996", + "username": "travel.to.mexico8", + "title": "Escape the everyday and treat your family to the vacation you’ve all been dreaming of — without breaking the bank! ✨ What’s Included: ✔️ 5 D" + }, + { + "id": "7605017071356792084", + "username": "travel.to.mexico8", + "title": "Escape the everyday and treat your family to the vacation you’ve all been dreaming of — without breaking the bank! ✨ What’s Included: ✔️ 5 D" + }, + { + "id": "7605011911742115093", + "username": "travel.to.mexico8", + "title": "Escape the everyday and treat your family to the vacation you’ve all been dreaming of — without breaking the bank! ✨ What’s Included: ✔️ 5 D" + }, + { + "id": "7603922127410056469", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7603917143503113492", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7603915141607853332", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7603904300644961556", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7603547156028476693", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7603542827678715156", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7602437874096639253", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7602435246679739668", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7602432094261841172", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7602427461330029844", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7602421070645169429", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7602416902236835093", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7601325166483737877", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7601319181895814421", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7601314019064040724", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7601307252854721813", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7601302133505264916", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600953259527818517", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600948478646242581", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600928959915756820", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600582098000383252", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600564867724021013", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600559301060578581", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600213209684970772", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600210082596539669", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600207352259808532", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7600201175933177109", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7598734004359007509", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7598726765644696853", + "username": "travel.to.mexico8", + "title": "🌴 CANCUN GETAWAY for ONLY $299?! 😱✈️ Yes, you read that right! Treat your family to a 5 Days / 4 Nights All-Inclusive Vacation in beautifu" + }, + { + "id": "7598715055793868052", + "username": "travel.to.mexico8", + "title": "" + }, + { + "id": "7618035152551218450", + "username": "chris724santos", + "title": "They said Cancun isn’t safe anymore… meanwhile I’m here watching the sun on the beach, totally safe! Toll-Free (US & Canada): 1-888-602-2424" + }, + { + "id": "7618034303322148114", + "username": "chris724santos", + "title": "Everyone says Cancun is dangerous… I’m here right now and honestly… it’s one of the most peaceful vacations I’ve had Toll-Free (US & Canada)" + }, + { + "id": "7618032920892148999", + "username": "chris724santos", + "title": "New Yorkers, Let me show you what a real stress-free vacation looks like. In Cancun, all-inclusive means everything is covered—from food and" + }, + { + "id": "7618031655797738760", + "username": "chris724santos", + "title": "Calgarians, Close your eyes… now imagine the perfect vacation. Warm beaches, unlimited cocktails, all-you-can-eat dining, poolside service—y" + }, + { + "id": "7618030206959340818", + "username": "chris724santos", + "title": "Spoiler alert: Paradise is actually affordable. Cancun all-inclusive resorts bundle meals, drinks, pools, and beach access into one simple p" + }, + { + "id": "7616168374267055378", + "username": "chris724santos", + "title": "Calgarians, here’s something better than your morning coffee… Waking up in Cancun to ocean breezes, breakfast buffets, and unlimited resort " + }, + { + "id": "7616166257783606535", + "username": "chris724santos", + "title": "Easterners, warning: This might cause you to pack your bags. Cancun offers all-inclusive resorts with 24/7 food, drinks, entertainment, and " + }, + { + "id": "7616163461604609287", + "username": "chris724santos", + "title": "Ottawans, your future self will thank you for hearing this. A Cancun all-inclusive stay means zero planning, zero stress—just sunsets, warm " + }, + { + "id": "7616159717517167879", + "username": "chris724santos", + "title": "New Yorkers, stop scrolling if you need a break from reality. Imagine unlimited cocktails, poolside vibes, and white-sand beaches—all packag" + }, + { + "id": "7616157825328254215", + "username": "chris724santos", + "title": "Hey If you could be anywhere right now… why not Cancun? All-inclusive means unlimited meals, drinks, pools, and beach access—no extra planni" + }, + { + "id": "7615802426007801096", + "username": "chris724santos", + "title": "Americans and Canadians, ever wanted to feel like a VIP without paying VIP prices? Cancun’s all-inclusive packages give you luxury—beach acc" + }, + { + "id": "7615798501636672776", + "username": "chris724santos", + "title": "Canadians and US, this is your sign to take that vacation you’ve been putting off. Cancun all-inclusive resorts take care of everything: foo" + }, + { + "id": "7615795344672132360", + "username": "chris724santos", + "title": "Americans, cancel your weekend plans… because I just found something better. Picture white-sand beaches, unlimited cocktails, poolside servi" + }, + { + "id": "7615793545781267730", + "username": "chris724santos", + "title": "What if your next vacation didn’t require planning anything at all? In Cancun’s all-inclusive resorts, you get meals, drinks, activities, an" + }, + { + "id": "7615791481458117895", + "username": "chris724santos", + "title": "US and CA, Imagine waking up in paradise where your only job is to relax… From unlimited food & drinks to beachfront pools and daily enterta" + }, + { + "id": "7615454710916533511", + "username": "chris724santos", + "title": "Americans, Need a break? If you want sun + sand? Think Cancun! Cancún gives you luxury vibes without the price, super affordable! Message me" + }, + { + "id": "7615452362475392264", + "username": "chris724santos", + "title": "Canadians, If you’ve been thinking about taking a break, Cancún is the perfect place to make it happen. Beautiful beaches, warm weather, gre" + }, + { + "id": "7615450387285331208", + "username": "chris724santos", + "title": "Sometimes you don’t need a long plan — just the right destination. And for many, that’s Cancún. This promo covers a comfortable resort stay," + }, + { + "id": "7615448674025442567", + "username": "chris724santos", + "title": "Looking for a destination that’s worth every minute of your vacation time? Cancún is always a solid choice. You get beaches, culture, nightl" + }, + { + "id": "7615447194316885256", + "username": "chris724santos", + "title": "Picture yourself waking up to turquoise water and warm ocean breeze — that’s Cancún. This offer gives you resort access, pools, entertainmen" + }, + { + "id": "7615066189110185234", + "username": "chris724santos", + "title": "US and Canadians, Sometimes you just need a break from everything. And that break looks like a beachfront resort in Cancun, warm weather, an" + }, + { + "id": "7615063282382146824", + "username": "chris724santos", + "title": "Canadians and US, You just booked a trip to Cancun. You start imagining the beaches, the sunsets, the resort pools, and the feeling of final" + }, + { + "id": "7615061277148187922", + "username": "chris724santos", + "title": "US and Canadian, This is your sign to take a vacation. Cancun beaches, ocean views, and a few days away from the daily routine. Message me o" + }, + { + "id": "7615059773691546887", + "username": "chris724santos", + "title": "Hey, If someone offered you a 4-day resort stay in Cancun, would you take it? Because honestly… most people would say yes. DM or comment Can" + }, + { + "id": "7615058166895709458", + "username": "chris724santos", + "title": "US and Canadians, Imagine spending a few days in Cancun instead of being stuck at work. Honestly… everyone deserves a vacation like this at " + }, + { + "id": "7613982935712107794", + "username": "chris724santos", + "title": "US and Canadians, if you’ve never been to Cancun… this is your chance. You might want to check out these resort stays we still have availabl" + }, + { + "id": "7613978916574874887", + "username": "chris724santos", + "title": "Canadians and US, Stop scrolling if you love beach vacations. We currently have Cancun resort stays available. Send a message and I’ll expla" + }, + { + "id": "7613977546417310983", + "username": "chris724santos", + "title": "Cancun vacations are expensive… unless you know about this. US and Canadians, We currently have discounted resort stays available. Outro: “C" + }, + { + "id": "7613976159252008199", + "username": "chris724santos", + "title": "Canadians and US, If Cancun has been on your bucket list… this is your sign. We still have discounted stays at beachfront resorts in Cancun," + }, + { + "id": "7613974424663952658", + "username": "chris724santos", + "title": "US and Canadians, Planning a trip to Mexico this year? We have Cancun resort stays available for a limited time. Send me a quick message bef" + }, + { + "id": "7613613311300504850", + "username": "chris724santos", + "title": "Americans and Canadians… if you need a warm escape, listen to this. We’re offering a Cancun resort getaway with beautiful beaches and relaxi" + }, + { + "id": "7613611646375693576", + "username": "chris724santos", + "title": "Canadians and US, if Cancun is on your travel list, listen to this. We’re helping travelers enjoy resort stays near the beach. DM or comment" + }, + { + "id": "7613609941160529160", + "username": "chris724santos", + "title": "Hey, thinking about a tropical vacation this year? Cancun offers stunning beaches and relaxing resort experiences. Comment ‘INFO’ and I’ll m" + }, + { + "id": "7613608501083917576", + "username": "chris724santos", + "title": "US and Canadians, here’s a quick vacation idea for you. A relaxing Cancun resort stay where you can enjoy the beach and sunshine. Message me" + }, + { + "id": "7613607211008363784", + "username": "chris724santos", + "title": "Still deciding where to go for your next vacation? Cancun might be the perfect choice—tropical beaches and amazing resort stays. Comment ‘CA" + }, + { + "id": "7613185644747164935", + "username": "chris724santos", + "title": "Canadians and US, let’s turn your dream beach trip into reality! We have discounted Cancun packages for couples, families, or solo travelers" + }, + { + "id": "7613183865204641031", + "username": "chris724santos", + "title": "Hey! Planning a trip but don’t know where to start? We’ve got Cancun deals that make your vacation simple and affordable. Chat with me and I" + }, + { + "id": "7613182036366478610", + "username": "chris724santos", + "title": "Got 15 seconds? Let me show you your next vacation. Cancun—beautiful beaches, great food, and affordable promo stays. Message me now for the" + }, + { + "id": "7613180263983549703", + "username": "chris724santos", + "title": "US and Canadians, you deserve a break, don’t you? We've got amazing discounted stays in Cancun with flexible dates. Send me a quick message " + }, + { + "id": "7613178679929228552", + "username": "chris724santos", + "title": "Hey! When was the last time you treated yourself to a real vacation? Come enjoy Cancun’s beaches and top-notch resorts with our promo packag" + }, + { + "id": "7612818180699852050", + "username": "chris724santos", + "title": "Canadian and US travelers, need a warm reset? Cancun is calling your name. With an all-inclusive food, drinks, pools, and upgraded safety fo" + }, + { + "id": "7612815416146103570", + "username": "chris724santos", + "title": "Hi there US and Canadians, it’s the perfect time for a warm break—sun, sand, and all-inclusive comfort. Enjoy unlimited food and drinks, res" + }, + { + "id": "7612813070410337554", + "username": "chris724santos", + "title": "From the US and Canada to pure tropical bliss—your next getaway starts now. Everything is covered. Escape the cold and enjoy crystal-clear w" + }, + { + "id": "7612811446468005138", + "username": "chris724santos", + "title": "Hey Canadians and US, if you want beaches, luxury, and stress-free travel, this is it. Krystal Grand Cancun, includes full amenities, daily " + }, + { + "id": "7611704941241060626", + "username": "chris724santos", + "title": "Hey friends from Canada and US! Ready to make new memories? Cancún is one of the top beach destinations waiting for you. With an all-inclusi" + }, + { + "id": "7611702983197314311", + "username": "chris724santos", + "title": "Feeling stressed lately? Maybe it’s time to unwind in Cancún, where every day feels like summer. Our all-inclusive package covers food, drin" + }, + { + "id": "7611700828247706887", + "username": "chris724santos", + "title": "Hey US ans Canadians! Your next unforgettable vacation starts the moment you land in Cancún. Relax at an all-inclusive resort with unlimited" + }, + { + "id": "7611698597385522440", + "username": "chris724santos", + "title": "Ready for a warm escape? Your dream getaway starts in Cancún, one of the top beach destinations in Mexico. Stay in an all-inclusive resort w" + }, + { + "id": "7611694991936736530", + "username": "chris724santos", + "title": "Hey there! If you’ve been craving sunshine and turquoise waters, Cancún is calling your name. Enjoy a full all-inclusive experience—unlimite" + }, + { + "id": "7611334414362004743", + "username": "chris724santos", + "title": "US and Canadian travelers—looking for a safe destination with crystal-blue waters? Stay at an all-inclusive resort with unlimited food, unli" + }, + { + "id": "7611332584718568711", + "username": "chris724santos", + "title": "Hey, If you're flying from Canada or the US, Cancún is still one of the top safe zones for tourist vacations, especially inside the resort a" + }, + { + "id": "7611330525692841224", + "username": "chris724santos", + "title": "To my US and Canadian travelers—your next safe and sunny escape is waiting in Cancun! Enjoy stress-free all-inclusive perks: gourmet meals, " + }, + { + "id": "7611328364732845320", + "username": "chris724santos", + "title": "Whether you're coming from Canada or the US, you deserve a getaway! All-inclusive resorts here give you unlimited dining, open bars, pristin" + }, + { + "id": "7611326035031788818", + "username": "chris724santos", + "title": "Calling all travelers from the US and Canada - dreaming of a tropical escape. Imagine staying in an all-inclusive resort where your food, dr" + }, + { + "id": "7610608531678530823", + "username": "chris724santos", + "title": "Imagine staying right on the beach, with warm waters, full amenities, and restaurants just steps away. Shall we go ahead and confirm your sp" + }, + { + "id": "7610594867172920584", + "username": "chris724santos", + "title": "Hi! I’ll keep this quick—I’ve got a Cancun vacation offer that’s too good not to share with you. If this sounds good so far, we can lock it " + }, + { + "id": "7610582097748577544", + "username": "chris724santos", + "title": "Hi! Quick question—have you ever imagined waking up to crystal-blue beaches in Cancun? We’re talking about staying in a beachfront resort—wh" + }, + { + "id": "7610228483909782791", + "username": "chris724santos", + "title": "Cold front in Ontario? Heatwave in Arizona? Cancun stays perfect all year round. A luxury vacation doesn’t have to be expensive—especially i" + }, + { + "id": "7610226305556221191", + "username": "chris724santos", + "title": "Say yes to sun-soaked mornings and unforgettable nights in Cancun. Cancun is the escape you deserve. PM or comment CANCUN! Toll-Free (US & C" + }, + { + "id": "7610220494742211858", + "username": "chris724santos", + "title": "Whether you’re near the mountains of Alberta or the plains of Kansas, Cancun is your chance to switch scenery instantly. What are you waitin" + }, + { + "id": "7610214043680345352", + "username": "chris724santos", + "title": "From Montreal to California, travelers are picking Cancun for the beaches, the food, and the crystal-clear water. Pack light. Relax heavy. C" + }, + { + "id": "7610205552127266066", + "username": "chris724santos", + "title": "Canadian winters hitting hard? Ready to trade your routine for turquoise waters? Just pm or comment Cancun! ☀︎.⋆ 𖤓 Toll-Free (US & Canada)" + }, + { + "id": "7609117013834845447", + "username": "chris724santos", + "title": "Calgary folks, quick vacation notice! We still have discounted vacation package for you yall. Message me or comment CANCUN! ☀︎.⋆ 𖤓 Toll-Fr" + }, + { + "id": "7609115330388053256", + "username": "chris724santos", + "title": "Miami travelers, imagine a private balcony right inside your bedroom. These bedrooms? Pure hotel-goals. DM or message CANCUN now! 𖤓 。𖦹°‧ ⋆" + }, + { + "id": "7609113519203667218", + "username": "chris724santos", + "title": "Good day, Montreal people — listen to this. We’re helping families secure their discounted vacation this year. Feel free to message or comme" + }, + { + "id": "7609100427375725832", + "username": "chris724santos", + "title": "Hey California! Quick vacation heads-up! I have a vacation package promo still open for you! Message me or comment CANCUN right now to take " + }, + { + "id": "7609098109934767367", + "username": "chris724santos", + "title": "Toronto! Listen up, this one’s for you. Your promo slot is still open — just letting you know. Message me or comment CANCUN! °❀⋆.ೃ #TorontoT" + }, + { + "id": "7609095709073263880", + "username": "chris724santos", + "title": "Ottawa people, hear me real quick. We still have your promo rate available if you want to grab it. Feel free to DM or comment Cancun. #Cancu" + }, + { + "id": "7608741747623070983", + "username": "chris724santos", + "title": "No matter if you're in Florida or New York, I’m ready when you are. Travelers from any state can avail this! Just send me a quick message to" + }, + { + "id": "7608739596989451527", + "username": "chris724santos", + "title": "New Yorkers, if you need a break from the city, listen to this. Imagine a vacation that doesn’t break your budget. Let me know once you’re r" + }, + { + "id": "7608727936786648338", + "username": "chris724santos", + "title": "Stop searching—your next getaway might be right here. If you’re planning a trip soon, this might be exactly what you need. DM or comment Cab" + }, + { + "id": "7608725450415770888", + "username": "chris724santos", + "title": "If affordable luxury exists, this is it. A lot of people are asking about this. I’m just a message away whenever you’re ready to proceed. Co" + }, + { + "id": "7608360935576603922", + "username": "chris724santos", + "title": "If you’re thinking of Riviera Maya this year… watch this first. Look at what’s already included in the price. Slots are limited — secure you" + }, + { + "id": "7605030386606951687", + "username": "chris724santos", + "title": "Picture this—sitting by the shore in Cancun, watching the sky turn gold and pink 🌅 Mexico sunsets hit different! Wanna experience this, wit" + }, + { + "id": "7605027310227426567", + "username": "chris724santos", + "title": "Wait—before you scroll, imagine yourself in Riviera Maya for a few days… but without spending too much. We’ve got an all-inclusive vacation " + }, + { + "id": "7605014829023251719", + "username": "chris724santos", + "title": "Looking for a budget-friendly getaway? Puerto Vallarta got you. All-inclusive vacation. We work directly with the resorts, so you’ll get ver" + }, + { + "id": "7605011840698944786", + "username": "chris724santos", + "title": "POV: You’re finally taking that trip you’ve been putting off forever. 🌴✨ Imagine waking up to ocean views, unlimited food and drinks, and a" + }, + { + "id": "7605009292281859335", + "username": "chris724santos", + "title": "🌴 Hey! If you’ve been dreaming of a quick escape, Cancun is calling your name. I’ve got an all-inclusive vacation package you might love D" + }, + { + "id": "7603919277430721810", + "username": "chris724santos", + "title": "Cancun? With an all-inclusive deal? Yes, please. We’re offering a full package that covers everything. “Food, drinks, hotel, beach access — " + }, + { + "id": "7603916425044036882", + "username": "chris724santos", + "title": "What if your next reset moment looked like this Hello, Cabo Let me show you a peaceful, all-inclusive escape You get ocean views, pools, unl" + }, + { + "id": "7603907885462080775", + "username": "chris724santos", + "title": "STOP scrolling — Riviera Maya might be cheaper than your last weekend out. Serious. This all-inclusive package is actually doable. From whit" + }, + { + "id": "7603905329964895495", + "username": "chris724santos", + "title": "POV: You’re tired of adulting and Cancun is calling your name. So here’s a quick look at this all-inclusive escape. 5 days, 4 nights. Beachf" + }, + { + "id": "7603547720111901970", + "username": "chris724santos", + "title": "Ready for a break? Cancun, Mexico has everything you need for the perfect getaway! 🌴 All-inclusive means zero stress — eat, drink, relax, a" + }, + { + "id": "7603541240449223943", + "username": "chris724santos", + "title": "Escape the daily grind… Puerto Vallarta is your paradise! 🏖️ All-inclusive fun means you can relax and enjoy every moment — no planning req" + }, + { + "id": "7603533611102424328", + "username": "chris724santos", + "title": "Who’s ready for some sunshine? ☀️ Riviera Maya, Mexico is waiting! All your meals, drinks, and activities are included. Just show up and enj" + }, + { + "id": "7603529785150672146", + "username": "chris724santos", + "title": "Hey! Planning your next getaway? 🌴 Imagine yourself in Cancun, Mexico — sun, sand, and total relaxation all in one trip! This is an all-inc" + }, + { + "id": "7603176846171098375", + "username": "chris724santos", + "title": "⋆.˚ 𓇼 Cancun trip on a budget? Yup, possible. We have an all-inclusive Cancun vacation with a super good rate. Perfect for couples, familie" + }, + { + "id": "7603172717289950472", + "username": "chris724santos", + "title": "CABO 2026 — who’s ready? ⋆.˚ 𓇼 We have an all-inclusive Cabo vacation with one of the best resort experiences in Mexico. 5 days, 4 nights. " + }, + { + "id": "7603170263777201416", + "username": "chris724santos", + "title": "Looking for your next escape? Try Riviera Maya. ⋆.˚ 𓇼 We’re offering an all-inclusive vacation in Riviera Maya — one of the most beautiful " + }, + { + "id": "7603162462015245576", + "username": "chris724santos", + "title": "Stop waiting for the ‘perfect time’ — your Cancun trip could literally start here. I’m helping people get an all-inclusive Cancun vacation f" + }, + { + "id": "7603159313737190664", + "username": "chris724santos", + "title": "We have an all-inclusive Mexico vacation package perfect for couples, families, or friends. ⋆.˚ 𓇼 5 days, 4 nights. Unlimited food, unlimit" + }, + { + "id": "7603155425307184402", + "username": "chris724santos", + "title": "POV: You’re about to unlock a Cancun vacation you didn’t know was possible. Hey! If you’ve been dreaming of white-sand beaches, bottomless d" + }, + { + "id": "7602808291353201938", + "username": "chris724santos", + "title": "No plans this year? No problem! 𓇼 ⋆.˚ 𓆉 𓆝 𓆡 Cancun all-inclusive: sun, food, pools, fun — done! Just relax and let the vacation do its m" + }, + { + "id": "7602801434245074194", + "username": "chris724santos", + "title": "Cancun hack: do less, enjoy more. °𓇼🌊⋆🐚 Think beaches, fun, and nonstop sunshine Let’s make it happen! °𓇼🌊⋆🐚 Just show up and enjoy! #" + }, + { + "id": "7602797860169567506", + "username": "chris724santos", + "title": "Pack your bags… paradise awaits! 🌊⋆🐚🫧🥥🌴 Think turquoise waters, endless sun, and all-inclusive treats — cocktails, food, pools, and mor" + }, + { + "id": "7602793251241856263", + "username": "chris724santos", + "title": "Paradise isn’t a place… it’s Cancun! 𓆝 𓆡⋆.˚ 𓇼 Sun, fun, and zero worries… who’s in? 𓆝 𓆡⋆.˚ 𓇼 All-inclusive vibes, just show up and enj" + }, + { + "id": "7602788687109328135", + "username": "chris724santos", + "title": "Stress-free mode: ON. 🌺🌅🌊𓇼 ⋆. Let me show you how! Think all-inclusive means boring? Think again! Cancun has it all — stunning beaches, " + }, + { + "id": "7602783687364578567", + "username": "chris724santos", + "title": "Ever seen water THIS blue? 😍 Cancun is calling! 🐚🫧🥥🌴 Stay at an all-inclusive resort, sip cocktails by the pool, and enjoy endless sun " + }, + { + "id": "7602436296635632904", + "username": "chris724santos", + "title": "It’s the kind of trip where your only problem is choosing between the pool or the beach! 𓆉°❀⋆.ೃ࿔*:・ ˚⋆𓇼˚⊹ If Mexico is on your wishlist, I" + }, + { + "id": "7602432924708834568", + "username": "chris724santos", + "title": "Hold up! Look how relaxing this is! You literally wake up, eat, swim, relax, repeat! No planning needed! If you ever needed a sign to plan a" + }, + { + "id": "7602427669245889800", + "username": "chris724santos", + "title": "Let’s manifest good vibes today… starting with this view! 𓆉°❀⋆.ೃ࿔*:・ ˚⋆𓇼˚⊹ Perfect for couples, families, friends—just pure good vibes! 𓆉" + }, + { + "id": "7602422161747283207", + "username": "chris724santos", + "title": "Real talk… we all deserve a soft life moment, so check this out! Everything is inside the resort—food spots, bars, pools, activities. You li" + }, + { + "id": "7602416103356239111", + "username": "chris724santos", + "title": "If you need a sign to take a vacation… this is it! 𓆉°❀⋆.ೃ࿔*:・ ˚⋆𓇼˚⊹ Picture this: pool in the morning, beach in the afternoon, shows at ni" + }, + { + "id": "7602412252343438599", + "username": "chris724santos", + "title": "POV: You just need a break… so here’s Mexico. 𖤓 ⋆˚࿔⋆.˚ 𓇼 Mexico has insane all-inclusive resorts… unlimited food, unlimited drinks, unlimi" + }, + { + "id": "7601324989383527698", + "username": "chris724santos", + "title": "If travel heals you…🥥🌴🌺🌅🌊𖤓 。𖦹°‧ ⋆☀︎.⋆ 𖤓 ⋆˚࿔⋆.˚ 𓇼 SAME!!!! There’s something about Cancun that just refreshes your whole soul. Maybe" + }, + { + "id": "7601322386176134407", + "username": "chris724santos", + "title": "Imagine being here right now… 🌻🏝️🕶️👕🌴☀️ Quiet beaches, good food, and days that just flow. Cancun feels like a reset button. One day, y" + }, + { + "id": "7601315168517180679", + "username": "chris724santos", + "title": "If you’ve been craving a breather… ☀︎.⋆ 𖤓 ⋆˚࿔⋆.˚ 𓇼 Cancun is that place where time slows down and everything feels lighter. Adding this to" + }, + { + "id": "7601306996272270599", + "username": "chris724santos", + "title": "Hey, you deserve a break. 🥥🌴🌺🌅🌊 Let me bring you to Cancun Beaches, sunsets, and unlimited fun. If you want the package, just DM me “CA" + }, + { + "id": "7601301018957106440", + "username": "chris724santos", + "title": "Cancun? Yes please. 🌴 ₊✩‧₊˚౨ৎ˚₊✩‧₊ ˚⟡˖ All-inclusive vacation for you and your loved ones — food, drinks, activities, everything! ₊✩‧₊˚౨ৎ˚₊" + }, + { + "id": "7601298265182637320", + "username": "chris724santos", + "title": "Tired of the same routine? Take a break in Cancun! All-inclusive food, drinks, and 5 days of purefun. DM me “MEXICO” for the details! ✈️🔥 " + }, + { + "id": "7600953943153331463", + "username": "chris724santos", + "title": "Stop scrolling — this is your next vacation 😎🌅 Travel smart and save more with this all-inclusive deal 🔥✈️ Let’s book your Cancun trip to" + }, + { + "id": "7600949627860307208", + "username": "chris724santos", + "title": "Stop dreaming, start packing! Cancun awaits!! 😎🧳 Family-friendly + budget-friendly = perfect deal!! 🔥 Ready to make memories in Mexico? M" + }, + { + "id": "7600940014209371400", + "username": "chris724santos", + "title": "Who wants Cancun for 5 days? Stay, dine, swim, enjoy! Promo won’t last long — DM me now °❀⋆.ೃ࿔*:・ ˚ ༘ #724vacation #cancunmexico #alliinclus" + }, + { + "id": "7600936171354557704", + "username": "chris724santos", + "title": "All-inclusive package: food, drinks, pools, hotel… everything covered! DM me to start planning your 5D4N Mexico adventure! #724vacation #can" + }, + { + "id": "7600931800495475975", + "username": "chris724santos", + "title": "Getaway vacation!!! ALERT!!! Just pure vacation mode!!! Limited time offer!!! Secure yours now!!! #724vacation #cancunmexico #familyvacation" + }, + { + "id": "7600928850821860626", + "username": "chris724santos", + "title": "Sun, sand & unlimited drinks? Cancun is calling!!! #724vacation #cancunmexico #familyvacation #CapCut " + }, + { + "id": "7600582664717913351", + "username": "chris724santos", + "title": "Craving a beach escape? Mexico has white-sand beaches and amazing resorts waiting for you! Let’s plan your trip today! 🌸🌺🌝💥☀️ #724vacati" + }, + { + "id": "7600579435082812679", + "username": "chris724santos", + "title": "Ready for paradise? Let Mexico take your stress away. Let’s get you booked! 🍷🍾🍹☕️🫧🌸 #724vacation #cancunmexico #familyvacation #allincl" + }, + { + "id": "7600574564598385928", + "username": "chris724santos", + "title": "Looking for your next destination? Mexico has everything—beach, culture, food, and unforgettable moments. Your Mexican adventure starts here" + }, + { + "id": "7600568620103650578", + "username": "chris724santos", + "title": "Hey Travelers!!! Mexico is the best place to relax!!! DM Cancun!!! ☀️☀️ #724vacation #cancunmexico #alliinclusive " + }, + { + "id": "7600564652304518418", + "username": "chris724santos", + "title": "Need a break from stress? Fly to Mexico! Plan your trip today! DM Cancun! 🐚😎 #724vacation #cancunmexico #alliinclusive #familyvacation " + }, + { + "id": "7600559846118690055", + "username": "chris724santos", + "title": "Dreaming of a sunny escape? DM Cancun! ☀️🕶️ #cancunmexico #724vacation #alliinclusive " + }, + { + "id": "7600210549238009106", + "username": "chris724santos", + "title": "Cancun is waiting for you!!! DM meeee!!!! #cancunmexico #724vacation #alliinclusive " + }, + { + "id": "7600205520087043335", + "username": "chris724santos", + "title": "Want a luxury Cancun getaway? #cancunmexico #724vacation #familyvacation " + }, + { + "id": "7600194855343541522", + "username": "chris724santos", + "title": "Cancun is calling!!! ⛱️🐚 #cancunmexico #familyvacation #724vacation " + }, + { + "id": "7600189653106380039", + "username": "chris724santos", + "title": "Imagine yourself here in Cancun! 🐚 #cancunmexico #familyvacation #724vacation " + }, + { + "id": "7599836054589394194", + "username": "chris724santos", + "title": "Dreaming of a getaway? DM us Cancun #cancunmexico #724vacation #familyvacation " + }, + { + "id": "7599823703538453768", + "username": "chris724santos", + "title": "All Inclusive Vacation, DM us CANCUN #cancunmexico #724vacation #familyvacation " + }, + { + "id": "7599817155001044231", + "username": "chris724santos", + "title": "Cancun Vacation getaway #cancunmexico #724vacation #allinclusive " + } +] \ No newline at end of file diff --git a/src/hooks/useEarlyLead.ts b/src/hooks/useEarlyLead.ts new file mode 100644 index 0000000..3e8b238 --- /dev/null +++ b/src/hooks/useEarlyLead.ts @@ -0,0 +1,74 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +interface CapturePayload { + email: string + phone?: string + name?: string + source_lp?: string + referral_code?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string +} + +interface UseEarlyLeadOptions { + debounceMs?: number +} + +/** + * Fire-and-forget early lead capture: posts to /api/track/lead the moment a + * valid email is detected, again whenever the payload changes (debounced). + * Used so we never lose a lead to abandoned-cart / abandoned-form behavior. + * + * Returns { captured, capture } — `captured` flips to true once the server + * has acknowledged at least one valid payload; `capture` is the manual + * trigger (call from onBlur for instant-on-blur capture in addition to the + * debounced auto-trigger). + */ +export function useEarlyLead(payload: CapturePayload, options: UseEarlyLeadOptions = {}) { + const { debounceMs = 800 } = options + const [captured, setCaptured] = useState(false) + const lastSentRef = useRef('') + const inFlightRef = useRef(false) + const timerRef = useRef | null>(null) + + const send = useCallback(async (p: CapturePayload) => { + if (!p.email || !EMAIL_RE.test(p.email)) return + const key = JSON.stringify(p) + if (key === lastSentRef.current || inFlightRef.current) return + inFlightRef.current = true + try { + const res = await fetch('/api/track/lead', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(p), + keepalive: true, + }) + if (res.ok) { + lastSentRef.current = key + setCaptured(true) + } + } catch { + // Silent — capture is best-effort + } finally { + inFlightRef.current = false + } + }, []) + + const payloadKey = JSON.stringify(payload) + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { void send(payload) }, debounceMs) + return () => { if (timerRef.current) clearTimeout(timerRef.current) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [payloadKey, debounceMs]) + + const capture = useCallback(() => { void send(payload) }, [payloadKey, send]) // eslint-disable-line react-hooks/exhaustive-deps + + return { captured, capture } +} diff --git a/src/hooks/useTrackingParams.ts b/src/hooks/useTrackingParams.ts new file mode 100644 index 0000000..db35de4 --- /dev/null +++ b/src/hooks/useTrackingParams.ts @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useState } from 'react' + +export interface TrackingParams { + ref?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string + source_lp?: string +} + +const STORAGE_KEY = 'hi2b_tracking' + +export function useTrackingParams(sourceLp?: string): TrackingParams { + const [params, setParams] = useState({}) + + useEffect(() => { + // Read from URL + const url = new URL(window.location.href) + const ref = url.searchParams.get('ref') || undefined + const utm_source = url.searchParams.get('utm_source') || undefined + const utm_medium = url.searchParams.get('utm_medium') || undefined + const utm_campaign = url.searchParams.get('utm_campaign') || undefined + + // Merge with sessionStorage (URL params take priority) + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}') + const merged: TrackingParams = { + ref: ref || stored.ref, + utm_source: utm_source || stored.utm_source, + utm_medium: utm_medium || stored.utm_medium, + utm_campaign: utm_campaign || stored.utm_campaign, + source_lp: sourceLp || stored.source_lp, + } + + // Persist + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(merged)) + setParams(merged) + + // Record page view + if (sourceLp) { + fetch('/api/track/pageview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + page_slug: sourceLp, + referral_code: merged.ref, + utm_source: merged.utm_source, + utm_medium: merged.utm_medium, + utm_campaign: merged.utm_campaign, + }), + }).catch(() => {}) + } + }, [sourceLp]) + + return params +} diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts new file mode 100644 index 0000000..43b883a --- /dev/null +++ b/src/lib/admin-auth.ts @@ -0,0 +1,72 @@ +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { cookies } from 'next/headers' +import { randomBytes, createHash } from 'crypto' + +if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { + throw new Error('JWT_SECRET env var is required (≥32 chars)') +} +const BASE_SECRET: string = process.env.JWT_SECRET +const ADMIN_SECRET = createHash('sha256').update(BASE_SECRET + '::admin').digest('hex') +const AFFILIATE_SECRET = createHash('sha256').update(BASE_SECRET + '::affiliate').digest('hex') + +// ─── Admin Auth ──────────────────────────────────────────── + +export function createAdminToken(payload: { id: number; email: string; role: string }): string { + return jwt.sign({ ...payload, type: 'admin' }, ADMIN_SECRET, { expiresIn: '24h' }) +} + +export function verifyAdminToken(token: string): { id: number; email: string; role: string } | null { + try { + const decoded = jwt.verify(token, ADMIN_SECRET) as any + if (decoded.type !== 'admin') return null + return decoded + } catch { return null } +} + +export async function getAdminSession() { + const cookieStore = await cookies() + const token = cookieStore.get('admin_token')?.value + if (!token) return null + return verifyAdminToken(token) +} + +// ─── Affiliate Auth ──────────────────────────────────────── + +export function createAffiliateToken(payload: { id: number; email: string; referral_code: string }): string { + return jwt.sign({ ...payload, type: 'affiliate' }, AFFILIATE_SECRET, { expiresIn: '7d' }) +} + +export function verifyAffiliateToken(token: string): { id: number; email: string; referral_code: string } | null { + try { + const decoded = jwt.verify(token, AFFILIATE_SECRET) as any + if (decoded.type !== 'affiliate') return null + return decoded + } catch { return null } +} + +export async function getAffiliateSession() { + const cookieStore = await cookies() + const token = cookieStore.get('affiliate_token')?.value + if (!token) return null + return verifyAffiliateToken(token) +} + +// ─── Shared ──────────────────────────────────────────────── + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 12) +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash) +} + +export function generateReferralCode(name: string): string { + const prefix = name.replace(/[^a-zA-Z]/g, '').substring(0, 3).toUpperCase() || 'AFF' + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const bytes = randomBytes(4) + let suffix = '' + for (let i = 0; i < 4; i++) suffix += chars[bytes[i] % chars.length] + return `${prefix}${suffix}` +} diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts new file mode 100644 index 0000000..78a68e0 --- /dev/null +++ b/src/lib/auth-utils.ts @@ -0,0 +1,48 @@ +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { cookies } from 'next/headers' +import { randomBytes } from 'crypto' + +if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { + throw new Error('JWT_SECRET env var is required (≥32 chars)') +} +const JWT_SECRET: string = process.env.JWT_SECRET +const TOKEN_EXPIRY = '7d' + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 12) +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash) +} + +export function createToken(payload: { id: number; email: string }): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }) +} + +export function verifyToken(token: string): { id: number; email: string } | null { + try { + return jwt.verify(token, JWT_SECRET) as { id: number; email: string } + } catch { + return null + } +} + +export async function getSessionUser(): Promise<{ id: number; email: string } | null> { + const cookieStore = await cookies() + const token = cookieStore.get('auth_token')?.value + if (!token) return null + return verifyToken(token) +} + +export function generateResetToken(): string { + return randomBytes(32).toString('hex') +} + +export function generateCertificateNumber(): string { + const prefix = 'MPV' + const year = new Date().getFullYear() + const random = randomBytes(4).toString('hex').toUpperCase().slice(0, 6) + return `${prefix}-${year}-${random}` +} diff --git a/src/lib/db-admin.ts b/src/lib/db-admin.ts new file mode 100644 index 0000000..d351b1d --- /dev/null +++ b/src/lib/db-admin.ts @@ -0,0 +1,197 @@ +import pool from './db-mysql' + +// ─── Admin Stats ─────────────────────────────────────────── + +export async function getKPIStats() { + const [salesRow] = await pool.execute( + `SELECT COUNT(*) as total_sales, COALESCE(SUM(amount),0) as total_revenue + FROM signups WHERE payment_status IN ('active','completed')` + ) as any[] + + const [mrrRow] = await pool.execute( + `SELECT COUNT(*) * 29 as mrr FROM signups WHERE payment_status = 'active' AND payment_plan_months > 1` + ) as any[] + + const [leadsRow] = await pool.execute(`SELECT COUNT(*) as total_leads FROM ebook_leads`) as any[] + + const [viewsRow] = await pool.execute(`SELECT COUNT(*) as total_views FROM page_views`) as any[] + + const totalSales = salesRow[0]?.total_sales || 0 + const totalLeads = leadsRow[0]?.total_leads || 0 + const conversionRate = totalLeads > 0 ? ((totalSales / totalLeads) * 100).toFixed(1) : '0' + + return { + totalSales: salesRow[0]?.total_sales || 0, + totalRevenue: parseFloat(salesRow[0]?.total_revenue || 0), + mrr: mrrRow[0]?.mrr || 0, + totalLeads, + totalViews: viewsRow[0]?.total_views || 0, + conversionRate: parseFloat(conversionRate), + } +} + +// ─── Sales by LP ─────────────────────────────────────────── + +export async function getSalesByLP() { + const [rows] = await pool.execute( + `SELECT COALESCE(source_lp,'unknown') as name, COUNT(*) as count, COALESCE(SUM(amount),0) as revenue + FROM signups WHERE payment_status IN ('active','completed') + GROUP BY source_lp ORDER BY count DESC LIMIT 20` + ) as any[] + return rows +} + +// ─── Sales by Source ─────────────────────────────────────── + +export async function getSalesBySource() { + const [rows] = await pool.execute( + `SELECT COALESCE(utm_source,'direct') as name, COUNT(*) as count, COALESCE(SUM(amount),0) as revenue + FROM signups WHERE payment_status IN ('active','completed') + GROUP BY utm_source ORDER BY count DESC` + ) as any[] + return rows +} + +// ─── Sales by Affiliate ─────────────────────────────────── + +export async function getSalesByAffiliate() { + const [rows] = await pool.execute( + `SELECT a.name, a.referral_code, COUNT(ar.id) as sales, COALESCE(SUM(ar.commission_amount),0) as total_commission + FROM affiliates a + LEFT JOIN affiliate_referrals ar ON a.id = ar.affiliate_id + GROUP BY a.id ORDER BY sales DESC` + ) as any[] + return rows +} + +// ─── Revenue Over Time ───────────────────────────────────── + +export async function getRevenueOverTime(days: number = 30) { + const [rows] = await pool.execute( + `SELECT DATE(created_at) as date, COUNT(*) as sales, COALESCE(SUM(amount),0) as revenue + FROM signups WHERE payment_status IN ('active','completed') AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY DATE(created_at) ORDER BY date`, + [days] + ) as any[] + return rows +} + +// ─── Funnel Data ─────────────────────────────────────────── + +export async function getFunnelData(days: number = 30) { + const since = `DATE_SUB(NOW(), INTERVAL ${days} DAY)` + + const [views] = await pool.execute(`SELECT COUNT(*) as c FROM page_views WHERE created_at >= ${since}`) as any[] + const [leads] = await pool.execute(`SELECT COUNT(*) as c FROM ebook_leads WHERE created_at >= ${since}`) as any[] + const [signups] = await pool.execute(`SELECT COUNT(*) as c FROM signups WHERE created_at >= ${since}`) as any[] + const [paid] = await pool.execute(`SELECT COUNT(*) as c FROM signups WHERE payment_status IN ('active','completed') AND created_at >= ${since}`) as any[] + + return { + pageViews: views[0]?.c || 0, + ebookDownloads: leads[0]?.c || 0, + signups: signups[0]?.c || 0, + paidCustomers: paid[0]?.c || 0, + } +} + +// ─── Paginated Sales ─────────────────────────────────────── + +export async function getSales(params: { + page?: number + limit?: number + status?: string + source_lp?: string + affiliate_id?: number + utm_source?: string + search?: string +}) { + const { page = 1, limit = 25, status, source_lp, affiliate_id, utm_source, search } = params + const offset = (page - 1) * limit + const conditions: string[] = [] + const values: any[] = [] + + if (status) { conditions.push('s.payment_status = ?'); values.push(status) } + if (source_lp) { conditions.push('s.source_lp = ?'); values.push(source_lp) } + if (affiliate_id) { conditions.push('s.affiliate_id = ?'); values.push(affiliate_id) } + if (utm_source) { conditions.push('s.utm_source = ?'); values.push(utm_source) } + if (search) { conditions.push('(s.email LIKE ? OR s.full_name LIKE ?)'); values.push(`%${search}%`, `%${search}%`) } + + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '' + + const [countRow] = await pool.execute(`SELECT COUNT(*) as total FROM signups s ${where}`, values) as any[] + const total = countRow[0]?.total || 0 + + const [rows] = await pool.execute( + `SELECT s.*, + a.name as affiliate_name, + a.referral_code as aff_code, + (SELECT COUNT(*) FROM payments p + WHERE p.signup_id = s.id AND p.status = 'completed') as payments_made + FROM signups s LEFT JOIN affiliates a ON s.affiliate_id = a.id + ${where} ORDER BY s.created_at DESC LIMIT ? OFFSET ?`, + [...values, limit, offset] + ) as any[] + + return { data: rows, total, page, limit, totalPages: Math.ceil(total / limit) } +} + +// ─── Paginated Leads ─────────────────────────────────────── + +export async function getLeads(params: { page?: number; limit?: number; search?: string }) { + const { page = 1, limit = 25, search } = params + const offset = (page - 1) * limit + const where = search ? 'WHERE email LIKE ? OR name LIKE ?' : '' + const values = search ? [`%${search}%`, `%${search}%`] : [] + + const [countRow] = await pool.execute(`SELECT COUNT(*) as total FROM ebook_leads ${where}`, values) as any[] + const [rows] = await pool.execute( + `SELECT * FROM ebook_leads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, + [...values, limit, offset] + ) as any[] + + return { data: rows, total: countRow[0]?.total || 0, page, limit } +} + +// ─── Affiliate Management ────────────────────────────────── + +export async function getAffiliates() { + const [rows] = await pool.execute( + `SELECT a.*, COUNT(ar.id) as referral_count, COALESCE(SUM(CASE WHEN ar.status != 'rejected' THEN ar.commission_amount ELSE 0 END),0) as earned + FROM affiliates a LEFT JOIN affiliate_referrals ar ON a.id = ar.affiliate_id + GROUP BY a.id ORDER BY a.created_at DESC` + ) as any[] + return rows +} + +export async function updateAffiliateStatus(id: number, status: string) { + await pool.execute('UPDATE affiliates SET status = ? WHERE id = ?', [status, id]) +} + +// ─── Payouts ─────────────────────────────────────────────── + +export async function getPayouts() { + const [rows] = await pool.execute( + `SELECT ap.*, a.name as affiliate_name, a.email as affiliate_email + FROM affiliate_payouts ap JOIN affiliates a ON ap.affiliate_id = a.id + ORDER BY ap.created_at DESC` + ) as any[] + return rows +} + +export async function createPayout(affiliateId: number, amount: number, method: string, reference: string) { + const [result] = await pool.execute( + `INSERT INTO affiliate_payouts (affiliate_id, amount, method, reference, status) VALUES (?, ?, ?, ?, 'completed')`, + [affiliateId, amount, method, reference] + ) as any[] + + // Update affiliate totals + await pool.execute('UPDATE affiliates SET total_paid = total_paid + ? WHERE id = ?', [amount, affiliateId]) + + // Mark referrals as paid + await pool.execute( + `UPDATE affiliate_referrals SET status = 'paid', paid_at = NOW() WHERE affiliate_id = ? AND status = 'approved'`, + [affiliateId] + ) + + return (result as any).insertId +} diff --git a/src/lib/db-mysql.ts b/src/lib/db-mysql.ts new file mode 100644 index 0000000..682ad08 --- /dev/null +++ b/src/lib/db-mysql.ts @@ -0,0 +1,296 @@ +import mysql from 'mysql2/promise' + +const { MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE } = process.env +if (!MYSQL_HOST || !MYSQL_USER || !MYSQL_PASSWORD || !MYSQL_DATABASE) { + throw new Error('MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE env vars are required') +} + +const pool = mysql.createPool({ + host: MYSQL_HOST, + user: MYSQL_USER, + password: MYSQL_PASSWORD, + database: MYSQL_DATABASE, + waitForConnections: true, + connectionLimit: 10, +}) + +export default pool + +// ─── Signups ─────────────────────────────────────────────── + +export async function createSignup(data: { + email: string + full_name?: string | null + phone?: string | null + amount?: number | null + monthly_payment?: number | null + payment_plan_months?: number | null + payment_status?: string | null + source_lp?: string + referral_code?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string +}) { + // Apply sensible defaults so the client dashboard never crashes on nulls. + const full_name = data.full_name ?? '' + const phone = data.phone ?? '' + const amount = data.amount ?? 290 + const monthly_payment = data.monthly_payment ?? 29 + const payment_plan_months = data.payment_plan_months ?? 10 + const payment_status = data.payment_status ?? 'pending' + + const [existing] = await pool.execute('SELECT * FROM signups WHERE email = ?', [data.email]) as any[] + + if (existing.length > 0) { + await pool.execute( + `UPDATE signups SET payment_status = ?, full_name = ?, phone = ?, amount = ?, monthly_payment = ?, + payment_plan_months = ?, source_lp = COALESCE(?, source_lp), referral_code = COALESCE(?, referral_code), + utm_source = COALESCE(?, utm_source), utm_medium = COALESCE(?, utm_medium), utm_campaign = COALESCE(?, utm_campaign) + WHERE email = ?`, + [payment_status, full_name, phone, amount, monthly_payment, payment_plan_months, + data.source_lp || null, data.referral_code || null, + data.utm_source || null, data.utm_medium || null, data.utm_campaign || null, data.email] + ) + // Re-fetch to get the ID + const [updated] = await pool.execute('SELECT * FROM signups WHERE email = ?', [data.email]) as any[] + return { error: null, data: updated[0] } + } + + // Look up affiliate by referral code + let affiliateId: number | null = null + if (data.referral_code) { + const [aff] = await pool.execute('SELECT id FROM affiliates WHERE referral_code = ? AND status = ?', [data.referral_code, 'active']) as any[] + if (aff.length > 0) affiliateId = aff[0].id + } + + const [result] = await pool.execute( + `INSERT INTO signups (email, full_name, phone, amount, monthly_payment, payment_plan_months, payment_status, + source_lp, affiliate_id, referral_code, utm_source, utm_medium, utm_campaign) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [data.email, full_name, phone, amount, monthly_payment, payment_plan_months, payment_status, + data.source_lp || null, affiliateId, data.referral_code || null, + data.utm_source || null, data.utm_medium || null, data.utm_campaign || null] + ) as any[] + + const [rows] = await pool.execute('SELECT * FROM signups WHERE id = ?', [(result as any).insertId]) as any[] + return { error: null, data: rows[0] } +} + +// Strict whitelist of columns that updateSignup is allowed to modify. Any key +// outside this set is rejected to prevent SQL injection via attacker-controlled +// object keys. +const UPDATE_SIGNUP_ALLOWED_COLUMNS = new Set([ + 'full_name', 'phone', 'amount', 'monthly_payment', 'payment_plan_months', + 'payment_status', 'certificate_number', 'certificate_expires', 'subscription_id', + 'source_lp', 'affiliate_id', 'referral_code', 'utm_source', 'utm_medium', + 'utm_campaign', 'password_hash', 'reset_token', 'reset_token_expires', 'destination', +]) + +export async function updateSignup(id: number, updates: Record) { + const keys = Object.keys(updates) + for (const key of keys) { + if (!UPDATE_SIGNUP_ALLOWED_COLUMNS.has(key)) { + throw new Error(`updateSignup: disallowed column "${key}"`) + } + } + if (keys.length === 0) return + const fields = keys.map(k => `${k} = ?`).join(', ') + const values = keys.map(k => updates[k]) + await pool.execute(`UPDATE signups SET ${fields} WHERE id = ?`, [...values, id]) +} + +// ─── Payments ────────────────────────────────────────────── + +export async function createPayment(data: { + signup_id: number + payment_method?: string + amount: number + currency?: string + payment_type?: string + status?: string + transaction_id?: string +}) { + const [result] = await pool.execute( + `INSERT INTO payments (signup_id, payment_method, amount, currency, payment_type, status, transaction_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [data.signup_id, data.payment_method || 'nmi', data.amount, data.currency || 'USD', + data.payment_type || 'initial', data.status || 'completed', data.transaction_id || null] + ) as any[] + return { insertId: (result as any).insertId } +} + +// ─── Ebook Leads ─────────────────────────────────────────── + +export async function createEbookLead(data: { + email: string + name?: string + source_lp?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string +}) { + try { + await pool.execute( + `INSERT INTO ebook_leads (email, name, source_lp, utm_source, utm_medium, utm_campaign) VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE source_lp = COALESCE(VALUES(source_lp), source_lp), + utm_source = COALESCE(VALUES(utm_source), utm_source)`, + [data.email, data.name || null, data.source_lp || 'unknown', + data.utm_source || null, data.utm_medium || null, data.utm_campaign || null] + ) + return { error: null } + } catch (err) { + return { error: err } + } +} + +// ─── Early Leads (debounced input capture, pre-submit) ───── + +let earlyLeadsTableReady: Promise | null = null +function ensureEarlyLeadsTable(): Promise { + if (!earlyLeadsTableReady) { + earlyLeadsTableReady = pool.execute(` + CREATE TABLE IF NOT EXISTS early_leads ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + phone VARCHAR(50), + name VARCHAR(255), + ip_address VARCHAR(64), + source_lp VARCHAR(100), + referral_code VARCHAR(50), + utm_source VARCHAR(100), + utm_medium VARCHAR(100), + utm_campaign VARCHAR(100), + confirmed TINYINT(1) NOT NULL DEFAULT 0, + confirmed_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_confirmed (confirmed), + INDEX idx_created (created_at) + ) + `).then(() => undefined) + } + return earlyLeadsTableReady +} + +export async function upsertEarlyLead(data: { + email: string + phone?: string | null + name?: string | null + ip_address?: string | null + source_lp?: string | null + referral_code?: string | null + utm_source?: string | null + utm_medium?: string | null + utm_campaign?: string | null +}) { + await ensureEarlyLeadsTable() + await pool.execute( + `INSERT INTO early_leads + (email, phone, name, ip_address, source_lp, referral_code, utm_source, utm_medium, utm_campaign) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + phone = COALESCE(VALUES(phone), phone), + name = COALESCE(VALUES(name), name), + ip_address = COALESCE(VALUES(ip_address), ip_address), + source_lp = COALESCE(VALUES(source_lp), source_lp), + referral_code = COALESCE(VALUES(referral_code), referral_code), + utm_source = COALESCE(VALUES(utm_source), utm_source), + utm_medium = COALESCE(VALUES(utm_medium), utm_medium), + utm_campaign = COALESCE(VALUES(utm_campaign), utm_campaign)`, + [data.email, data.phone || null, data.name || null, data.ip_address || null, + data.source_lp || null, data.referral_code || null, + data.utm_source || null, data.utm_medium || null, data.utm_campaign || null] + ) +} + +export async function confirmEarlyLead(email: string) { + await ensureEarlyLeadsTable() + await pool.execute( + 'UPDATE early_leads SET confirmed = 1, confirmed_at = CURRENT_TIMESTAMP WHERE email = ?', + [email] + ) +} + +// ─── Affiliate Short Codes (2-char vanity for hi2b.com/pay/XX) ─ + +let affiliateShortCodeReady: Promise | null = null +function ensureAffiliateShortCodeColumn(): Promise { + if (!affiliateShortCodeReady) { + affiliateShortCodeReady = (async () => { + // Add column if missing — MySQL has no IF NOT EXISTS for ADD COLUMN, so catch dup error + try { + await pool.execute('ALTER TABLE affiliates ADD COLUMN short_code VARCHAR(8) UNIQUE') + } catch (e: any) { + if (!String(e?.code || e?.message).match(/Duplicate column|already exists/i)) throw e + } + })() + } + return affiliateShortCodeReady +} + +// Confusable-free alphabet for 2-char codes (32 chars = 1024 combinations) +const SC_ALPHA = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +async function generateUniqueShortCode(): Promise { + await ensureAffiliateShortCodeColumn() + for (let attempt = 0; attempt < 50; attempt++) { + const code = SC_ALPHA[Math.floor(Math.random() * SC_ALPHA.length)] + + SC_ALPHA[Math.floor(Math.random() * SC_ALPHA.length)] + const [rows] = await pool.execute('SELECT id FROM affiliates WHERE short_code = ?', [code]) as any[] + if (rows.length === 0) return code + } + throw new Error('No free short codes — increase to 3 chars') +} + +export async function ensureAffiliateShortCode(affiliateId: number): Promise { + await ensureAffiliateShortCodeColumn() + const [rows] = await pool.execute('SELECT short_code FROM affiliates WHERE id = ?', [affiliateId]) as any[] + if (rows[0]?.short_code) return rows[0].short_code + const code = await generateUniqueShortCode() + await pool.execute('UPDATE affiliates SET short_code = ? WHERE id = ?', [code, affiliateId]) + return code +} + +export async function getAffiliateByShortCode(code: string): Promise<{ id: number; referral_code: string; name: string } | null> { + await ensureAffiliateShortCodeColumn() + const [rows] = await pool.execute( + 'SELECT id, referral_code, name FROM affiliates WHERE short_code = ? AND status = ?', + [code.toUpperCase(), 'active'] + ) as any[] + return rows.length > 0 ? rows[0] : null +} + +// ─── Affiliate Referral (called after successful payment) ── + +export async function createAffiliateReferral(signupId: number) { + // Get signup with affiliate info + const [signups] = await pool.execute('SELECT * FROM signups WHERE id = ?', [signupId]) as any[] + if (signups.length === 0) return + + const signup = signups[0] + if (!signup.affiliate_id) return + + // Get affiliate commission rate + const [affs] = await pool.execute('SELECT * FROM affiliates WHERE id = ? AND status = ?', [signup.affiliate_id, 'active']) as any[] + if (affs.length === 0) return + + const affiliate = affs[0] + const commissionAmount = (signup.monthly_payment || 29) * (affiliate.commission_rate / 100) + + // Check for duplicate + const [existing] = await pool.execute( + 'SELECT id FROM affiliate_referrals WHERE affiliate_id = ? AND signup_id = ?', + [affiliate.id, signupId] + ) as any[] + if (existing.length > 0) return + + // Create referral + await pool.execute( + `INSERT INTO affiliate_referrals (affiliate_id, signup_id, commission_amount, status) VALUES (?, ?, ?, 'pending')`, + [affiliate.id, signupId, commissionAmount] + ) + + // Update affiliate total_earned + await pool.execute('UPDATE affiliates SET total_earned = total_earned + ? WHERE id = ?', [commissionAmount, affiliate.id]) +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..d43d6b1 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,283 @@ +const MAIL_API_URL = process.env.MAIL_API_URL || 'https://mail.3ava.com/api/emails' +const MAIL_API_KEY = process.env.MAIL_API_KEY +const FROM = process.env.EMAIL_FROM || 'Mexico Paradise Vacations ' +const REPLY_TO = process.env.EMAIL_REPLY_TO || 'support@724vacation.com' +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://hi2b.com' + +const HEADER = ` +
+

Mexico Paradise Vacations

+

Your All-Inclusive Paradise Awaits

+
` + +const FOOTER = ` +
+

Mexico Paradise Vacations • hi2b.com

+

Toll-Free: 888-602-2424

+

Mon-Fri 9am-8pm • Sat 10am-4pm EST

+
` + +function wrap(content: string) { + return ` + + + + +
+ ${HEADER} +
+ ${content} +
+ ${FOOTER} +
+ +` +} + +function btn(text: string, url: string, color = '#E8651A') { + return `
+ ${text} +
` +} + +interface SendEmailInput { + to: string | string[] + subject: string + html: string + text?: string + from?: string + replyTo?: string +} + +/** + * Send a transactional email via 3AVA Mail. + * Never throws into the request path — logs and resolves with the API response or null. + */ +async function sendMail(input: SendEmailInput): Promise<{ id?: string; status?: string } | null> { + if (!MAIL_API_KEY) { + console.error('[mail] MAIL_API_KEY not configured; skipping send to', input.to) + return null + } + const replyTo = input.replyTo ?? REPLY_TO + const body = { + from: input.from ?? FROM, + to: Array.isArray(input.to) ? input.to : [input.to], + subject: input.subject, + html: input.html, + ...(input.text ? { text: input.text } : {}), + ...(replyTo ? { reply_to: [replyTo] } : {}), + } + try { + const res = await fetch(MAIL_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${MAIL_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + console.error('[mail] send failed', res.status, data) + return null + } + return data + } catch (err) { + console.error('[mail] network error', err) + return null + } +} + +// ─── Welcome / Payment Confirmation ─────────────────────── + +export async function sendWelcomeEmail(email: string, name: string, certificateNumber: string, paymentType: string, amount: number) { + return sendMail({ + to: email, + subject: 'Welcome to Paradise! Your Vacation Certificate is Ready', + html: wrap(` +

Welcome to Paradise, ${name}!

+

Congratulations! Your vacation certificate has been activated. Here are your details:

+ +
+

Certificate Number

+

${certificateNumber}

+
+ +
+

Payment: $${amount} ${paymentType === 'monthly' ? '(first of 10 monthly payments)' : '(one-time payment)'}

+

Package: 5 Days / 4 Nights All-Inclusive

+

Destinations: Cancun, Cabo, Riviera Maya, Puerto Vallarta

+

Valid for: 18 months from today

+
+ +
+

100% Money-Back Guarantee

+

Full refund within 30 days, no questions asked.

+
+ +

What's Next?

+
    +
  1. Set up your account password below
  2. +
  3. Log in to your dashboard to view your certificate
  4. +
  5. Call 888-602-2424 to book your travel dates!
  6. +
+ + ${btn('Set Up My Account', `${APP_URL}/dashboard/login`)} + +
+

Ready to book? Call us now!

+

888-602-2424

+
+ `), + }) +} + +// ─── Payment Receipt ────────────────────────────────────── + +export async function sendPaymentReceiptEmail(email: string, name: string, amount: number, transactionId: string, paymentNumber: number, totalPayments: number) { + return sendMail({ + to: email, + subject: `Payment Received — $${amount.toFixed(2)} — Mexico Paradise Vacations`, + html: wrap(` +

Payment Received

+

Hi ${name}, your payment has been processed successfully.

+ +
+
+

Payment Receipt

+
+
+ + + + + +
Amount$${amount.toFixed(2)}
Payment${paymentNumber} of ${totalPayments}
Transaction ID${transactionId}
Date${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
+
+
+ + ${btn('View Billing History', `${APP_URL}/dashboard/billing`)} + `), + }) +} + +// ─── Password Reset ─────────────────────────────────────── + +export async function sendPasswordResetEmail(email: string, token: string) { + const resetUrl = `${APP_URL}/dashboard/reset-password?token=${token}` + + return sendMail({ + to: email, + subject: 'Reset Your Password — Mexico Paradise Vacations', + html: wrap(` +

Reset Your Password

+

We received a request to reset your password. Click the button below to create a new one.

+ + ${btn('Reset My Password', resetUrl)} + +

This link expires in 1 hour. If you didn't request this, you can safely ignore this email.

+ +
+

If the button doesn't work, copy this link:

+

${resetUrl}

+
+ `), + }) +} + +// ─── Certificate Email ──────────────────────────────────── + +export async function sendCertificateEmail(email: string, name: string, certificateNumber: string, expiryDate: string) { + return sendMail({ + to: email, + subject: `Your Vacation Certificate #${certificateNumber} — Mexico Paradise Vacations`, + html: wrap(` +

Your Vacation Certificate

+

Hi ${name}, here's your vacation certificate. Save this email for your records.

+ +
+

Vacation Certificate

+

MEXICO PARADISE VACATIONS

+

Presented To

+

${name}

+ +
+
+

Certificate No.

+

${certificateNumber}

+
+
+ +

Valid until: ${expiryDate}

+ +
+

5 Days / 4 Nights • All-Inclusive • 2 Guests

+

Cancun • Cabo San Lucas • Riviera Maya • Puerto Vallarta

+
+
+ +
+

To Book Your Vacation, Call

+

888-602-2424

+

Have your certificate number ready: ${certificateNumber}

+
+ + ${btn('View in Dashboard', `${APP_URL}/dashboard/certificate`)} + `), + }) +} + +// ─── Ebook Download Confirmation ────────────────────────── + +export async function sendEbookEmail(email: string, name?: string) { + return sendMail({ + to: email, + subject: 'Your Free Guide: Budget Luxury Travel in Mexico', + html: wrap(` +

Your Free Guide is Ready!

+

Hi${name ? ` ${name}` : ''}, thanks for downloading our Budget Luxury Travel guide. If your download didn't start, use the link below.

+ + ${btn('Download Guide (PDF)', `${APP_URL}/ebooks/budget-luxury-travel.pdf`, '#16A34A')} + +
+

Special Offer: $29/month

+

5 Days / 4 Nights All-Inclusive Mexico Vacation

+

100% money-back guarantee. Book immediately after first payment.

+ ${btn('Claim Your Vacation Certificate', APP_URL)} +
+ `), + }) +} + +// ─── Affiliate Welcome ──────────────────────────────────── + +export async function sendAffiliateWelcomeEmail(email: string, name: string, referralCode: string) { + return sendMail({ + to: email, + subject: 'Welcome to the Affiliate Program — Mexico Paradise Vacations', + html: wrap(` +

Welcome, ${name}!

+

You're now part of the Mexico Paradise Vacations affiliate program. Start sharing your unique referral link and earn commission on every sale.

+ +
+

Your Referral Code

+

${referralCode}

+
+ +
+

Your Referral Link:

+

${APP_URL}/lp/golden-hour?ref=${referralCode}

+
+ +

How It Works

+
    +
  1. Share your referral link on social media, email, or your website
  2. +
  3. When someone purchases through your link, you earn 20% commission
  4. +
  5. Track your earnings in your affiliate dashboard
  6. +
  7. Get paid monthly via PayPal, bank transfer, or check
  8. +
+ + ${btn('Go to Affiliate Dashboard', `${APP_URL}/affiliate`, '#0D9488')} + `), + }) +} diff --git a/src/lib/maverick.ts b/src/lib/maverick.ts index 190dfe8..e61d071 100644 --- a/src/lib/maverick.ts +++ b/src/lib/maverick.ts @@ -1,152 +1,376 @@ -export interface MaverickPaymentRequest { - amount: number - currency: string +/** + * Maverick Payments API Integration + * + * Flow for vacation certificate purchase: + * 1. Create customer in Customer Vault + * 2. Add card to customer vault (tokenized) + * 3. Process first $39 sale + * 4. Set up recurring payment ($39/mo × 9 remaining months) + * + * API Docs: https://developers.maverickpayments.com + * Dashboard: https://dashboard.maverickpayments.com + */ + +const GATEWAY_URL = process.env.MAVERICK_GATEWAY_URL || 'https://gateway.maverickpayments.com' +const DASHBOARD_URL = process.env.MAVERICK_DASHBOARD_URL || 'https://dashboard.maverickpayments.com' +const API_TOKEN = process.env.MAVERICK_API_TOKEN || '' +const TERMINAL_ID = parseInt(process.env.MAVERICK_TERMINAL_ID || '0', 10) +const DBA_ID = parseInt(process.env.MAVERICK_DBA_ID || '0', 10) +const BILLING_ID = parseInt(process.env.MAVERICK_BILLING_ID || '0', 10) + +function headers() { + return { + 'Authorization': `Bearer ${API_TOKEN}`, + 'Content-Type': 'application/json', + } +} + +// ─── Customer Vault ──────────────────────────────────────── + +export interface CreateCustomerRequest { + firstName: string + lastName: string email: string - fullName: string + phone: string +} + +export interface CustomerResponse { + id: number + token: string + firstName: string + lastName: string + email: string +} + +export async function createCustomer(req: CreateCustomerRequest): Promise { + const res = await fetch(`${DASHBOARD_URL}/api/customer-vault`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + dba: { id: DBA_ID }, + firstName: req.firstName, + lastName: req.lastName, + email: req.email, + phone: req.phone, + description: 'Mexico Paradise Vacations - Certificate Purchase', + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Failed to create customer: ${err}`) + } + + return res.json() +} + +// ─── Add Card to Vault ───────────────────────────────────── + +export interface AddCardRequest { + customerId: number + cardNumber: string + exp: string // MM/YY format + cvv: string + holderName: string +} + +export interface CardResponse { + id: number + number: number // last 4 digits + token: string + exp: string + status: string +} + +export async function addCardToVault(req: AddCardRequest): Promise { + const res = await fetch(`${DASHBOARD_URL}/api/customer-vault/${req.customerId}/card`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + billing: { id: BILLING_ID }, + terminal: { id: TERMINAL_ID }, + holderName: req.holderName, + number: req.cardNumber, + exp: req.exp, + cvv: req.cvv, + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Failed to add card: ${err}`) + } + + return res.json() +} + +// ─── Process Sale ────────────────────────────────────────── + +export interface SaleRequest { + amount: number // in dollars (e.g., 39.00) + cardToken: string description?: string - returnUrl?: string - cancelUrl?: string } -export interface MaverickPaymentResponse { - success: boolean - paymentId?: string - checkoutUrl?: string - error?: string +export interface SaleResponse { + id: string + status: string + amount: string + card: { + token: string + number: number + } } -export interface MaverickRefundRequest { - paymentId: string - amount?: number - reason?: string +export async function processSale(req: SaleRequest): Promise { + const res = await fetch(`${GATEWAY_URL}/payment/sale`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + terminal: { id: TERMINAL_ID }, + amount: req.amount.toFixed(2), + source: 'Internet', + level: 1, + card: { + token: req.cardToken, + }, + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Sale failed: ${err}`) + } + + return res.json() } -export interface MaverickRefundResponse { - success: boolean - refundId?: string - error?: string +// ─── Tokenize Card (without charging) ────────────────────── + +export interface TokenizeRequest { + cardName: string + cardNumber: string + exp: string // MM/YY + cvv: string +} + +export interface TokenizeResponse { + token: string + card: { + number: number + brand: string + } } -export class MaverickPaymentAPI { - private baseUrl: string - private apiKey: string +export async function tokenizeCard(req: TokenizeRequest): Promise { + const res = await fetch(`${GATEWAY_URL}/payment/generate-token`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + terminal: { id: TERMINAL_ID }, + source: 'Internet', + card: { + name: req.cardName, + number: req.cardNumber, + exp: req.exp, + cvv: req.cvv, + }, + }), + }) - constructor(baseUrl: string, apiKey: string) { - this.baseUrl = baseUrl - this.apiKey = apiKey + if (!res.ok) { + const err = await res.text() + throw new Error(`Tokenization failed: ${err}`) } - async createPayment(request: MaverickPaymentRequest): Promise { - try { - const response = await fetch(`${this.baseUrl}/payments`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, - body: JSON.stringify({ - amount: request.amount, - currency: request.currency, - customer: { - email: request.email, - name: request.fullName, - }, - description: request.description || 'Service Payment', - return_url: request.returnUrl, - cancel_url: request.cancelUrl, - }), - }) + return res.json() +} - const data = await response.json() - - if (response.ok) { - return { - success: true, - paymentId: data.id, - checkoutUrl: data.checkout_url, - } - } else { - return { - success: false, - error: data.error || 'Payment creation failed', - } - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Network error', - } - } +// ─── Create Recurring Payment ────────────────────────────── + +export interface RecurringPaymentRequest { + customerId: number + cardId: number + amount: number + name: string + description: string + maxPayments: number // e.g., 9 (remaining after first charge) + startDate: string // YYYY-MM-DD (next month) +} + +export interface RecurringPaymentResponse { + id: string + name: string + amount: string + execute: { + frequency: number + period: string + } + payment: { + next: string + max: number } + status: string | null +} - async getPaymentStatus(paymentId: string): Promise<{ success: boolean; status?: string; error?: string }> { - try { - const response = await fetch(`${this.baseUrl}/payments/${paymentId}`, { - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - }, - }) +export async function createRecurringPayment(req: RecurringPaymentRequest): Promise { + // Calculate end date (maxPayments months from start) + const start = new Date(req.startDate) + const end = new Date(start) + end.setMonth(end.getMonth() + req.maxPayments) - const data = await response.json() - - if (response.ok) { - return { - success: true, - status: data.status, - } - } else { - return { - success: false, - error: data.error || 'Failed to get payment status', - } - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Network error', - } - } + const res = await fetch(`${DASHBOARD_URL}/api/customer-vault/${req.customerId}/recurring-payment`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + name: req.name, + description: req.description, + amount: req.amount, + execute: { + frequency: 1, + period: 'month', + }, + valid: { + from: req.startDate, + to: end.toISOString().split('T')[0], + }, + payment: { + max: req.maxPayments, + }, + terminal: { id: TERMINAL_ID }, + customer: { + id: req.customerId, + card: { id: req.cardId }, + }, + dba: { id: DBA_ID }, + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Recurring payment setup failed: ${err}`) } - async createRefund(request: MaverickRefundRequest): Promise { - try { - const response = await fetch(`${this.baseUrl}/refunds`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - }, - body: JSON.stringify({ - payment_id: request.paymentId, - amount: request.amount, - reason: request.reason || 'Customer refund', - }), + return res.json() +} + +// ─── Refund ──────────────────────────────────────────────── + +export interface RefundRequest { + transactionId: string + amount?: number +} + +export interface RefundResponse { + id: string + status: string + amount: string +} + +export async function processRefund(req: RefundRequest): Promise { + const res = await fetch(`${GATEWAY_URL}/payment/refund`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + terminal: { id: TERMINAL_ID }, + transactionId: req.transactionId, + ...(req.amount && { amount: req.amount.toFixed(2) }), + }), + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`Refund failed: ${err}`) + } + + return res.json() +} + +// ─── Full Purchase Flow ──────────────────────────────────── +// This is the main function used by the payment API route + +export interface PurchaseRequest { + firstName: string + lastName: string + email: string + phone: string + cardNumber: string + cardExp: string // MM/YY + cardCvv: string + cardName: string + paymentType: 'monthly' | 'one-time' +} + +export interface PurchaseResult { + success: boolean + customerId?: number + cardId?: number + transactionId?: string + recurringPaymentId?: string + error?: string +} + +export async function processFullPurchase(req: PurchaseRequest): Promise { + try { + // Step 1: Create customer in vault + const customer = await createCustomer({ + firstName: req.firstName, + lastName: req.lastName, + email: req.email, + phone: req.phone, + }) + + // Step 2: Add card to customer vault + const card = await addCardToVault({ + customerId: customer.id, + cardNumber: req.cardNumber, + exp: req.cardExp, + cvv: req.cardCvv, + holderName: req.cardName, + }) + + // Step 3: Process first payment + const amount = req.paymentType === 'monthly' ? 39.00 : 399.00 + const sale = await processSale({ + amount, + cardToken: card.token, + description: req.paymentType === 'monthly' + ? 'Mexico Paradise Vacation Certificate - Payment 1 of 10' + : 'Mexico Paradise Vacation Certificate - Full Payment', + }) + + // Step 4: If monthly, set up recurring for remaining 9 payments + let recurringPaymentId: string | undefined + if (req.paymentType === 'monthly') { + const nextMonth = new Date() + nextMonth.setMonth(nextMonth.getMonth() + 1) + const startDate = nextMonth.toISOString().split('T')[0] + + const recurring = await createRecurringPayment({ + customerId: customer.id, + cardId: card.id, + amount: 39.00, + name: 'Mexico Paradise Vacation - Monthly Payment', + description: 'Vacation certificate payment plan - $39/month', + maxPayments: 9, + startDate, }) - const data = await response.json() - - if (response.ok) { - return { - success: true, - refundId: data.id, - } - } else { - return { - success: false, - error: data.error || 'Refund creation failed', - } - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Network error', - } + recurringPaymentId = recurring.id + } + + return { + success: true, + customerId: customer.id, + cardId: card.id, + transactionId: sale.id, + recurringPaymentId, + } + } catch (error) { + console.error('Purchase flow error:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Payment processing failed', } } } - -// Initialize with environment variables or defaults -export const maverickAPI = new MaverickPaymentAPI( - process.env.MAVERICK_API_URL || 'https://api.maverickpayments.com/v1', - process.env.MAVERICK_API_KEY || 'your-api-key-here' -) \ No newline at end of file diff --git a/src/lib/nmi.ts b/src/lib/nmi.ts new file mode 100644 index 0000000..7140d07 --- /dev/null +++ b/src/lib/nmi.ts @@ -0,0 +1,285 @@ +/** + * NMI Payment Gateway Integration + * API: https://secure.nmi.com/api/transact.php + * Docs: https://docs.nmi.com + */ + +const NMI_API_URL = process.env.NMI_API_URL || 'https://secure.nmi.com/api/transact.php' +const SECURITY_KEY = process.env.NMI_SECURITY_KEY || '' + +interface NMIResponse { + response: string // '1' = approved, '2' = declined, '3' = error + responsetext: string + authcode?: string + transactionid?: string + customer_vault_id?: string + subscription_id?: string + response_code?: string +} + +async function nmiRequest(params: Record): Promise { + params.security_key = SECURITY_KEY + + const body = new URLSearchParams(params).toString() + const res = await fetch(NMI_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + const text = await res.text() + const parsed: Record = {} + text.split('&').forEach(pair => { + const [key, ...vals] = pair.split('=') + parsed[decodeURIComponent(key)] = decodeURIComponent(vals.join('=')) + }) + + return parsed as unknown as NMIResponse +} + +// ─── Process Sale ────────────────────────────────────────── + +export async function processSale(params: { + amount: number + firstName: string + lastName: string + email: string + phone?: string + orderDescription?: string + // Either token OR card details + paymentToken?: string + cardNumber?: string + cardExp?: string + cardCvv?: string +}): Promise { + const data: Record = { + type: 'sale', + amount: params.amount.toFixed(2), + first_name: params.firstName, + last_name: params.lastName, + email: params.email, + phone: params.phone || '', + order_description: params.orderDescription || 'Mexico Paradise Vacation Certificate', + orderid: `HI2B-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + } + + if (params.paymentToken) { + data.payment_token = params.paymentToken + } else if (params.cardNumber) { + data.ccnumber = params.cardNumber + data.ccexp = (params.cardExp || '').replace('/', '') + data.cvv = params.cardCvv || '' + } + + return nmiRequest(data) +} + +// ─── Add Customer to Vault ───────────────────────────────── + +export async function addToVault(params: { + firstName: string + lastName: string + email: string + phone?: string + paymentToken?: string + cardNumber?: string + cardExp?: string + cardCvv?: string +}): Promise { + const data: Record = { + customer_vault: 'add_customer', + first_name: params.firstName, + last_name: params.lastName, + email: params.email, + phone: params.phone || '', + } + + if (params.paymentToken) { + data.payment_token = params.paymentToken + } else if (params.cardNumber) { + data.ccnumber = params.cardNumber + data.ccexp = (params.cardExp || '').replace('/', '') + data.cvv = params.cardCvv || '' + } + + return nmiRequest(data) +} + +// ─── Create Recurring Subscription ───────────────────────── +// Accepts EITHER a customer_vault_id OR card details directly. Live merchant +// accounts without Customer Vault enabled can still bill recurring by passing +// ccnumber/ccexp directly to add_subscription. + +export async function createSubscription(params: { + customerVaultId?: string + paymentToken?: string + cardNumber?: string + cardExp?: string + cardCvv?: string + planPayments: number + planAmount: number + monthFrequency?: number + dayOfMonth?: number + startDate?: string + firstName: string + lastName: string + email: string + phone?: string +}): Promise { + const now = new Date() + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()) + const defaultStartDate = nextMonth.toISOString().slice(0, 10).replace(/-/g, '') + + const data: Record = { + recurring: 'add_subscription', + plan_payments: params.planPayments.toString(), + plan_amount: params.planAmount.toFixed(2), + month_frequency: (params.monthFrequency || 1).toString(), + day_of_month: (params.dayOfMonth || now.getDate()).toString(), + start_date: params.startDate || defaultStartDate, + first_name: params.firstName, + last_name: params.lastName, + email: params.email, + phone: params.phone || '', + } + + if (params.customerVaultId) { + data.customer_vault_id = params.customerVaultId + } else if (params.paymentToken) { + data.payment_token = params.paymentToken + } else if (params.cardNumber) { + data.ccnumber = params.cardNumber + data.ccexp = (params.cardExp || '').replace('/', '') + if (params.cardCvv) data.cvv = params.cardCvv + } + + return nmiRequest(data) +} + +// ─── Refund ──────────────────────────────────────────────── + +export async function processRefund(params: { + transactionId: string + amount?: number +}): Promise { + const data: Record = { + type: 'refund', + transactionid: params.transactionId, + } + if (params.amount) data.amount = params.amount.toFixed(2) + return nmiRequest(data) +} + +// ─── Full Purchase Flow ──────────────────────────────────── + +export interface PurchaseRequest { + firstName: string + lastName: string + email: string + phone: string + paymentType: 'monthly' | 'one-time' + paymentToken?: string + cardNumber?: string + cardExp?: string + cardCvv?: string +} + +export interface PurchaseResult { + success: boolean + transactionId?: string + customerVaultId?: string + subscriptionId?: string + error?: string +} + +export async function processFullPurchase(req: PurchaseRequest): Promise { + try { + const amount = req.paymentType === 'monthly' ? 29.00 : 249.00 + + // Step 1: Process initial sale + const sale = await processSale({ + amount, + firstName: req.firstName, + lastName: req.lastName, + email: req.email, + phone: req.phone, + paymentToken: req.paymentToken, + cardNumber: req.cardNumber, + cardExp: req.cardExp, + cardCvv: req.cardCvv, + orderDescription: req.paymentType === 'monthly' + ? 'Mexico Paradise Vacation Certificate - Payment 1 of 10' + : 'Mexico Paradise Vacation Certificate - Full Payment', + }) + + if (sale.response !== '1') { + return { + success: false, + error: sale.responsetext || 'Payment declined', + } + } + + let customerVaultId: string | undefined + let subscriptionId: string | undefined + + // Step 2: If monthly, create the recurring subscription for the remaining + // 9 payments. Try Customer Vault first (cleanest record-keeping); if the + // merchant account doesn't have Vault enabled, fall back to passing card + // details straight to add_subscription — NMI accepts both flows. + if (req.paymentType === 'monthly') { + const vault = await addToVault({ + firstName: req.firstName, + lastName: req.lastName, + email: req.email, + phone: req.phone, + paymentToken: req.paymentToken, + cardNumber: req.cardNumber, + cardExp: req.cardExp, + cardCvv: req.cardCvv, + }) + + if (vault.response === '1') { + customerVaultId = vault.customer_vault_id + } else { + console.warn('Vault unavailable, creating subscription with card data directly:', vault.responsetext) + } + + const subscription = await createSubscription({ + ...(customerVaultId + ? { customerVaultId } + : { + paymentToken: req.paymentToken, + cardNumber: req.cardNumber, + cardExp: req.cardExp, + cardCvv: req.cardCvv, + }), + planPayments: 9, + planAmount: 29.00, + monthFrequency: 1, + firstName: req.firstName, + lastName: req.lastName, + email: req.email, + phone: req.phone, + }) + + if (subscription.response === '1') { + subscriptionId = subscription.subscription_id + } else { + console.error('Subscription failed:', subscription.responsetext) + } + } + + return { + success: true, + transactionId: sale.transactionid, + customerVaultId, + subscriptionId, + } + } catch (error) { + console.error('Purchase error:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Payment processing failed', + } + } +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts deleted file mode 100644 index 25b2d03..0000000 --- a/src/lib/supabase.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { createClient } from '@supabase/supabase-js' - -const supabaseUrl = 'https://apanmzuxhipnvfqnvuef.supabase.co' -const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFwYW5tenV4aGlwbnZmcW52dWVmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjE1OTI4MjksImV4cCI6MjA3NzE2ODgyOX0.TC8il_FWdXZp4McgYPFfzl4CnSmqQNzhLS2dc_1AJHY' - -export const supabase = createClient(supabaseUrl, supabaseAnonKey) - -export type Database = { - public: { - Tables: { - users: { - Row: { - id: string - email: string - full_name: string | null - role: 'admin' | 'client' - created_at: string - updated_at: string - } - Insert: { - id?: string - email: string - full_name?: string | null - role?: 'admin' | 'client' - created_at?: string - updated_at?: string - } - Update: { - id?: string - email?: string - full_name?: string | null - role?: 'admin' | 'client' - created_at?: string - updated_at?: string - } - } - signups: { - Row: { - id: string - email: string - full_name: string - phone: string - destination?: string - amount: number - monthly_payment: number - payment_plan_months: number - payment_status: 'pending' | 'active' | 'completed' | 'failed' | 'refunded' - payment_id: string | null - created_at: string - updated_at: string - } - Insert: { - id?: string - email: string - full_name: string - phone: string - destination?: string - amount: number - monthly_payment?: number - payment_plan_months?: number - payment_status?: 'pending' | 'active' | 'completed' | 'failed' | 'refunded' - payment_id?: string | null - created_at?: string - updated_at?: string - } - Update: { - id?: string - email?: string - full_name?: string - phone?: string - destination?: string - amount?: number - monthly_payment?: number - payment_plan_months?: number - payment_status?: 'pending' | 'active' | 'completed' | 'failed' | 'refunded' - payment_id?: string | null - created_at?: string - updated_at?: string - } - } - certificates: { - Row: { - id: string - user_id: string - destination: string - resort_name: string - check_in: string - check_out: string - nights: number - guests: number - certificate_url: string - issued_at: string - expires_at: string | null - status: 'active' | 'used' | 'expired' - created_at: string - updated_at: string - } - Insert: { - id?: string - user_id: string - destination: string - resort_name: string - check_in: string - check_out: string - nights: number - guests: number - certificate_url: string - issued_at?: string - expires_at?: string | null - status?: 'active' | 'used' | 'expired' - created_at?: string - updated_at?: string - } - Update: { - id?: string - user_id?: string - destination?: string - resort_name?: string - check_in?: string - check_out?: string - nights?: number - guests?: number - certificate_url?: string - issued_at?: string - expires_at?: string | null - status?: 'active' | 'used' | 'expired' - created_at?: string - updated_at?: string - } - } - payments: { - Row: { - id: string - signup_id: string - payment_method: 'maverick' - amount: number - currency: string - payment_type: 'initial' | 'monthly' | 'refund' - status: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' - transaction_id: string | null - refund_id: string | null - created_at: string - updated_at: string - } - Insert: { - id?: string - signup_id: string - payment_method: 'maverick' - amount: number - currency?: string - payment_type?: 'initial' | 'monthly' | 'refund' - status?: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' - transaction_id?: string | null - refund_id?: string | null - created_at?: string - updated_at?: string - } - Update: { - id?: string - signup_id?: string - payment_method?: 'maverick' - amount?: number - currency?: string - payment_type?: 'initial' | 'monthly' | 'refund' - status?: 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' - transaction_id?: string | null - refund_id?: string | null - created_at?: string - updated_at?: string - } - } - } - } -} \ No newline at end of file diff --git a/src/lib/tiktok-pixel.ts b/src/lib/tiktok-pixel.ts new file mode 100644 index 0000000..17d06a5 --- /dev/null +++ b/src/lib/tiktok-pixel.ts @@ -0,0 +1,82 @@ +/** + * TikTok Pixel helper. + * + * The pixel is env-driven: set NEXT_PUBLIC_TIKTOK_PIXEL_ID to activate it. + * When the ID is unset every function here is a safe no-op, so this code can + * ship before the TikTok Ads Manager account exists. + * + * Pixel ID comes from Ads Manager → Assets → Events → Web Events. + */ + +declare global { + interface Window { + ttq?: { + track: (event: string, params?: Record, options?: Record) => void + identify: (data: Record) => void + page: () => void + } + } +} + +export const TIKTOK_PIXEL_ID = process.env.NEXT_PUBLIC_TIKTOK_PIXEL_ID || '' +export const tiktokPixelEnabled = TIKTOK_PIXEL_ID.length > 0 + +/** Standard TikTok events used in this funnel. */ +export type TiktokEvent = + | 'ViewContent' + | 'ClickButton' + | 'SubmitForm' + | 'InitiateCheckout' + | 'AddPaymentInfo' + | 'CompletePayment' + | 'CompleteRegistration' + | 'Contact' + +export interface TiktokEventParams { + value?: number + currency?: string + content_id?: string + content_type?: string + content_name?: string + description?: string +} + +/** Fire a TikTok pixel event. No-op if the pixel isn't loaded. */ +export function ttqTrack(event: TiktokEvent, params?: TiktokEventParams): void { + if (typeof window === 'undefined' || !window.ttq) return + try { + window.ttq.track(event, params ? { ...params } : {}) + } catch { + /* pixel errors must never break the funnel */ + } +} + +/** + * Advanced matching — associates the event with a hashed email/phone so + * TikTok can attribute conversions across devices. The pixel SDK hashes + * these client-side before they leave the browser. + */ +export function ttqIdentify(user: { email?: string; phone?: string }): void { + if (typeof window === 'undefined' || !window.ttq) return + const payload: Record = {} + if (user.email) payload.email = user.email.trim().toLowerCase() + if (user.phone) { + const e164 = toE164(user.phone) + if (e164) payload.phone_number = e164 + } + if (Object.keys(payload).length === 0) return + try { + window.ttq.identify(payload) + } catch { + /* ignore */ + } +} + +/** Normalize a US phone number to E.164 (TikTok's required format). */ +function toE164(phone: string): string { + const digits = phone.replace(/\D/g, '') + if (digits.length === 10) return `+1${digits}` + if (digits.length === 11 && digits.startsWith('1')) return `+${digits}` + if (phone.trim().startsWith('+')) return phone.trim() + return digits ? `+${digits}` : '' +}