← back to Dw Marketing Reels
scripts/build-reel.mjs
249 lines
#!/usr/bin/env node
// Generate a portrait "New Arrivals" reel composition from data/new-arrivals.json,
// download product images locally (deterministic render), then render to MP4 via HyperFrames.
// $0 — local ffmpeg/Chrome render, no paid API.
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
import { LEAK_DENY, safe } from './leak-deny.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WS = join(ROOT, 'hf-workspace');
const N = Number(process.env.REEL_COUNT || 6); // products per reel
const PER = Number(process.env.REEL_SECS || 2.4); // seconds per product
const INTRO = 2.0, OUTRO = 2.6;
const SLUG = (process.env.REEL_SLUG || 'new-arrivals').replace(/[^a-z0-9-]+/gi, '-').toLowerCase(); // output filename prefix
// Reel theming — all env-overridable, defaulting to the generic "New Arrivals" reel.
// A themed run (e.g. Phillipe Romano corks) sets these to rebrand the cards + caption
// without touching the render pipeline.
const THEME = {
kicker: process.env.REEL_KICKER || 'New Arrivals',
title: process.env.REEL_TITLE || 'Just In',
subtitle: process.env.REEL_SUBTITLE || '', // '' → "Designer Wallcoverings · <Month Year>"
outroKicker: process.env.REEL_OUTRO_KICKER || 'Shop the collection',
outroBig: process.env.REEL_OUTRO_BIG || 'Designer<br/>Wallcoverings', // raw HTML by design (supports <br/>); operator-set only, not user input
url: process.env.REEL_URL || 'designerwallcoverings.com',
captionLead: process.env.REEL_CAPTION_LEAD || 'New arrivals just landed ✨',
captionTail: process.env.REEL_CAPTION_TAIL || 'Discover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗',
tagsExtra: (process.env.REEL_HASHTAGS_EXTRA || '#NewArrivals').split(/\s+/).filter(Boolean),
};
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// Private-label / upstream leak filter (LEAK_DENY + safe) imported from ./leak-deny.mjs —
// single source synced to canonical dw-leak-scanner. Captions pull from the LIVE storefront,
// so no denied vendor name reaches IG/TikTok.
// On-brand social caption + hashtags from the reel's products ($0 local, deterministic).
function captionFor(items) {
const pats = [...new Set(items.map(i => i.pattern).filter(safe))];
const vendors = [...new Set(items.map(i => i.vendor).filter(safe))];
const lead = pats.slice(0, 3).join(', ');
const body = `${THEME.captionLead} ${lead}${pats.length > 3 ? ' & more' : ''}`
+ `${vendors.length ? ` from ${vendors.slice(0, 3).join(', ')}` : ''}.`
+ `\n\n${THEME.captionTail}`;
const tagify = s => '#' + String(s).replace(/[^a-z0-9]+/gi, '');
const tags = [...new Set([
'#DesignerWallcoverings', ...THEME.tagsExtra, '#Wallcovering', '#Wallcoverings',
'#InteriorDesign', '#InteriorDecor', '#HomeDesign', '#LuxuryInteriors', '#DesignInspo',
...vendors.slice(0, 3).map(tagify),
])].slice(0, 14);
// final safety net: never emit a denylisted token even if one slipped through a field
const clean = s => s.split(/\s+/).filter(w => !LEAK_DENY.test(w)).join(' ');
return { text: `${clean(body)}\n\n${tags.join(' ')}`, hashtags: tags };
}
async function dl(url, dest) {
try {
const r = await fetch(url, { headers: { 'User-Agent': 'dw-marketing-reels' } });
if (!r.ok) return false;
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length < 1000) return false;
writeFileSync(dest, buf);
return true;
} catch { return false; }
}
async function main() {
const data = JSON.parse(readFileSync(join(ROOT, 'data', 'new-arrivals.json'), 'utf8'));
const items = data.items.slice(0, N);
if (!items.length) throw new Error('no items in data/new-arrivals.json — run fetch first');
const imgDir = join(WS, 'assets', 'img');
mkdirSync(imgDir, { recursive: true });
for (const [i, it] of items.entries()) {
const dest = join(imgDir, `p${i}.jpg`);
const ok = (await dl(it.image_hi, dest)) || (await dl(it.image, dest));
it._local = ok ? `assets/img/p${i}.jpg` : it.image;
console.log(` img ${i}: ${ok ? '✓ local' : '↯ remote'} ${it.pattern}`);
}
const total = +(INTRO + items.length * PER + OUTRO).toFixed(2);
const dateStr = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const introSub = THEME.subtitle || `Designer Wallcoverings · ${dateStr}`;
// --- slides markup ---
const slides = items.map((it, i) => `
<div class="slide" id="slide${i}">
<img class="ph" src="${esc(it._local)}" alt="${esc(it.pattern)}" />
<div class="scrim"></div>
<div class="cap">
<div class="pattern">${esc(it.pattern)}</div>
<div class="meta">${esc([it.color, it.vendor].filter(Boolean).join(' · '))}</div>
</div>
</div>`).join('');
// --- gsap timeline (seek-safe) ---
const anim = items.map((it, i) => {
const at = +(INTRO + i * PER).toFixed(3);
return `
gsap.set("#slide${i}", { opacity: 0 });
gsap.set("#slide${i} .ph", { scale: 1.12 });
gsap.set("#slide${i} .cap", { y: 40, opacity: 0 });
tl.to("#slide${i}", { opacity: 1, duration: 0.5, ease: "power1.out" }, ${at});
tl.to("#slide${i} .ph", { scale: 1.0, duration: ${PER}, ease: "none" }, ${at});
tl.to("#slide${i} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(at + 0.25).toFixed(3)});
tl.to("#slide${i}", { opacity: 0, duration: 0.45, ease: "power1.in" }, ${(at + PER - 0.35).toFixed(3)});`;
}).join('\n');
const outroAt = +(INTRO + items.length * PER).toFixed(3);
const html = `<!doctype html>
<html lang="en" data-resolution="portrait">
<head>
<meta charset="UTF-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
:root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; }
html,body { margin:0; padding:0; width:1080px; height:1920px; overflow:hidden;
background:var(--ink); font-family:"Helvetica Neue",system-ui,sans-serif; }
#main-composition { position:relative; width:1080px; height:1920px; overflow:hidden; background:var(--ink); }
.slide { position:absolute; inset:0; }
.slide .ph { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transform-origin:center; }
.scrim { position:absolute; inset:0; background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,0) 38%,rgba(0,0,0,.72) 100%); }
.cap { position:absolute; left:72px; right:72px; bottom:150px; }
.cap .pattern { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:96px; line-height:1.02;
color:#fff; letter-spacing:.01em; text-shadow:0 2px 20px rgba(0,0,0,.5); }
.cap .meta { margin-top:18px; font-size:34px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
/* intro / outro cards */
.card { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
text-align:center; background:var(--cream); color:var(--ink); }
.card .kicker { font-size:32px; letter-spacing:.42em; text-transform:uppercase; color:var(--gold); }
.card .big { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:150px; line-height:.98; margin:26px 60px; }
.card .sub { font-size:34px; letter-spacing:.16em; text-transform:uppercase; color:#5a544c; }
.card .rule { width:120px; height:2px; background:var(--gold); margin:44px 0; }
.card .url { position:absolute; bottom:150px; font-size:30px; letter-spacing:.24em; text-transform:uppercase; color:var(--ink); }
</style>
</head>
<body>
<div id="main-composition" data-composition-id="main-video"
data-width="1080" data-height="1920" data-start="0" data-duration="${total}">
<div class="card" id="intro">
<div class="kicker">${esc(THEME.kicker)}</div>
<div class="big">${esc(THEME.title)}</div>
<div class="rule"></div>
<div class="sub">${esc(introSub)}</div>
</div>
${slides}
<div class="card" id="outro">
<div class="kicker">${esc(THEME.outroKicker)}</div>
<div class="big">${THEME.outroBig}</div>
<div class="url">${esc(THEME.url)}</div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
// intro
gsap.set("#intro .big", { y: 40, opacity: 0 });
gsap.set("#intro .kicker, #intro .sub, #intro .rule", { opacity: 0 });
tl.to("#intro .kicker", { opacity: 1, duration: 0.5 }, 0.2);
tl.to("#intro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, 0.4);
tl.to("#intro .rule, #intro .sub", { opacity: 1, duration: 0.5 }, 0.9);
tl.to("#intro", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${(INTRO - 0.4).toFixed(2)});
// slides
${anim}
// outro
gsap.set("#outro", { opacity: 0 });
gsap.set("#outro .big", { y: 40, opacity: 0 });
gsap.set("#outro .url", { opacity: 0 });
tl.to("#outro", { opacity: 1, duration: 0.5 }, ${outroAt.toFixed(2)});
tl.to("#outro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, ${(outroAt + 0.2).toFixed(2)});
tl.to("#outro .url", { opacity: 1, duration: 0.5 }, ${(outroAt + 0.7).toFixed(2)});
window.__timelines["main-video"] = tl;
</script>
</div>
</body>
</html>`;
writeFileSync(join(WS, 'index.html'), html);
// strip the starter sub-compositions so they don't get pulled in
for (const f of ['compositions/intro.html', 'compositions/graphics.html', 'compositions/captions.html']) {
const p = join(WS, f); if (existsSync(p)) writeFileSync(p, '<div></div>');
}
console.log(`✓ composition written (${items.length} products, ${total}s portrait)`);
// --- render ---
console.log('→ rendering (local ffmpeg/Chrome, $0)…');
execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
// move newest mp4 to reels/ (REEL_SLUG names themed reels; default keeps new-arrivals)
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outName = `${SLUG}-${stamp}.mp4`;
const found = findMp4(WS);
if (found) {
mkdirSync(join(ROOT, 'reels'), { recursive: true });
const outPath = join(ROOT, 'reels', outName);
// Instagram Reels REQUIRE an audio track — a silent (no-audio) MP4 fails
// Meta processing with error 2207082. Mux in a silent AAC track so every
// reel is Reels-publishable. Falls back to a plain copy if ffmpeg is absent.
try {
execSync(`ffmpeg -y -i ${JSON.stringify(found)} -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -c:v copy -c:a aac -b:a 128k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
} catch {
console.warn(' (ffmpeg silent-audio mux failed — falling back to raw copy; reel may not be IG-Reels-publishable)');
copyFileSync(found, outPath);
}
// manifest for the gallery
const manPath = join(ROOT, 'data', 'reels.json');
const man = existsSync(manPath) ? JSON.parse(readFileSync(manPath, 'utf8')) : [];
const cap = captionFor(items);
man.unshift({ file: outName, created_at: new Date().toISOString(), products: items.length,
seconds: total, titles: items.map(i => `${i.pattern} / ${i.color}`),
caption: cap.text, hashtags: cap.hashtags,
publish: { instagram: { status: 'pending' }, tiktok: { status: 'pending' } } });
writeFileSync(manPath, JSON.stringify(man, null, 2));
console.log(`✓ reel -> reels/${outName}`);
} else {
console.error('✗ no MP4 produced');
process.exit(2);
}
}
function findMp4(dir) {
let newest = null, mt = 0;
const walk = d => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
const p = join(d, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.mp4')) {
const t = statSync(p).mtimeMs;
if (t > mt) { mt = t; newest = p; }
}
}
};
walk(dir);
return newest;
}
main().catch(e => { console.error('✗', e.message); process.exit(1); });