← back to Marketing Command Center
Add New Arrivals 9:16 short-form video builder (local, $0)
d28190e5a8433f83216f627916901debf74d4a5e · 2026-09-01 07:43:48 -0700 · Steve Abrams
Reusable ffmpeg+ImageMagick reel generator: title card -> per-product
editorial beats (framed swatch + DW wordmark / New Arrival lower-third)
-> CTA card, over a locally-synthesized ambient music bed. TikTok/Reels/
Shorts ready (1080x1920). No paid API, no publish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A scripts/new-arrivals-reel.mjs
Diff
commit d28190e5a8433f83216f627916901debf74d4a5e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 07:43:48 2026 -0700
Add New Arrivals 9:16 short-form video builder (local, $0)
Reusable ffmpeg+ImageMagick reel generator: title card -> per-product
editorial beats (framed swatch + DW wordmark / New Arrival lower-third)
-> CTA card, over a locally-synthesized ambient music bed. TikTok/Reels/
Shorts ready (1080x1920). No paid API, no publish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/new-arrivals-reel.mjs | 281 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 281 insertions(+)
diff --git a/scripts/new-arrivals-reel.mjs b/scripts/new-arrivals-reel.mjs
new file mode 100644
index 0000000..69df093
--- /dev/null
+++ b/scripts/new-arrivals-reel.mjs
@@ -0,0 +1,281 @@
+#!/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';
+
+// ---- 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, '꞉'); }
+
+// ---- 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 (download if missing)
+products.forEach((p, i) => {
+ const f = path.join(SRC, `img-${String(i + 1).padStart(2, '0')}.jpg`);
+ if (!fs.existsSync(f)) {
+ execFileSync('curl', ['-sSL', '-o', f, p.image]);
+ }
+});
+
+// ---- 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) {
+ // Build a standalone transparent lower-third layer (scrim + text)...
+ const lt = path.join(WORK, `lt-${path.basename(basePanel, '.png')}.png`);
+ magick([
+ '-size', `${W}x${H}`, 'xc:none',
+ // lower gradient scrim for legibility
+ '(', '-size', `${W}x560`, 'gradient:none-rgba(20,18,15,0.66)', ')',
+ '-gravity', 'south', '-composite',
+ '-gravity', 'south',
+ // "New Arrival" eyebrow
+ '-font', SANS, '-pointsize', '30', '-fill', ACCENT,
+ '-kerning', '8', '-annotate', '+0+300', 'NEW ARRIVAL',
+ // pattern name (display serif)
+ '-font', SERIF, '-pointsize', '76', '-fill', '#FBF7EE',
+ '-kerning', '1', '-annotate', `+0+200`, esc(name),
+ // colorway (serif)
+ '-font', SERIF_G, '-pointsize', '38', '-fill', '#E6DCC8',
+ '-annotate', '+0+150', esc(colorway || vendor),
+ // 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,
+ ]);
+ // ...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 = path.join(SRC, `img-${String(i + 1).padStart(2, '0')}.jpg`);
+ 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
+ drawLowerThird(panel, name, colorway, p.vendor);
+ 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)');
← 9a0aa08 auto-data-snapshot: 2026-08-31T20:06:26 (1 data files) — pub
·
back to Marketing Command Center
·
auto-data-snapshot: 2026-09-01T07:51:05 (1 data files) — dat b7ffb5f →