← back to Dw Marketing Reels
scripts/build-colorwheel-reel.mjs
325 lines
#!/usr/bin/env node
// Build a portrait "Shop by Color" reel that OPENS on the spinning color wheel
// (frame 0 = a vivid full-bleed wheel, so the Instagram grid thumbnail is the wheel,
// NOT a blank card — the fix for the blank-reel complaint 2026-07-17).
//
// Story: wheel spins → the center hub fills with a hue → real DW wallcoverings in that
// color pop in → repeat across a few hue buckets → outro "Shop by Color" + /pages/colors.
//
// Source data = data/new-arrivals.json (real live products, bucketed by color name).
// $0 — local ffmpeg/Chrome render via HyperFrames, no paid API. Mirrors build-reel.mjs
// conventions (esc / dl / findMp4 / silent-AAC mux / LEAK_DENY / reels.json manifest).
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 { safe } from './leak-deny.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WS = join(ROOT, 'hf-workspace');
const SLUG = 'shop-by-color';
// timing (seconds)
const HERO = 3.0; // opening wheel hold
const PHASE = 3.2; // per hue bucket
const OUTRO = 3.0;
const PER_CARD = 3; // max product cards shown per hue
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// Private-label / upstream leak filter (safe) imported from ./leak-deny.mjs — single
// source synced to canonical dw-leak-scanner.
// HSL→hex at l=0.5, matching the live shop-by-color widget's wheel math.
function hsl2hex(h, s, l = 0.5) {
h = ((h % 360) + 360) % 360 / 360;
let r, g, b;
if (s === 0) { r = g = b = l; }
else {
const q = l < .5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
const t = a => { if (a < 0) a += 1; if (a > 1) a -= 1;
if (a < 1 / 6) return p + (q - p) * 6 * a; if (a < 1 / 2) return q;
if (a < 2 / 3) return p + (q - p) * (2 / 3 - a) * 6; return p; };
r = t(h + 1 / 3); g = t(h); b = t(h - 1 / 3);
}
return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('');
}
// Hue buckets, ordered vivid→neutral. Each product's color name (or pattern) is bucketed
// into the FIRST matching bucket; buckets with no product are dropped.
const BUCKETS = [
{ key: 'Blues', hue: 210, sat: .60, rx: /blue|navy|aqua|indigo|teal|bluemoon|sky|cobalt|denim/i },
{ key: 'Greens', hue: 135, sat: .52, rx: /green|chartreuse|olive|sage|moss|emerald|fern/i },
{ key: 'Pinks & Reds', hue: 342, sat: .55, rx: /pink|rose|fuchsia|magenta|\bred\b|coral|blush|berry|raspberry/i },
{ key: 'Warm Neutrals',hue: 34, sat: .30, rx: /gold|amber|cream|beige|taupe|tan|sand|camel|honey|wheat|pearl/i },
{ key: 'Black & Grey', hue: 0, sat: .04, rx: /black|gray|grey|charcoal|slate|silver|ink|onyx/i },
];
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 (e) { console.warn(` dl failed ${url}: ${e.message}`); return false; }
}
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;
}
async function main() {
const data = JSON.parse(readFileSync(join(ROOT, 'data', 'new-arrivals.json'), 'utf8'));
const items = (data.items || []).filter(i => (i.image_hi || i.image) && safe(i.pattern) && safe(i.vendor));
// bucket products by color-name → first matching hue bucket
const used = new Set();
const buckets = [];
for (const B of BUCKETS) {
const picks = [];
for (const it of items) {
if (used.has(it.id)) continue;
const hay = `${it.color || ''} ${it.pattern || ''} ${it.title || ''}`;
if (B.rx.test(hay)) { picks.push(it); used.add(it.id); if (picks.length >= PER_CARD) break; }
}
if (picks.length) buckets.push({ ...B, hex: hsl2hex(B.hue, B.sat), items: picks });
}
if (buckets.length < 2) throw new Error('not enough color buckets from new-arrivals.json');
// download every card image locally for a deterministic render
const imgDir = join(WS, 'assets', 'cw');
mkdirSync(imgDir, { recursive: true });
let imgIdx = 0;
for (const B of buckets) for (const it of B.items) {
const dest = join(imgDir, `c${imgIdx}.jpg`);
const ok = (await dl(it.image_hi, dest)) || (await dl(it.image, dest));
it._local = ok ? `assets/cw/c${imgIdx}.jpg` : it.image;
console.log(` ${ok ? '✓' : '↯'} ${B.key} · ${it.pattern}`);
imgIdx++;
}
const total = +(HERO + buckets.length * PHASE + OUTRO).toFixed(2);
// ── per-phase markup: hue label + product cards ──
const phases = buckets.map((B, pi) => {
const cards = B.items.map((it, ci) => `
<figure class="cw-card" id="p${pi}c${ci}">
<img src="${esc(it._local)}" alt="${esc(it.pattern)}"/>
<figcaption><b>${esc(it.pattern)}</b><i>${esc(it.vendor || '')}</i></figcaption>
</figure>`).join('');
return `
<div class="cw-phase" id="phase${pi}">
<div class="cw-huelabel"><span class="cw-dot" style="background:${B.hex}"></span>${esc(B.key)}</div>
<div class="cw-cards">${cards}</div>
</div>`;
}).join('');
// ── GSAP timeline (seek-safe; every property is a function of time) ──
const spinTurns = 3;
const phaseAnim = buckets.map((B, pi) => {
const at = +(HERO + pi * PHASE).toFixed(3);
const cards = B.items.map((_, ci) =>
`tl.fromTo("#p${pi}c${ci}", { y: 60, opacity: 0, scale: .96 }, { y: 0, opacity: 1, scale: 1, duration: .6, ease: "power3.out" }, ${(at + 0.35 + ci * 0.18).toFixed(3)});`
).join('\n ');
return `
// ${B.key}
tl.to("#hub .cw-sw", { backgroundColor: "${B.hex}", duration: .5, ease: "power1.inout" }, ${at.toFixed(3)});
tl.fromTo("#ring", { scale: .6, opacity: .9 }, { scale: 1.35, opacity: 0, duration: .9, ease: "power2.out" }, ${at.toFixed(3)});
tl.set("#phase${pi}", { visibility: "visible" }, ${at.toFixed(3)});
tl.fromTo("#phase${pi} .cw-huelabel", { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: .5 }, ${(at + 0.1).toFixed(3)});
${cards}
tl.to("#phase${pi}", { opacity: 0, duration: .4, ease: "power1.in" }, ${(at + PHASE - 0.4).toFixed(3)});`;
}).join('\n');
const outroAt = +(HERO + buckets.length * PHASE).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:#181614; --cream:#f4efe6; --gold:#b9892f; --line:#e6e0d6; }
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:radial-gradient(120% 90% at 50% 18%, #241f1b 0%, #131110 60%, #0d0b0a 100%); }
/* header */
.cw-kicker { position:absolute; top:96px; left:0; right:0; text-align:center;
font-size:34px; letter-spacing:.44em; text-transform:uppercase; color:var(--gold); }
.cw-title { position:absolute; top:150px; left:0; right:0; text-align:center;
font-family:"Didot","Bodoni 72",Georgia,serif; font-size:112px; color:#fff; line-height:1; }
/* wheel (frame 0 hero — painted synchronously, visible immediately) */
#wheelwrap { position:absolute; top:360px; left:50%; transform:translateX(-50%);
width:640px; height:640px; }
#wheelspin { position:absolute; inset:0; transform-origin:50% 50%; }
#wheel { width:100%; height:100%; border-radius:50%;
box-shadow:0 24px 80px rgba(0,0,0,.55), inset 0 0 0 2px rgba(255,255,255,.06); }
#ring { position:absolute; inset:-6px; border-radius:50%; border:4px solid #fff; opacity:0; }
/* fixed pointer at 12 o'clock */
#pointer { position:absolute; top:-30px; left:50%; transform:translateX(-50%);
width:0; height:0; border-left:22px solid transparent; border-right:22px solid transparent;
border-top:32px solid #fff; filter:drop-shadow(0 3px 6px rgba(0,0,0,.4)); }
/* center hub swatch = the currently-picked color */
#hub { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
width:210px; height:210px; border-radius:50%; background:#fff;
box-shadow:0 10px 40px rgba(0,0,0,.35), inset 0 0 0 1px var(--line);
display:flex; align-items:center; justify-content:center; }
#hub .cw-sw { width:150px; height:150px; border-radius:50%; background:#cfc8bd;
box-shadow:inset 0 0 0 1px rgba(0,0,0,.14); }
/* lower content zone */
.cw-sub { position:absolute; top:1080px; left:0; right:0; text-align:center;
font-size:36px; letter-spacing:.14em; text-transform:uppercase; color:#cfc7ba; }
.cw-phase { position:absolute; top:1030px; left:0; right:0; visibility:hidden; }
.cw-huelabel { text-align:center; font-size:44px; letter-spacing:.10em; text-transform:uppercase;
color:#fff; display:flex; align-items:center; justify-content:center; gap:20px; }
.cw-huelabel .cw-dot { width:34px; height:34px; border-radius:50%; box-shadow:0 0 0 3px rgba(255,255,255,.15); }
.cw-cards { display:flex; gap:26px; justify-content:center; margin-top:46px; padding:0 60px; }
.cw-card { margin:0; width:300px; background:#fff; border-radius:16px; overflow:hidden;
box-shadow:0 18px 50px rgba(0,0,0,.4); }
.cw-card img { width:100%; height:360px; object-fit:cover; display:block; background:#efeae1; }
.cw-card figcaption { padding:16px 16px 20px; color:var(--ink); }
.cw-card figcaption b { display:block; font-family:"Didot","Bodoni 72",Georgia,serif;
font-size:28px; font-weight:400; line-height:1.1; }
.cw-card figcaption i { display:block; margin-top:8px; font-size:20px; font-style:normal;
letter-spacing:.14em; text-transform:uppercase; color:var(--gold); }
/* outro */
#outro { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
justify-content:center; text-align:center; opacity:0; }
#outro .k { font-size:34px; letter-spacing:.42em; text-transform:uppercase; color:var(--gold); }
#outro .big { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:150px; line-height:.98;
color:#fff; margin:28px 60px; }
#outro .rule { width:120px; height:2px; background:var(--gold); margin:20px 0 34px; }
#outro .url { font-size:32px; letter-spacing:.20em; text-transform:uppercase; color:#e8e2d6; }
.foot { position:absolute; bottom:120px; left:0; right:0; text-align:center;
font-size:28px; letter-spacing:.22em; text-transform:uppercase; color:#8f8578; }
</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="cw-kicker" id="kicker">Designer Wallcoverings</div>
<div class="cw-title" id="title">Shop by Color</div>
<div id="wheelwrap">
<div id="pointer"></div>
<div id="wheelspin"><canvas id="wheel" width="680" height="680"></canvas></div>
<div id="ring"></div>
<div id="hub"><div class="cw-sw"></div></div>
</div>
<div class="cw-sub" id="sub">Spin the wheel · find your hue</div>
${phases}
<div id="outro">
<div class="k">Find your color</div>
<div class="big">Shop<br/>by Color</div>
<div class="rule"></div>
<div class="url">designerwallcoverings.com/pages/colors</div>
</div>
<div class="foot" id="foot">designerwallcoverings.com</div>
<script>
// paint the HSL wheel synchronously — visible at frame 0 (this is the IG thumbnail)
(function(){
var cv=document.getElementById('wheel'),ctx=cv.getContext('2d'),R=cv.width/2;
function h2(h,s,l){h/=360;var r,g,b;if(s===0){r=g=b=l;}else{
var q=l<.5?l*(1+s):l+s-l*s,p=2*l-q;var t=function(a){if(a<0)a+=1;if(a>1)a-=1;
if(a<1/6)return p+(q-p)*6*a;if(a<1/2)return q;if(a<2/3)return p+(q-p)*(2/3-a)*6;return p;};
r=t(h+1/3);g=t(h);b=t(h-1/3);}return[r*255|0,g*255|0,b*255|0];}
var img=ctx.createImageData(cv.width,cv.height),d=img.data;
for(var y=0;y<cv.height;y++)for(var x=0;x<cv.width;x++){
var dx=x-R,dy=y-R,rr=Math.sqrt(dx*dx+dy*dy),i=(y*cv.width+x)*4;
if(rr>R){d[i+3]=0;continue;}
var hh=Math.atan2(dy,dx)*180/Math.PI;if(hh<0)hh+=360;
var c=h2(hh,Math.min(1,rr/R),0.5);
d[i]=c[0];d[i+1]=c[1];d[i+2]=c[2];d[i+3]=255;
}
ctx.putImageData(img,0,0);
})();
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
// continuous wheel spin — seek-safe (rotation is a pure function of timeline time)
gsap.set("#wheelspin", { rotation: 0 });
tl.to("#wheelspin", { rotation: ${360 * spinTurns}, duration: ${total}, ease: "none" }, 0);
// hero → fade header down to make room once the tour starts
tl.to("#sub", { opacity: 0, duration: .4 }, ${(HERO - 0.5).toFixed(2)});
tl.to("#title", { fontSize: "72px", top: "150px", duration: .6, ease: "power2.inout" }, ${(HERO - 0.5).toFixed(2)});
${phaseAnim}
// outro
tl.to("#wheelwrap, #kicker, #title, #foot", { opacity: 0, duration: .5 }, ${(outroAt - 0.3).toFixed(2)});
tl.to("#outro", { opacity: 1, duration: .6 }, ${outroAt.toFixed(2)});
tl.fromTo("#outro .big", { y: 40, opacity: 0 }, { y: 0, opacity: 1, duration: .7, ease: "power2.out" }, ${(outroAt + 0.2).toFixed(2)});
tl.fromTo("#outro .url", { opacity: 0 }, { opacity: 1, duration: .5 }, ${(outroAt + 0.7).toFixed(2)});
window.__timelines["main-video"] = tl;
</script>
</div>
</body>
</html>`;
writeFileSync(join(WS, 'index.html'), html);
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 (${buckets.length} hue buckets, ${total}s portrait)`);
console.log('→ rendering (local ffmpeg/Chrome, $0)…');
execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outName = `${SLUG}-${stamp}.mp4`;
const found = findMp4(WS);
if (!found) { console.error('✗ no MP4 produced'); process.exit(2); }
mkdirSync(join(ROOT, 'reels'), { recursive: true });
const outPath = join(ROOT, 'reels', outName);
// Instagram Reels REQUIRE an audio track (Meta error 2207082 otherwise) — mux silent AAC.
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(' (silent-audio mux failed — raw copy; may not be IG-Reels-publishable)');
copyFileSync(found, outPath);
}
// manifest entry for the gallery + publish tracking
const allItems = buckets.flatMap(b => b.items);
const patterns = [...new Set(allItems.map(i => i.pattern).filter(safe))].slice(0, 4);
const caption = `Spin the wheel, find your hue 🎨 Shop our wallcoverings by color — from `
+ `${buckets.slice(0, 3).map(b => b.key.toLowerCase()).join(', ')} & more.\n\n`
+ `Explore the whole palette → designerwallcoverings.com/pages/colors 🔗\n\n`
+ `#DesignerWallcoverings #ShopByColor #Wallcovering #InteriorDesign #ColorInspo #HomeDesign #LuxuryInteriors #DesignInspo`;
const manPath = join(ROOT, 'data', 'reels.json');
const man = existsSync(manPath) ? JSON.parse(readFileSync(manPath, 'utf8')) : [];
man.unshift({ file: outName, created_at: new Date().toISOString(), kind: 'shop-by-color',
buckets: buckets.map(b => b.key), products: allItems.length, seconds: total,
titles: allItems.map(i => `${i.pattern} / ${i.color}`), caption, hashtags: caption.match(/#\w+/g) || [],
publish: { instagram: { status: 'pending' }, tiktok: { status: 'pending' } } });
writeFileSync(manPath, JSON.stringify(man, null, 2));
console.log(`✓ reel -> reels/${outName} (${allItems.length} products across ${buckets.length} hues)`);
}
main().catch(e => { console.error('✗', e.message); process.exit(1); });