← back to Marketing Command Center

scripts/new-arrivals-reel.mjs

343 lines

#!/usr/bin/env node
/**
 * new-arrivals-reel.mjs — DW "New Arrivals" 9:16 short-form video builder.
 *
 * Turns a JSON array of catalog products (title, handle, vendor, image, url)
 * into a vertical (1080x1920) TikTok/Reels/Shorts-ready MP4:
 *   title card -> one editorial beat per product (framed swatch + lower-third
 *   with DW wordmark + "New Arrival") -> CTA card, over a light music bed.
 *
 * $0 / fully local: ffmpeg + ImageMagick only. No paid API, no narration,
 * no publish. Renders to disk; posting stays Steve-gated.
 *
 * Usage:
 *   node new-arrivals-reel.mjs --data /tmp/new-arrivals-10.json --out ~/Videos/dw-new-arrivals-reel
 *
 * The luxury/editorial look is deliberately restrained: warm ivory ground,
 * Didot display serif, hairline rules, gentle Ken Burns, soft crossfades.
 *
 * Author: steve@designerwallcoverings.com
 */
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';

// ---- args -------------------------------------------------------------
const args = Object.fromEntries(
  process.argv.slice(2).reduce((acc, a, i, arr) => {
    if (a.startsWith('--')) acc.push([a.slice(2), arr[i + 1]]);
    return acc;
  }, [])
);
const DATA = args.data || '/tmp/new-arrivals-10.json';
const OUT = (args.out || path.join(os.homedir(), 'Videos/dw-new-arrivals-reel'))
  .replace(/^~/, os.homedir());
const SRC = path.join(OUT, 'src');   // pre-downloaded img-01.jpg ... (or downloaded here)
const PREP = path.join(OUT, 'prep'); // per-beat rendered panels
const WORK = path.join(OUT, 'work'); // intermediate clips

// ---- design tokens ----------------------------------------------------
const W = 1080, H = 1920, FPS = 30;
const BG = '#F2EBDF';          // warm ivory ground
const INK = '#2A2622';         // near-black warm ink
const ACCENT = '#8A7A5C';      // muted brass/taupe
const RULE = '#C9BCA3';        // hairline rule
const SERIF = '/System/Library/Fonts/Supplemental/Didot.ttc';
const SERIF_G = '/System/Library/Fonts/Supplemental/Georgia.ttf';
const SANS = '/System/Library/Fonts/Supplemental/Futura.ttc';

const TITLE_SEC = 2.6;         // opening card
const BEAT_SEC = 2.2;          // per product
const CTA_SEC = 3.0;           // closing card
const XFADE = 0.5;             // crossfade duration

const magick = (a) => execFileSync('magick', a, { stdio: ['ignore', 'ignore', 'inherit'] });
const ff = (a) => execFileSync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...a], { stdio: ['ignore', 'ignore', 'inherit'] });

// ---- helpers ----------------------------------------------------------
// Clean up messy catalog titles: strip " | Sanderson", fix "&Amp;" glitch,
// split "Pattern Name Colorway" into a name + colorway line heuristically.
function parseTitle(raw) {
  let t = raw.replace(/\s*\|\s*[^|]+$/, '').trim();       // drop vendor suffix
  t = t.replace(/&/gi, '&').replace(/\s*&\s*/g, ' & ');
  t = t.replace(/\(Wallcovering\)/gi, '').replace(/\s{2,}/g, ' ').trim();
  // de-dupe an accidental "Roses & Roses" style repeat
  t = t.replace(/\b(\w+)\s+&\s+\1\b/gi, '$1');
  return t;
}

