[object Object]

← back to Dw Marketing Reels

reels: novasuede 'new colour 3x/week' generator + account rotation (jewelry + music)

d7e908b320eefb038daffdf5088e9756071dbb54 · 2026-08-10 17:08:10 -0700 · Steve

build-novasuede-color-post.mjs picks the next unposted colour (rotation state) and the next
target account round-robin across [novasuede, suedewallpaper, textilewallpaper,
commercialwallcoverings, designerwallcoverings], builds a 15s post (jewelry-display hero +
suede swatch + samples-free outro) with a jazz MUSIC bed, first frame = jewelry display.
First drop LIVE: Acorn → @suedewallpaper (novasuede skipped: pending-creds).

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

Files touched

Diff

commit d7e908b320eefb038daffdf5088e9756071dbb54
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 17:08:10 2026 -0700

    reels: novasuede 'new colour 3x/week' generator + account rotation (jewelry + music)
    
    build-novasuede-color-post.mjs picks the next unposted colour (rotation state) and the next
    target account round-robin across [novasuede, suedewallpaper, textilewallpaper,
    commercialwallcoverings, designerwallcoverings], builds a 15s post (jewelry-display hero +
    suede swatch + samples-free outro) with a jazz MUSIC bed, first frame = jewelry display.
    First drop LIVE: Acorn → @suedewallpaper (novasuede skipped: pending-creds).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/build-novasuede-color-post.mjs | 129 +++++++++++++++++++++++++++++++++
 1 file changed, 129 insertions(+)

