[object Object]

← back to Dw Marketing Reels

reels: build-novasuede.mjs — 15s Novasuede color-story reel

ae7b91db6ad363c680274b034c31591c37dddbdb · 2026-08-10 16:58:20 -0700 · Steve

Hero opens on a real suede swatch (most-saturated picked color) — NOT the misleading
Drapery_Drama.jpg lifestyle photo. Color cards from novasuede colors-data.json + local
swatches, 'samples no charge for professionals' outro, Steve cloned-voice VO muxed, first
frame = product swatch (clean IG index thumbnail). 15s (NS_TARGET override).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ae7b91db6ad363c680274b034c31591c37dddbdb
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 16:58:20 2026 -0700

    reels: build-novasuede.mjs — 15s Novasuede color-story reel
    
    Hero opens on a real suede swatch (most-saturated picked color) — NOT the misleading
    Drapery_Drama.jpg lifestyle photo. Color cards from novasuede colors-data.json + local
    swatches, 'samples no charge for professionals' outro, Steve cloned-voice VO muxed, first
    frame = product swatch (clean IG index thumbnail). 15s (NS_TARGET override).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/build-novasuede.mjs | 127 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 127 insertions(+)

diff --git a/scripts/build-novasuede.mjs b/scripts/build-novasuede.mjs
new file mode 100644
index 0000000..ce6869d
--- /dev/null
+++ b/scripts/build-novasuede.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+// build-novasuede.mjs — 15s Novasuede color-story reel: hero intro → N color cards → outro
+// (samples-free), with Steve's cloned-voice VO. First frame is the hero image (clean IG index
+// thumbnail). Assets pulled from the novasuede prod-mirror. $0 render (HyperFrames + ffmpeg).
+import { writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync, readFileSync } from 'node:fs';
+import { execSync } from 'node:child_process';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const WS = join(ROOT, 'hf-workspace');
+const A = join(WS, 'assets', 'ns');
+mkdirSync(A, { recursive: true });
+const NS = process.env.NS_DIR || join(ROOT, '..', '_prod-mirror', 'novasuede');
+const VO = process.env.NS_VO || '/tmp/vo-novasuede.wav';
+const pick = JSON.parse(readFileSync(process.env.NS_PICK || '/tmp/ns-pick.json', 'utf8'));
+const TOTAL = Number(process.env.NS_TARGET || 15);
+
+// assets: hero (a real suede swatch — NOT a lifestyle photo) + one swatch per picked color.
+// Open on the most saturated picked color so frame 0 is unmistakably novasuede suede.
+const heroColor = [...pick].sort((a, b) => (b.hsl?.[1] || 0) - (a.hsl?.[1] || 0))[0];
+copyFileSync(join(NS, 'images', 'colors', `novasuede-${(process.env.NS_HERO_SLUG || heroColor.slug)}.jpg`), join(A, 'hero.jpg'));
+pick.forEach((c, i) => copyFileSync(join(NS, 'images', 'colors', `novasuede-${c.slug}.jpg`), join(A, `c${i}.jpg`)));
+
+const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
+const titleCase = s => s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+
+const INTRO = 3.5, OUTRO = 3.5;
+const PER = +(((TOTAL - INTRO - OUTRO) / pick.length)).toFixed(3);
+
+// scenes: [intro] + one per color
+const scenes = [`
+    <div class="scene" id="s_intro">
+      <img class="ph" src="assets/ns/hero.jpg" alt="Novasuede microfiber suede swatch" />
+      <div class="scrim"></div>
+      <div class="cap">
+        <div class="kick">Novasuede™</div>
+        <div class="big">176 Colours</div>
+        <div class="sub">Luxury microfiber suede</div>
+      </div>
+    </div>`,
+  ...pick.map((c, i) => `
+    <div class="scene" id="s${i}">
+      <img class="ph" src="assets/ns/c${i}.jpg" alt="Novasuede ${esc(titleCase(c.slug))} microfiber suede swatch" />
+      <div class="scrim"></div>
+      <div class="cap">
+        <div class="kick">Novasuede™</div>
+        <div class="big">${esc(titleCase(c.slug))}</div>
+        <div class="sub">Soft · durable · ${esc(c.hex)}</div>
+      </div>
+    </div>`)].join('');
+
+// timeline
+const starts = [0, ...pick.map((_, i) => +(INTRO + i * PER).toFixed(3))];
+const durs = [INTRO, ...pick.map(() => PER)];
+const ids = ['s_intro', ...pick.map((_, i) => `s${i}`)];
+const anim = ids.map((id, i) => {
+  const at = starts[i], d = durs[i], fo = +(at + d - 0.5).toFixed(2);
+  return `
+    gsap.set("#${id}", { opacity: ${i === 0 ? 1 : 0} });
+    gsap.set("#${id} .ph", { scale: 1.1 });
+    gsap.set("#${id} .cap", { y: 40, opacity: 0 });
+    ${i === 0 ? '' : `tl.to("#${id}", { opacity: 1, duration: 0.5 }, ${at});`}
+    tl.to("#${id} .ph", { scale: 1.0, duration: ${d}, ease: "none" }, ${at});
+    tl.to("#${id} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(at + 0.3).toFixed(2)});
+    tl.to("#${id}", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${fo});`;
+}).join('\n');
+const OUTRO_AT = +(INTRO + pick.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>
+  html,body{margin:0;padding:0;width:1080px;height:1920px;overflow:hidden;background:#0e0e10;font-family:"Helvetica Neue",system-ui,sans-serif;}
+  #main-composition{position:relative;width:1080px;height:1920px;overflow:hidden;background:#0e0e10;}
+  .scene{position:absolute;inset:0;}
+  .scene .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,.28) 0%,rgba(0,0,0,.05) 42%,rgba(0,0,0,.70) 100%);}
+  .cap{position:absolute;left:72px;right:72px;bottom:180px;}
+  .cap .kick{font-size:32px;letter-spacing:.36em;text-transform:uppercase;color:#DDAB1E;}
+  .cap .big{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:110px;line-height:1.0;color:#fff;margin:14px 0 12px;text-shadow:0 2px 24px rgba(0,0,0,.6);}
+  .cap .sub{font-size:30px;letter-spacing:.16em;text-transform:uppercase;color:#f5f1e8;}
+  #outro{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:#0e0e10;}
+  #outro .samples{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:74px;line-height:1.1;color:#fff;margin:0 72px 36px;}
+  #outro .samples b{color:#DDAB1E;font-style:italic;}
+  #outro .rule{width:120px;height:2px;background:#DDAB1E;margin:6px 0 36px;}
+  #outro .brand{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:70px;color:#f5f1e8;}
+  #outro .url{margin-top:24px;font-size:32px;letter-spacing:.26em;text-transform:uppercase;color:#DDAB1E;}
+</style></head><body>
+  <div id="main-composition" data-composition-id="main-video" data-width="1080" data-height="1920" data-start="0" data-duration="${TOTAL}">
+    ${scenes}
+    <div id="outro">
+      <div class="samples">Samples are always<br/><b>no charge for professionals</b></div>
+      <div class="rule"></div>
+      <div class="brand">Novasuede™</div>
+      <div class="url">novasuede.com</div>
+    </div>
+    <script>
+      const tl = gsap.timeline({ paused: true });
+      window.__timelines = window.__timelines || {};
+${anim}
+      gsap.set("#outro", { opacity: 0 });
+      gsap.set("#outro .samples", { y: 40, opacity: 0 });
+      gsap.set("#outro .rule, #outro .brand, #outro .url", { opacity: 0 });
+      tl.to("#outro", { opacity: 1, duration: 0.5 }, ${OUTRO_AT});
+      tl.to("#outro .samples", { opacity: 1, y: 0, duration: 0.6, ease: "power2.out" }, ${OUTRO_AT + 0.2});
+      tl.to("#outro .rule, #outro .brand", { opacity: 1, duration: 0.5 }, ${OUTRO_AT + 0.8});
+      tl.to("#outro .url", { opacity: 1, duration: 0.5 }, ${OUTRO_AT + 1.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(`✓ novasuede composition written (${TOTAL}s, ${pick.length} colors, first frame = hero)`);
+execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
+
+function findMp4(d) { let n=null,mt=0; const w=x=>{for(const e of readdirSync(x,{withFileTypes:true})){if(e.name==='node_modules'||e.name.startsWith('.'))continue;const p=join(x,e.name);if(e.isDirectory())w(p);else if(e.name.endsWith('.mp4')){const t=statSync(p).mtimeMs;if(t>mt){mt=t;n=p;}}}};w(d);return n; }
+const found = findMp4(WS); if (!found) { console.error('✗ no mp4'); process.exit(2); }
+const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
+const outName = `novasuede-colors-15s-${stamp}.mp4`;
+const outPath = join(ROOT, 'reels', outName);
+execSync(`ffmpeg -y -i ${JSON.stringify(found)} -i ${JSON.stringify(VO)} -filter_complex "[1:a]apad" -c:v copy -c:a aac -b:a 160k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
+console.log(`✓ novasuede reel -> reels/${outName}`);
+console.log(outName);

← bba1c99 chore: version bump (session close)  ·  back to Dw Marketing Reels  ·  reels: novasuede 'new colour 3x/week' generator + account ro d7e908b →