function esc(s) { return s.replace(/'/g, "’").replace(/:/g, '꞉'); }

// Resolve the colorway-accurate hero URL for a product. The verified source
// data (na10-verified.json) carries `correct_image_url` — a byte-distinct
// Sanderson CDN JPEG per colorway. Fall back to legacy `image`/`image_url`
// only if the verified field is absent.
function heroUrl(p) {
  return p.correct_image_url || p.image_url || p.image || p.hero || '';
}
// DW/Shopify storefront SKU (primary) + Sanderson article code (secondary).
function skuLines(p) {
  const dw = p.shopify_sku || p.sku || '';
  const mfr = p.sanderson_sku_authoritative || p.mfr_sku || '';
  return { dw, mfr };
}

// ---- load data --------------------------------------------------------
const products = JSON.parse(fs.readFileSync(DATA, 'utf8'));
if (!Array.isArray(products) || !products.length) throw new Error('no products in ' + DATA);
[PREP, WORK].forEach((d) => fs.mkdirSync(d, { recursive: true }));
fs.mkdirSync(SRC, { recursive: true });

// ensure images present. IMPORTANT (bug-fix): the FIRST cut reused shared /
// wrong-colorway heroes because the cache was keyed by index (img-01.jpg …)
// and a stale wrong file short-circuited the download. We now key each cached
// file by a hash of its resolved URL, so a changed colorway URL always
// re-fetches, and index-01 can never accidentally serve a prior batch's bytes.
const beatSrc = products.map((p, i) => {
  const url = heroUrl(p);
  if (!url) throw new Error(`no image url for beat ${i + 1} (${p.title})`);
  const key = crypto.createHash('sha1').update(url).digest('hex').slice(0, 12);
  const f = path.join(SRC, `img-${String(i + 1).padStart(2, '0')}-${key}.jpg`);
  if (!fs.existsSync(f) || fs.statSync(f).size < 1024) {
    execFileSync('curl', ['-sSL', '--fail', '-o', f, url]);
  }
  return f;
});

// Assert every colorway hero is BYTE-distinct (the exact defect that shipped
// the first cut: img-02/03/04 all 55656 bytes, img-08/09/10 all 114734).
{
  const seen = new Map();
  beatSrc.forEach((f, i) => {
    const sum = crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
    if (seen.has(sum)) {
      throw new Error(
        `DUPLICATE hero: beat ${i + 1} (${products[i].title}) is byte-identical to ` +
        `beat ${seen.get(sum) + 1} (${products[seen.get(sum)].title}). ` +
        `correct_image_url did not resolve to a distinct colorway image.`
      );
    }
    seen.set(sum, i);
  });
  process.stdout.write(`  ✓ ${beatSrc.length} colorway heroes verified byte-distinct\n`);
}

// ---- build the DW wordmark strip (reused on every lower-third) ---------
// "DESIGNER WALLCOVERINGS" — spaced small-caps sans, hairline rule above.
function drawLowerThird(basePanel, name, colorway, vendor, sku = {}) {
  // Build a standalone transparent lower-third layer (scrim + text)...
  const lt = path.join(WORK, `lt-${path.basename(basePanel, '.png')}.png`);
  // SKU lines: primary = DW/Shopify storefront SKU (small caps, muted);
  // secondary = Sanderson article code (lighter, one tidy line). Only render
  // what exists so we never leave a stray "SKU " with no value.
  const skuMain = sku.dw ? `SKU ${sku.dw}` : '';
  const skuSecondary = sku.mfr ? `Sanderson ${sku.mfr}` : '';
  const a = [
    '-size', `${W}x${H}`, 'xc:none',
    // lower gradient scrim for legibility (slightly taller to seat the SKU line)
    '(', '-size', `${W}x600`, 'gradient:none-rgba(18,16,13,0.74)', ')',
    '-gravity', 'south', '-composite',
    '-gravity', 'south',
    // "New Arrival" eyebrow
    '-font', SANS, '-pointsize', '30', '-fill', ACCENT,
    '-kerning', '8', '-annotate', '+0+320', 'NEW ARRIVAL',
    // pattern name (display serif)
    '-font', SERIF, '-pointsize', '76', '-fill', '#FBF7EE',
    '-kerning', '1', '-annotate', `+0+220`, esc(name),
    // colorway (serif)
    '-font', SERIF_G, '-pointsize', '38', '-fill', '#E6DCC8',
    '-annotate', '+0+170', esc(colorway || vendor),
  ];
  // SKU line — small-caps spec label, wide-kerned. Warm cream (not the muted
  // brass) so the storefront identifier stays legible over EVERY swatch tone,
  // including the pale ivory grounds where brass-on-scrim washes out.
  if (skuMain) {
    a.push('-font', SANS, '-pointsize', '27', '-fill', '#EBDFC6',
      '-kerning', '4', '-annotate', '+0+120', esc(skuMain));
  }
  // Sanderson article — lighter secondary, tucked just under the DW SKU.
  if (skuSecondary) {
    a.push('-font', SANS, '-pointsize', '21', '-fill', '#C8BB9E',
      '-kerning', '2', '-annotate', '+0+88', esc(skuSecondary));
  }
  a.push(
    // hairline rule
    '-fill', RULE, '-draw', `rectangle 390,${H - 118} 690,${H - 116}`,
    // DW wordmark
    '-font', SANS, '-pointsize', '26', '-fill', '#D8CDB4',
    '-kerning', '6', '-annotate', '+0+64', 'DESIGNER WALLCOVERINGS',
    lt,
  );
  magick(a);
  // ...then composite it ONTO the base swatch panel (in place).
  magick([basePanel, lt, '-gravity', 'center', '-composite', basePanel]);
}

// ---- render one product beat panel ------------------------------------
// Framed swatch on ivory: swatch inset with a hairline keyline, generous margins.
function renderBeat(i, p) {
  const src = beatSrc[i];   // colorway-accurate, byte-verified hero (see above)
  const panel = path.join(PREP, `beat-${String(i + 1).padStart(2, '0')}.png`);
  const { name, colorway } = splitName(parseTitle(p.title));
  const swatch = 812;        // swatch size on the ivory ground
  const top = 300;           // top margin for the swatch
  // 1) ivory ground + framed swatch with keyline
  magick([
    '-size', `${W}x${H}`, `xc:${BG}`,
    // subtle top eyebrow rule + "Designer Wallcoverings New Arrivals" running head
    '-gravity', 'north',
    '-font', SANS, '-pointsize', '24', '-fill', ACCENT, '-kerning', '5',
    '-annotate', '+0+150', 'NEW ARRIVALS',
    '-fill', RULE, '-draw', `rectangle 470,205 610,207`,
    // swatch: cover-fit into square, then keyline frame
    '(', src, '-resize', `${swatch}x${swatch}^`, '-gravity', 'center',
    '-extent', `${swatch}x${swatch}`,
    '-bordercolor', '#FFFFFF', '-border', '10',
    '-bordercolor', RULE, '-border', '2', ')',
    '-gravity', 'north', '-geometry', `+0+${top}`, '-composite',
    panel,
  ]);
  // 2) lower-third overlay (now carries the SKU line)
  drawLowerThird(panel, name, colorway, p.vendor, skuLines(p));
  return panel;
}

// Heuristic: last token(s) that look like a colorway vs the pattern name.
// Known colorway words for this batch; falls back to splitting on the last word.
const COLORWORDS = /\b(Eggshell|Silver Grey|Indigo|Birch|Barley|China Blue|Amanpuri Red|Mist\/Ivory|Chalk\/Sepia|Blue\/Grey|Grey|Blue|Red|Ivory|Sepia|Mist|Chalk|Silver)\b/i;
function splitName(t) {
  const m = t.match(COLORWORDS);
  if (m && m.index > 0) {
    return { name: t.slice(0, m.index).trim().replace(/\s+&$/, ''), colorway: t.slice(m.index).trim() };
  }
  return { name: t, colorway: '' };
}

// ---- title + CTA cards ------------------------------------------------
function renderTitleCard() {
  const f = path.join(PREP, 'card-title.png');
  magick([
    '-size', `${W}x${H}`, `xc:${BG}`,
    '-gravity', 'center',
    '-font', SANS, '-pointsize', '30', '-fill', ACCENT, '-kerning', '10',
    '-annotate', '+0-360', 'DESIGNER WALLCOVERINGS',
    '-fill', RULE, '-draw', 'rectangle 440,660 640,662',
    '-font', SERIF, '-pointsize', '132', '-fill', INK, '-kerning', '2',
    '-annotate', '+0-70', 'New',
    '-annotate', '+0+90', 'Arrivals',
    '-font', SERIF_G, '-pointsize', '40', '-fill', ACCENT,
    '-annotate', '+0+320', 'The latest to the collection',
    '-fill', RULE, '-draw', 'rectangle 490,1300 590,1302',
    f,
  ]);
  return f;
}
function renderCtaCard() {
  const f = path.join(PREP, 'card-cta.png');
  magick([
    '-size', `${W}x${H}`, `xc:${INK}`,
    '-gravity', 'center',
    '-font', SANS, '-pointsize', '28', '-fill', ACCENT, '-kerning', '9',
    '-annotate', '+0-300', 'DESIGNER WALLCOVERINGS',
    '-fill', RULE, '-draw', 'rectangle 460,700 620,702',
    '-font', SERIF, '-pointsize', '96', '-fill', '#FBF7EE', '-kerning', '1',
    '-annotate', '+0-40', 'Shop New',
    '-annotate', '+0+80', 'Arrivals',
    '-font', SERIF_G, '-pointsize', '44', '-fill', '#E6DCC8',
    '-annotate', '+0+320', 'designerwallcoverings.com',
    f,
  ]);
  return f;
}

// ---- Ken Burns clip from a still --------------------------------------
// gentle zoom so each beat breathes; deterministic, seek-safe.
function toClip(png, sec, out, zoomIn = true) {
  const frames = Math.round(sec * FPS);
  // very subtle zoom (1.0 -> 1.06) centered. d=1 so zoompan emits exactly one
  // output frame per (looped) input frame — the -r/-t pair controls length,
  // avoiding the d=frames over-production that inflates duration.
  const prog = `on/${frames}`;
  const z = zoomIn
    ? `zoompan=z='min(1.0+${prog}*0.06,1.06)':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${W}x${H}`
    : `zoompan=z='max(1.06-${prog}*0.06,1.0)':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${W}x${H}`;
  ff(['-loop', '1', '-t', String(sec), '-r', String(FPS), '-i', png,
    '-vf', z, '-frames:v', String(frames),
    '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-preset', 'medium', out]);
}

// ---- build all clips --------------------------------------------------
const clips = [];
const durs = [];

const titleClip = path.join(WORK, 'clip-000-title.mp4');
toClip(renderTitleCard(), TITLE_SEC, titleClip, true);
clips.push(titleClip); durs.push(TITLE_SEC);

products.forEach((p, i) => {
  const panel = renderBeat(i, p);
  const c = path.join(WORK, `clip-${String(i + 1).padStart(3, '0')}.mp4`);
  toClip(panel, BEAT_SEC, c, i % 2 === 0);
  clips.push(c); durs.push(BEAT_SEC);
  process.stdout.write(`  beat ${i + 1}/${products.length} rendered\n`);
});

const ctaClip = path.join(WORK, 'clip-999-cta.mp4');
toClip(renderCtaCard(), CTA_SEC, ctaClip, false);
clips.push(ctaClip); durs.push(CTA_SEC);

// ---- xfade chain ------------------------------------------------------
// total visual duration = sum(durs) - (n-1)*XFADE
const inputs = clips.flatMap((c) => ['-i', c]);
let filter = '';
let prev = '0:v';
let offset = durs[0] - XFADE;
for (let i = 1; i < clips.length; i++) {
  const out = i === clips.length - 1 ? 'vout' : `x${i}`;
  filter += `[${prev}][${i}:v]xfade=transition=fade:duration=${XFADE}:offset=${offset.toFixed(3)}[${out}];`;
  prev = out;
  offset += durs[i] - XFADE;
}
filter = filter.replace(/;$/, '');
const totalDur = durs.reduce((a, b) => a + b, 0) - (clips.length - 1) * XFADE;

const silentVideo = path.join(WORK, 'reel-silent.mp4');
ff([...inputs, '-filter_complex', filter, '-map', '[vout]',
  '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-preset', 'medium',
  '-r', String(FPS), silentVideo]);

// ---- music bed (synthesized locally, brand-appropriate ambient pad) ----
// Two soft detuned sine partials + slow tremolo = a restrained, unobtrusive
// pad. 100% local (ffmpeg lavfi), $0, royalty-free by construction.
const music = path.join(WORK, 'bed.wav');
const md = totalDur.toFixed(2);
ff(['-f', 'lavfi', '-i',
  `sine=frequency=220:duration=${md}`,
  '-f', 'lavfi', '-i', `sine=frequency=277.18:duration=${md}`, // C#/major-third-ish
  '-f', 'lavfi', '-i', `sine=frequency=329.63:duration=${md}`,
  '-filter_complex',
  `[0:a]volume=0.30[a0];[1:a]volume=0.18[a1];[2:a]volume=0.12[a2];` +
  `[a0][a1][a2]amix=inputs=3:normalize=0,` +
  `tremolo=f=0.15:d=0.5,` +
  `lowpass=f=900,` +
  `aformat=channel_layouts=stereo,` +
  `afade=t=in:st=0:d=1.2,afade=t=out:st=${(totalDur - 2).toFixed(2)}:d=2.0[aout]`,
  '-map', '[aout]', '-ac', '2', '-ar', '44100', music]);

const FINAL = path.join(OUT, 'dw-new-arrivals-reel-9x16.mp4');
ff(['-i', silentVideo, '-i', music,
  '-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k', '-ar', '44100',
  '-shortest', '-map', '0:v:0', '-map', '1:a:0', FINAL]);

// ---- report -----------------------------------------------------------
const probe = execFileSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
  '-show_entries', 'stream=width,height,duration', '-of', 'default=noprint_wrappers=1',
  FINAL]).toString().trim();
console.log('\n=== DONE ===');
console.log('FINAL:', FINAL);
console.log(probe);
console.log('total video duration (computed):', totalDur.toFixed(2), 's');
console.log('💸 render (ffmpeg + ImageMagick, local music bed) — $0 (local)');