← back to Dw Marketing Reels
scripts/build-reel-1x1.mjs
194 lines
#!/usr/bin/env node
// Build ONE 1:1 (1080x1080) Instagram reel for Designer Wallcoverings "New Arrivals".
// Pulls varied products from data/new-arrivals.json, downloads images locally, composes
// luxe slide frames (Didot titles + gold meta over a bottom scrim) with ImageMagick, then
// assembles a gentle push-in + crossfade slideshow via ffmpeg. $0 — all local.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
import { LEAK_DENY, safe } from './leak-deny.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WORK = join(ROOT, 'reels-1x1-work');
const IMG = join(WORK, 'img');
const FR = join(WORK, 'frames');
mkdirSync(IMG, { recursive: true });
mkdirSync(FR, { recursive: true });
const W = 1080, H = 1080, FPS = 30;
const SLIDE = 2.6, INTRO = 2.2, OUTRO = 2.8, XF = 0.5; // seconds
const DIDOT = '/System/Library/Fonts/Supplemental/Didot.ttc';
const HELV = '/System/Library/Fonts/HelveticaNeue.ttc';
const INK = '#1c1a17', CREAM = '#f4efe6', GOLD = '#b08d57';
// Hand-picked varied indices (vendors: Rebel Walls / Stout / Knoll; murals + wallcoverings;
// colors spanning blue, pink, ink, peacock, sand, tropic green, spring, lilac).
const PICKS = [0, 1, 4, 5, 7, 17, 15, 19];
const decode = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const sh = a => `'${String(a).replace(/'/g, `'\\''`)}'`;
const run = c => execSync(c, { stdio: ['ignore', 'pipe', 'pipe'] });
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 < 2000) return false;
writeFileSync(dest, buf);
return true;
} catch { return false; }
}
function scrim() {
const p = join(WORK, 'scrim.png');
// transparent at top -> deep ink at bottom (luxe bottom-third darkening for text legibility)
run(`magick -size ${W}x${H} gradient:'rgba(0,0,0,0)-rgba(18,16,13,0.86)' ${sh(p)}`);
return p;
}
function slideFrame(i, it) {
const base = join(IMG, `p${i}.src`);
const out = join(FR, `s${String(i).padStart(2, '0')}.jpg`);
const pattern = decode(it.pattern);
const price = it.price ? `$${Number(it.price).toFixed(0)}` : '';
const metaBits = [it.vendor, price].filter(Boolean).map(decode).join(' · ').toUpperCase();
// fill-crop the product image to 1080x1080, lay the scrim over it
run(`magick ${sh(base)} -auto-orient -resize ${W}x${H}^ -gravity center -extent ${W}x${H} ${sh(SC)} -composite ${sh(out + '.tmp.jpg')}`);
// pattern name — wrapped caption in Didot, up from the bottom-left
const capImg = join(FR, `cap${i}.png`);
run(`magick -background none -fill white -font ${sh(DIDOT)} -pointsize 92 -size 900x300 -gravity SouthWest caption:${sh(pattern)} ${sh(capImg)}`);
// gold kicker chip (top-left) + meta line (bottom-left, under the pattern)
run([
`magick ${sh(out + '.tmp.jpg')}`,
// top-left kicker
`-font ${sh(HELV)} -pointsize 30 -fill ${sh(GOLD)} -gravity NorthWest -annotate +74+74 ${sh('N E W A R R I V A L S')}`,
// thin gold rule under kicker
`-fill ${sh(GOLD)} -draw ${sh('rectangle 74,120 174,122')}`,
// pattern caption composited bottom-left
`${sh(capImg)} -gravity SouthWest -geometry +66+150 -composite`,
// meta line
`-font ${sh(HELV)} -pointsize 31 -fill ${sh(GOLD)} -gravity SouthWest -annotate +74+96 ${sh(metaBits)}`,
sh(out),
].join(' '));
return out;
}
function card(kind, lines) {
const out = join(FR, `${kind}.jpg`);
const [kicker, big, sub, url] = lines;
const cmd = [
`magick -size ${W}x${H} xc:${sh(CREAM)}`,
`-gravity Center`,
// kicker
`-font ${sh(HELV)} -pointsize 32 -fill ${sh(GOLD)} -annotate +0-250 ${sh(kicker)}`,
// big Didot title (supports two lines via \n)
`-font ${sh(DIDOT)} -pointsize 132 -fill ${sh(INK)} -annotate +0-70 ${sh(big)}`,
];
// gold rule + subtitle only for single-line cards (skip on the two-line outro to avoid a strikethrough)
if (sub) cmd.push(
`-fill ${sh(GOLD)} -draw ${sh(`rectangle ${W/2-60},${H/2+40} ${W/2+60},${H/2+43}`)}`,
`-font ${sh(HELV)} -pointsize 34 -fill '#5a544c' -annotate +0+130 ${sh(sub)}`,
);
if (url) cmd.push(`-font ${sh(HELV)} -pointsize 30 -fill ${sh(INK)} -gravity South -annotate +0+120 ${sh(url)}`);
cmd.push(sh(out));
run(cmd.join(' '));
return out;
}
function caption(items) {
const pats = [...new Set(items.map(i => decode(i.pattern)).filter(safe))];
const vendors = [...new Set(items.map(i => decode(i.vendor)).filter(safe))];
const lead = pats.slice(0, 3).join(', ');
const body = `New arrivals just landed ✨ ${lead} & more`
+ `${vendors.length ? ` from ${vendors.slice(0, 3).join(', ')}` : ''}.`
+ `\n\nDiscover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗`;
const tagify = s => '#' + String(s).replace(/[^a-z0-9]+/gi, '');
const tags = [...new Set([
'#DesignerWallcoverings', '#NewArrivals', '#Wallcovering', '#Wallcoverings',
'#InteriorDesign', '#InteriorDecor', '#HomeDesign', '#LuxuryInteriors', '#DesignInspo',
'#WallMural', ...vendors.slice(0, 3).map(tagify),
])].slice(0, 14);
const clean = s => s.split(/\s+/).filter(w => !LEAK_DENY.test(w)).join(' ');
return { text: `${clean(body)}\n\n${tags.join(' ')}`, hashtags: tags };
}
const SC = scrim();
async function main() {
const data = JSON.parse(readFileSync(join(ROOT, 'data', 'new-arrivals.json'), 'utf8'));
const items = PICKS.map(idx => data.items[idx]).filter(Boolean);
if (items.length < 6) throw new Error('need >= 6 products');
// download + compose each slide
const slideFrames = [];
for (const [i, it] of items.entries()) {
const dest = join(IMG, `p${i}.src`);
const ok = (await dl(it.image_hi, dest)) || (await dl(it.image, dest));
if (!ok) { console.log(` ! skip ${it.pattern} (no image)`); continue; }
console.log(` img ${i}: ✓ ${decode(it.pattern)}`);
slideFrames.push(slideFrame(i, it));
}
// date subtitle
const dateStr = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
const intro = card('intro', ['N E W A R R I V A L S', 'Just In', `Designer Wallcoverings · ${dateStr}`]);
const outro = card('outro', ['S H O P T H E C O L L E C T I O N', 'Designer\nWallcoverings', '', 'designerwallcoverings.com']);
// assemble ffmpeg: intro + slides + outro, each still with a gentle push-in, crossfaded.
const seq = [{ f: intro, d: INTRO }, ...slideFrames.map(f => ({ f, d: SLIDE })), { f: outro, d: OUTRO }];
const inputs = seq.map(s => `-loop 1 -t ${s.d} -i ${sh(s.f)}`).join(' ');
// per-input: scale/pad safety + zoompan push-in (1.0 -> ~1.07)
let fc = '';
seq.forEach((s, i) => {
const frames = Math.round(s.d * FPS);
fc += `[${i}:v]scale=${W}:${H},setsar=1,`
+ `zoompan=z='min(zoom+0.00035,1.07)':d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${W}x${H}:fps=${FPS}[v${i}];`;
});
// xfade chain
let prev = 'v0', acc = seq[0].d, k = 0;
for (let i = 1; i < seq.length; i++) {
const off = +(acc - XF).toFixed(3);
const lbl = (i === seq.length - 1) ? 'vout' : `x${k}`;
fc += `[${prev}][v${i}]xfade=transition=fade:duration=${XF}:offset=${off}[${lbl}];`;
prev = lbl; acc = +(acc + seq[i].d - XF).toFixed(3); k++;
}
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outName = `new-arrivals-1x1-${stamp}.mp4`;
mkdirSync(join(ROOT, 'reels'), { recursive: true });
const outPath = join(ROOT, 'reels', outName);
// silent AAC track (IG Reels require an audio track), yuv420p, faststart
const cmd = `ffmpeg -y ${inputs} -f lavfi -t ${acc.toFixed(2)} -i anullsrc=channel_layout=stereo:sample_rate=44100 `
+ `-filter_complex ${sh(fc + `[vout]format=yuv420p[vf]`)} -map '[vf]' -map ${seq.length}:a `
+ `-c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p -c:a aac -b:a 128k -shortest -movflags +faststart -r ${FPS} ${sh(outPath)}`;
console.log(`→ rendering ${outName} (${acc.toFixed(1)}s, ${seq.length} frames)…`);
execSync(cmd, { stdio: ['ignore', 'ignore', 'inherit'] });
// caption + manifest
const cap = caption(items);
writeFileSync(join(ROOT, 'reels', outName.replace(/\.mp4$/, '.caption.txt')), cap.text);
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(), format: '1x1', products: items.length,
seconds: +acc.toFixed(2), titles: items.map(i => `${decode(i.pattern)} / ${decode(i.color) || decode(i.vendor)}`),
caption: cap.text, hashtags: cap.hashtags,
publish: { instagram: { status: 'pending' } },
});
writeFileSync(manPath, JSON.stringify(man, null, 2));
console.log(`✓ reel -> reels/${outName}`);
console.log(`✓ caption -> reels/${outName.replace(/\.mp4$/, '.caption.txt')}`);
console.log(`\n--- CAPTION ---\n${cap.text}\n`);
}
main().catch(e => { console.error('✗', e.stack || e.message); process.exit(1); });