← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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); });