diff --git a/scripts/build-novasuede-color-post.mjs b/scripts/build-novasuede-color-post.mjs
new file mode 100644
index 0000000..42bf1db
--- /dev/null
+++ b/scripts/build-novasuede-color-post.mjs
@@ -0,0 +1,129 @@
+#!/usr/bin/env node
+// build-novasuede-color-post.mjs — "new color 3x/week" generator.
+// Picks the NEXT unposted Novasuede color (rotation state), builds a ~15s vertical POST:
+//   jewelry-display hero → suede swatch → jewelry detail → samples-free outro,
+// with a MUSIC bed (no voiceover). First frame = the jewelry-display image (real product context).
+// Marks the color posted in data/novasuede-post-state.json. $0 render (HyperFrames + ffmpeg).
+import { writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } 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', 'nscp');
+mkdirSync(A, { recursive: true });
+const NS = process.env.NS_DIR || join(ROOT, '..', '_prod-mirror', 'novasuede');
+const MUSIC = process.env.NS_MUSIC || join(WS, 'audio', 'jazz.wav');
+const STATE = join(ROOT, 'data', 'novasuede-post-state.json');
+const TOTAL = Number(process.env.NS_TARGET || 15);
+
+const colors = (() => {
+  const d = JSON.parse(readFileSync(join(NS, 'colors-data.json'), 'utf8'));
+  return Array.isArray(d) ? d : (d.colors || []);
+})();
+const hasJewel = s => existsSync(join(NS, 'images', 'jewelry', `novasuede-${s}.jpg`));
+const hasSwatch = s => existsSync(join(NS, 'images', 'colors', `novasuede-${s}.jpg`));
+
+const state = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : { posted: [], acctIdx: 0 };
+const done = new Set(state.posted);
+// ROTATE between all target accounts (Steve): round-robin — each run posts to the next account.
+const ACCOUNTS = (process.env.NS_ACCOUNTS || 'novasuede,suedewallpaper,textilewallpaper,commercialwallcoverings,designerwallcoverings')
+  .split(',').map(s => s.trim().replace(/^@/, '')).filter(Boolean);
+const acctIdx = (state.acctIdx || 0) % ACCOUNTS.length;
+const account = ACCOUNTS[acctIdx];
+// deterministic rotation: colors-data order, first eligible unposted color
+const next = process.env.NS_SLUG
+  ? colors.find(c => c.slug === process.env.NS_SLUG)
+  : colors.find(c => !done.has(c.slug) && hasJewel(c.slug) && hasSwatch(c.slug));
+if (!next) { console.error('✗ no eligible unposted color left (all posted or missing assets)'); process.exit(3); }
+const slug = next.slug;
+const title = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
+const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
+
+copyFileSync(join(NS, 'images', 'jewelry', `novasuede-${slug}.jpg`), join(A, 'jewel.jpg'));
+copyFileSync(join(NS, 'images', 'colors', `novasuede-${slug}.jpg`), join(A, 'swatch.jpg'));
+
+const INTRO = 4, MID1 = 4, MID2 = 4, OUTRO = TOTAL - 12; // 3s outro at 15s
+const S = [
+  { at: 0, d: INTRO, img: 'jewel.jpg', kick: 'Novasuede™ · New Colour', big: esc(title), sub: 'Microfiber suede' },
+  { at: INTRO, d: MID1, img: 'swatch.jpg', kick: 'The colour', big: esc(title), sub: `Soft · durable · ${esc(next.hex)}` },
+  { at: INTRO + MID1, d: MID2, img: 'jewel.jpg', kick: 'In the display case', big: 'Jewelry-case ready', sub: 'Cases · walls · drapery' },
+];
+const OUTRO_AT = INTRO + MID1 + MID2;
+
+const scenes = S.map((s, i) => `
+    <div class="scene" id="s${i}">
+      <img class="ph" src="assets/nscp/${s.img}" alt="Novasuede ${esc(title)} suede — jewelry display and swatch" />
+      <div class="scrim"></div>
+      <div class="cap"><div class="kick">${s.kick}</div><div class="big">${s.big}</div><div class="sub">${s.sub}</div></div>
+    </div>`).join('');
+const anim = S.map((s, i) => `
+    gsap.set("#s${i}", { opacity: ${i === 0 ? 1 : 0} });
+    gsap.set("#s${i} .ph", { scale: 1.1 });
+    gsap.set("#s${i} .cap", { y: 40, opacity: 0 });
+    ${i === 0 ? '' : `tl.to("#s${i}", { opacity: 1, duration: 0.5 }, ${s.at});`}
+    tl.to("#s${i} .ph", { scale: 1.0, duration: ${s.d}, ease: "none" }, ${s.at});
+    tl.to("#s${i} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(s.at + 0.3).toFixed(2)});
+    tl.to("#s${i}", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${(s.at + s.d - 0.5).toFixed(2)});`).join('\n');
+
+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,.30) 0%,rgba(0,0,0,.05) 42%,rgba(0,0,0,.68) 100%);}
+  .cap{position:absolute;left:72px;right:72px;bottom:180px;}
+  .cap .kick{font-size:32px;letter-spacing:.34em;text-transform:uppercase;color:#DDAB1E;}
+  .cap .big{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:104px;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:72px;line-height:1.1;color:#fff;margin:0 72px 34px;}
+  #outro .samples b{color:#DDAB1E;font-style:italic;}
+  #outro .rule{width:120px;height:2px;background:#DDAB1E;margin:6px 0 34px;}
+  #outro .brand{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:70px;color:#f5f1e8;}
+  #outro .url{margin-top:22px;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(`✓ composition written — colour "${title}" (${TOTAL}s, first frame = jewelry display)`);
+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-colour-${slug}-${stamp}.mp4`;
+const outPath = join(ROOT, 'reels', outName);
+// MUSIC bed: loop to length, gentle volume, 2s fade-out; NO voiceover.
+const fadeSt = Math.max(0, TOTAL - 2);
+execSync(`ffmpeg -y -i ${JSON.stringify(found)} -stream_loop -1 -i ${JSON.stringify(MUSIC)} -filter_complex "[1:a]volume=0.55,afade=t=out:st=${fadeSt}:d=2[a]" -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 160k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
+
+// record rotation state
+state.posted = [...new Set([...(state.posted || []), slug])];
+state.acctIdx = (acctIdx + 1) % ACCOUNTS.length;              // advance rotation for the next run
+state.last = { slug, title, account, file: outName, built_at: new Date().toISOString() };
+writeFileSync(STATE, JSON.stringify(state, null, 2));
+console.log(`✓ novasuede colour post -> reels/${outName}  → @${account}  (posted ${state.posted.length}, nextAcct=@${ACCOUNTS[state.acctIdx]})`);
+console.log(JSON.stringify({ file: outName, account }));

← ae7b91d reels: build-novasuede.mjs — 15s Novasuede color-story reel  ·  back to Dw Marketing Reels  ·  auto-data-snapshot: 2026-08-10T17:11:22 (1 data files) — dat 1651946 →