/* * build-reels.mjs — the faceless Reel generator. * * Turns prompts into upload-ready vertical MP4s (1080×1920, 30fps, silent — add * the trending sound in-app). Renders `reel-template.html` frame-by-frame headless * (Playwright), encodes with ffmpeg, and writes a hosted gallery (`reels/index.html`) * with each video + caption + music cue. This is the CONSISTENCY layer only — the * real-reaction Reels stay human (see instagram-channel.md). * * node apps/dare-night/marketing/build-reels.mjs * * Edit the BATCH below to swap prompts (there are 409 — sweep packs, rotate hooks), * then re-run; it overwrites reels/. Prompts + music here mirror ig-post-plan. * Needs: Playwright (global) + ffmpeg on PATH. */ import { execFileSync } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { chromium } = require('/opt/node22/lib/node_modules/playwright'); const HERE = dirname(fileURLToPath(import.meta.url)); // Output dir is configurable — the reels' real home is the studio-assets kit // (apps/dare-night/marketing/reels), served from dare-night.app/marketing/reels/. // REELS_OUT=./reels node build-reels.mjs const OUT = process.env.REELS_OUT ? resolve(process.env.REELS_OUT) : join(HERE, 'reels'); // ffmpeg may not be on PATH (e.g. cloud env) — Playwright ships one: // FFMPEG=/opt/pw-browsers/ffmpeg-/ffmpeg-linux node build-reels.mjs const FFMPEG = process.env.FFMPEG || 'ffmpeg'; const TEMPLATE = 'file://' + join(HERE, 'reel-template.html'); const FPS = 30, DUR = 12000, N = Math.round(DUR / 1000 * FPS); // 12s: CTA (fades in ~9s) now holds ~2.5s, readable // Pack accents (canon): classic #FFEE00 · crush #FF50C8 · spicy #FF7A00 · confessions #9B5DE5 · chaos #FF0040 // A month of faceless prompt-reveal reels — 3 per pack, sweeping all 5 packs, // TRUTH/DARE rotated. Every prompt is REAL (pulled from content/prompts.js). // Music is a VIBE + examples — verify what's actually trending the day you post; // a royalty-safe fallback (Instagram's Commercial library) is named each time. const BATCH = [ // ── 🎉 Classic (#FFEE00) ── { pack:'🎉 Classic Party', pk:'#FFEE00', tag:'TRUTH', prompt:'What’s the worst decision you made that somehow became a great story?', hook:'the question that makes
everyone lean in 🎉', nudge:'we need the whole story.', music:'Upbeat/trending, drop on the reveal · ex: This Girl — Kungs (~0:37) · fallback: bright pop, Commercial lib. [VERIFY day-of]', caption:'The exact second everyone leans in 😅 Every group has one “worst decision → best story.”\nFree, no download — link in bio.\n#truthordare #partygame #gamenight #icebreaker #friendgroup #storytime #darenight #reels #hostellife #travelbuddies' }, { pack:'🎉 Classic Party', pk:'#FFEE00', tag:'DARE', prompt:'Give someone here a compliment that sounds like a warning.', hook:'watch them try to work out
if that was a compliment 😅', nudge:'deliver it dead serious.', music:'Playful/deadpan · ex: Levitating — Dua Lipa (~0:53) · fallback: quirky upbeat, Commercial lib. [VERIFY]', caption:'Watching someone work out whether that was a compliment 😅\nTruth or Dare for the whole room — free, link in bio.\n#truthordare #partygame #gamenight #funny #icebreaker #darenight #reels #hostellife #backpackers' }, { pack:'🎉 Classic Party', pk:'#FFEE00', tag:'TRUTH', prompt:'Who here would you trust to lie for the group with confidence?', hook:'everyone looks at the
same person 🎉', nudge:'no hesitation.', music:'Cheeky/bouncy · ex: a trending “callout” sound · fallback: playful funk, Commercial lib. [VERIFY]', caption:'There’s a half-second where the whole table looks at the same person 👀\nFree, no download — dare-night.app.\n#truthordare #partygame #friendgroup #gamenight #callout #darenight #reels #travelbuddies #hostellife' }, // ── 💘 Crush (#FF50C8) ── { pack:'💘 Crush Mode', pk:'#FF50C8', tag:'TRUTH', prompt:'What’s the smallest thing someone can do that makes you suddenly interested?', hook:'the pause before
they answer 💘', nudge:'be honest. we saw that smile.', music:'Smooth/flirty chorus · ex: One Kiss — Calvin Harris & Dua Lipa (~0:48) · fallback: mellow R&B, Commercial lib. [VERIFY]', caption:'The pause before they answer, when everyone already knows 💘\nFree at dare-night.app.\n#truthordare #crush #situationship #partygame #datingbelike #couplegoals #darenight #reels #travelcouples #backpackers' }, { pack:'💘 Crush Mode', pk:'#FF50C8', tag:'DARE', prompt:'Give someone a compliment that sounds almost too honest.', hook:'the part where you
have to look at them 💘', nudge:'no flinching.', music:'Warm/tender · ex: Adore You — Harry Styles (~0:48) · fallback: soft indie-pop, Commercial lib. [VERIFY]', caption:'Saying the honest one out loud, to their face 💘 The room goes very quiet.\nFree at dare-night.app.\n#truthordare #crush #partygame #flirty #couplegoals #darenight #reels #travelcouples #hostellife' }, { pack:'💘 Crush Mode', pk:'#FF50C8', tag:'TRUTH', prompt:'What’s your dumbest weakness when you like someone?', hook:'ten minutes in and
everyone knows everything 💘', nudge:'we all have one.', music:'Playful-embarrassed · ex: a trending “caught” sound · fallback: light pop, Commercial lib. [VERIFY]', caption:'Ten minutes in and everyone knows something new about everyone 💘😭\nIt’s free. Play it with your people.\n#truthordare #crush #situationship #relatable #partygame #darenight #reels #travelbuddies #backpackers' }, // ── 🌶️ Spicy (#FF7A00) ── { pack:'🌶️ Spicy But Safe', pk:'#FF7A00', tag:'TRUTH', prompt:'Who here looks like trouble in a good outfit?', hook:'the part where you have
to say it out loud 😏', nudge:'point. no take-backs.', music:'Confident/flirty beat · ex: Tití Me Preguntó — Bad Bunny (~0:50) · fallback: sultry groove, Commercial lib. [VERIFY]', caption:'Nobody’s brave until the room is already laughing 😏\nFree, no download — link in bio.\n#truthordare #partygame #spicy #girlsnight #nightout #friendgroup #darenight #reels #hostellife #girlswhotravel' }, { pack:'🌶️ Spicy But Safe', pk:'#FF7A00', tag:'TRUTH', prompt:'What innocent thing do you find unfairly attractive?', hook:'the whole table
reacts at once 🌶️', nudge:'be specific 👀', music:'Smirky/slow-burn · ex: a trending sultry sound · fallback: smooth bass, Commercial lib. [VERIFY]', caption:'The answer lands and the whole table reacts at once 👀\nFree at dare-night.app.\n#truthordare #spicy #partygame #flirty #nightout #darenight #reels #hostellife #backpackers' }, { pack:'🌶️ Spicy But Safe', pk:'#FF7A00', tag:'DARE', prompt:'Let the group pick someone. Hold eye contact for 10 seconds.', hook:'10 seconds.
no looking away 🌶️', nudge:'the room counts out loud.', music:'Tension-building · ex: a trending countdown sound · fallback: cinematic build, Commercial lib. [VERIFY]', caption:'Ten seconds of eye contact with someone you met this morning 🌶️\nFree — dare-night.app.\n#truthordare #spicy #partygame #eyecontact #friendgroup #darenight #reels #hostellife #travelbuddies' }, // ── 🌑 Confessions (#9B5DE5) ── { pack:'🌑 Deep Confessions', pk:'#9B5DE5', tag:'TRUTH', prompt:'When did you last pretend you were fine?', hook:'the one that makes
the table go quiet 🌑', nudge:'take your time.', music:'Soft/emotional · ex: Until I Found You — Stephen Sanchez · fallback: gentle piano, Commercial lib. [VERIFY]', caption:'Save this for the people you actually want to know better 🌑\nA party game with a heart. Free — link in bio.\n#truthordare #deeptalks #meaningfulconversations #connection #friendship #darenight #reels #solotravel #hostellife' }, { pack:'🌑 Deep Confessions', pk:'#9B5DE5', tag:'DARE', prompt:'Tell someone here one thing you admire about them but never say.', hook:'the thing you’ve been
meaning to say 🌑', nudge:'look at them.', music:'Tender/warm · ex: a soft emotional trend · fallback: warm acoustic, Commercial lib. [VERIFY]', caption:'The thing you’ve been meaning to say, finally said out loud 🌑\nFree at dare-night.app.\n#truthordare #deeptalks #friendship #couplegoals #vulnerability #darenight #reels #travelcouples #solotravel' }, { pack:'🌑 Deep Confessions', pk:'#9B5DE5', tag:'TRUTH', prompt:'What compliment do you struggle to believe?', hook:'the soft one 🌑', nudge:'we’re listening.', music:'Reflective/quiet · ex: a mellow trending sound · fallback: ambient piano, Commercial lib. [VERIFY]', caption:'A party game that made the room go gentle for a second 🌑\nFree — dare-night.app.\n#truthordare #deeptalks #selflove #meaningfulconversations #connection #darenight #reels #solotravel #hostellife' }, // ── 💀 Chaos (#FF0040) ── { pack:'💀 Chaos Dares', pk:'#FF0040', tag:'DARE', prompt:'Walk away from the group, then return like everyone owes you money.', hook:'the face of someone who
just got handed a dare 💀', nudge:'who’s actually doing it?', music:'Hard drop on the walk-in · ex: Turn Down for What — DJ Snake & Lil Jon (~0:12) · fallback: big trap drop, Commercial lib. [VERIFY]', caption:'The three seconds before someone decides to fully commit 💀\nFree at dare-night.app.\n#truthordare #darenight #partygame #chaos #dares #funny #reels #hostellife #backpackers' }, { pack:'💀 Chaos Dares', pk:'#FF0040', tag:'DARE', prompt:'Point at one player and say, “You know exactly what you did.” Refuse to explain.', hook:'no context.
just chaos 💀', nudge:'hold the stare.', music:'Dramatic sting · ex: a trending “no context” sound · fallback: suspense hit, Commercial lib. [VERIFY]', caption:'No explanation. The room just watches it happen 💀\nFree — dare-night.app.\n#truthordare #chaos #dares #partygame #funny #friendgroup #darenight #hostellife #travelbuddies' }, { pack:'💀 Chaos Dares', pk:'#FF0040', tag:'TRUTH', prompt:'What’s the dumbest thing you’ve done because the room had too much energy?', hook:'story starts with
“so everyone was chanting…” 💀', nudge:'we need names.', music:'Fast/chaotic build · ex: a trending hype sound · fallback: energetic punk-pop, Commercial lib. [VERIFY]', caption:'Every group has an “and then the whole room was chanting” story 💀 This is where they come from.\nTruth or Dare for the whole room — free.\n#truthordare #chaos #partygame #storytime #friendgroup #funny #darenight #hostellife #backpackers' }, ]; function enc(item, t) { const p = new URLSearchParams({ pack:item.pack, pk:item.pk, tag:item.tag, prompt:item.prompt, hook:item.hook, nudge:item.nudge, t:String(t) }); return TEMPLATE + '?' + p.toString(); } mkdirSync(OUT, { recursive:true }); // ── PRECHECK before destroying anything (added 2026-07-29, Lina's QA) ────────────── // This script used to unlink every reel-*.mp4/.txt + index.html BEFORE verifying ffmpeg // existed. Running it without ffmpeg on PATH therefore DELETED all 15 rendered reels and // their approved captions, then crashed — real data loss, hit for real during QA. The reels // now live in the product repo and the captions are founder-approved copy, so the blast // radius got bigger. Fail before touching a single file. try { execFileSync('ffmpeg', ['-version'], { stdio:'ignore' }); } catch { console.error('✖ ffmpeg not found on PATH — aborting BEFORE deleting anything.\n' + ' This script replaces reel-*.mp4/.txt and index.html in ' + OUT + ',\n' + ' so it refuses to run without an encoder. Install ffmpeg, then re-run.'); process.exit(1); } // Non-destructive: clear ONLY previously-generated files, never the whole dir — // so a re-run keeps hand-added content in the kit (e.g. the archived posted/). for (const f of readdirSync(OUT)) { if (/^reel-\d+\.(mp4|txt)$/.test(f) || f === 'index.html') { try { unlinkSync(join(OUT, f)); } catch {} } } const tmp = join(OUT, '_frames'); const browser = await chromium.launch(); const page = await browser.newPage({ viewport:{ width:1080, height:1920 }, deviceScaleFactor:1 }); const built = []; for (let idx = 0; idx < BATCH.length; idx++) { const item = BATCH[idx]; rmSync(tmp, { recursive:true, force:true }); mkdirSync(tmp); // load once with content params, then just seek per frame await page.goto(enc(item, 0), { waitUntil:'networkidle' }); for (let i = 0; i < N; i++) { const t = Math.round(i * 1000 / FPS); await page.evaluate((tt) => window.seek(tt), t); await page.screenshot({ path: join(tmp, 'f' + String(i).padStart(4,'0') + '.png') }); } const file = `reel-${idx+1}.mp4`; execFileSync(FFMPEG, ['-y','-framerate','30','-i', join(tmp,'f%04d.png'), '-c:v','libx264','-crf','18','-preset','slow','-pix_fmt','yuv420p','-movflags','+faststart','-r','30', join(OUT, file)], { stdio:'ignore' }); writeFileSync(join(OUT, `reel-${idx+1}.txt`), item.caption + '\n'); built.push({ ...item, file }); console.log(` ✓ ${file} (${item.pack} · ${item.tag})`); } rmSync(tmp, { recursive:true, force:true }); await browser.close(); // hosted gallery — open on phone, each video + caption + music, save & post const esc = s => s.replace(/&/g,'&').replace(//g,'>'); const cards = built.map((b, i) => `
Reel ${i+1}${esc(b.pack)} ${b.tag}
🎵 Sound ${esc(b.music)}
⬇ Save video
${esc(b.caption)}
`).join(''); const html = ` Dare Night — Reel drop

Reel drop — the month (faceless)

${built.length} auto-made Reels — the faceless half of the month. Save each, post to IG + TikTok, add the sound in-app (they’re silent so the music isn’t copyright-muted). Long-press a caption to copy. Interleave these with your real-people clips (a table reacting, someone acting out a dare) — alternate faceless → real → faceless — and post the whole set before the end of August. Full shot list + schedule: the reel plan.
${cards}
`; writeFileSync(join(OUT, 'index.html'), html); console.log(`\n✅ ${built.length} reels + gallery → apps/dare-night/marketing/reels/ (${readdirSync(OUT).filter(f=>f.endsWith('.mp4')).length} mp4)`);