← back to Dw Marketing Reels
Add shop-by-color reel builder — opens on the color wheel (frame-0 thumbnail fix)
8c77003772bd0a41cd2747060b91176042fefc6c · 2026-07-17 10:21:55 -0700 · Steve
Files touched
M data/reels.jsonA scripts/build-colorwheel-reel.mjs
Diff
commit 8c77003772bd0a41cd2747060b91176042fefc6c
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 17 10:21:55 2026 -0700
Add shop-by-color reel builder — opens on the color wheel (frame-0 thumbnail fix)
---
data/reels.json | 49 ++++++
scripts/build-colorwheel-reel.mjs | 324 ++++++++++++++++++++++++++++++++++++++
2 files changed, 373 insertions(+)
diff --git a/data/reels.json b/data/reels.json
index a7bea89..639c263 100644
--- a/data/reels.json
+++ b/data/reels.json
@@ -1,4 +1,53 @@
[
+ {
+ "file": "shop-by-color-2026-07-17-17-20.mp4",
+ "created_at": "2026-07-17T17:20:47.789Z",
+ "kind": "shop-by-color",
+ "buckets": [
+ "Blues",
+ "Greens",
+ "Pinks & Reds",
+ "Warm Neutrals",
+ "Black & Grey"
+ ],
+ "products": 14,
+ "seconds": 22,
+ "titles": [
+ "Diamond Head / Light Blue",
+ "Diamond Head / Navy",
+ "Daintree / Blue on White",
+ "Diamond Head / Chartreuse",
+ "Floral Chinoiserie / Green",
+ "Maranta Arrowroot / Pale Green",
+ "Floral Fantasy / Soft Pink",
+ "Floral Fantasy / Pink",
+ "Daintree / Fuchsia",
+ "Daintree / Pearl",
+ "Gleniffer / Pearl",
+ "Floral Fantasy / Gray",
+ "Floral Fantasy / Black",
+ "Floral Elegance / Gray"
+ ],
+ "caption": "Spin the wheel, find your hue 🎨 Shop our wallcoverings by color — from blues, greens, pinks & reds & more.\n\nExplore the whole palette → designerwallcoverings.com/pages/colors 🔗\n\n#DesignerWallcoverings #ShopByColor #Wallcovering #InteriorDesign #ColorInspo #HomeDesign #LuxuryInteriors #DesignInspo",
+ "hashtags": [
+ "#DesignerWallcoverings",
+ "#ShopByColor",
+ "#Wallcovering",
+ "#InteriorDesign",
+ "#ColorInspo",
+ "#HomeDesign",
+ "#LuxuryInteriors",
+ "#DesignInspo"
+ ],
+ "publish": {
+ "instagram": {
+ "status": "pending"
+ },
+ "tiktok": {
+ "status": "pending"
+ }
+ }
+ },
{
"file": "dw-brand-promo-short-vertical-20260717.mp4",
"aspect": "1080x1920",
diff --git a/scripts/build-colorwheel-reel.mjs b/scripts/build-colorwheel-reel.mjs
new file mode 100644
index 0000000..c257419
--- /dev/null
+++ b/scripts/build-colorwheel-reel.mjs
@@ -0,0 +1,324 @@
+#!/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';
+
+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 names that must NEVER surface publicly (mirrors build-reel.mjs).
+const LEAK_DENY = /greenland|wallquest|chesapeake|nextwall|seabrook|brewster|command\s*54|momentum|versa|desima|carlsten|nicolette\s*mayer|york\s*wall/i;
+const safe = s => s && !LEAK_DENY.test(s);
+
+// 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 { return false; }
+}
+
+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 n = 0;
+ for (const B of buckets) for (const it of B.items) {
+ const dest = join(imgDir, `c${n}.jpg`);
+ const ok = (await dl(it.image_hi, dest)) || (await dl(it.image, dest));
+ it._local = ok ? `assets/cw/c${n}.jpg` : it.image;
+ console.log(` ${ok ? '✓' : '↯'} ${B.key} · ${it.pattern}`);
+ n++;
+ }
+
+ 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)`);
+}
+
+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); });
← 579b3f6 chore: v0.5.0 (session close) — Video Studio + color-index h
·
back to Dw Marketing Reels
·
add public /showcase brand-films gallery page (all 6 promo c 42687b8 →