← back to Dw Marketing Reels
reels: add 1:1 (1080x1080) New Arrivals square-reel pipeline (build-reel-1x1.mjs) — TK-00128
522cc20af9f8aeb2f6e2bf2a957a831aa5713324 · 2026-07-28 14:00:58 -0700 · Steve Abrams
Local $0 ffmpeg/ImageMagick build: 8 varied products from data/new-arrivals.json,
Didot titles + gold meta over scrim, cream intro/outro cards, push-in + crossfades.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M .gitignoreM data/reels.jsonA scripts/build-reel-1x1.mjs
Diff
commit 522cc20af9f8aeb2f6e2bf2a957a831aa5713324
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 14:00:58 2026 -0700
reels: add 1:1 (1080x1080) New Arrivals square-reel pipeline (build-reel-1x1.mjs) — TK-00128
Local $0 ffmpeg/ImageMagick build: 8 varied products from data/new-arrivals.json,
Didot titles + gold meta over scrim, cream intro/outro cards, push-in + crossfades.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 2 +
data/reels.json | 110 ++++++++++++++++++++++++++
scripts/build-reel-1x1.mjs | 193 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 305 insertions(+)
diff --git a/.gitignore b/.gitignore
index 77d0ea6..2bb1990 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,5 @@ videos/*/assets/bgm/
videos/*/assets/voice/
videos/*/node_modules/
Videos/
+reels-1x1-work/
+reels/*.caption.txt
diff --git a/data/reels.json b/data/reels.json
index 6e60dff..991f7ec 100644
--- a/data/reels.json
+++ b/data/reels.json
@@ -1,4 +1,114 @@
[
+ {
+ "file": "new-arrivals-1x1-2026-07-28-21-00.mp4",
+ "created_at": "2026-07-28T21:00:12.373Z",
+ "format": "1x1",
+ "products": 8,
+ "seconds": 21.3,
+ "titles": [
+ "Flowerhaze / Blue",
+ "Dreamy Reed / Pink",
+ "Xander Ink / Stout Textiles",
+ "Vicarro Peacock / Stout Textiles",
+ "Milky Way Desert Sand / Knoll",
+ "Meander By Trove Tropic / Knoll",
+ "Triangle Stars Spring / Stout Textiles",
+ "Vine Scallop Lilac / Stout Textiles"
+ ],
+ "caption": "New arrivals just landed ✨ Flowerhaze, Dreamy Reed, Xander Ink & more from Rebel Walls, Stout Textiles, Knoll. Discover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗\n\n#DesignerWallcoverings #NewArrivals #Wallcovering #Wallcoverings #InteriorDesign #InteriorDecor #HomeDesign #LuxuryInteriors #DesignInspo #WallMural #RebelWalls #StoutTextiles #Knoll",
+ "hashtags": [
+ "#DesignerWallcoverings",
+ "#NewArrivals",
+ "#Wallcovering",
+ "#Wallcoverings",
+ "#InteriorDesign",
+ "#InteriorDecor",
+ "#HomeDesign",
+ "#LuxuryInteriors",
+ "#DesignInspo",
+ "#WallMural",
+ "#RebelWalls",
+ "#StoutTextiles",
+ "#Knoll"
+ ],
+ "publish": {
+ "instagram": {
+ "status": "pending"
+ }
+ }
+ },
+ {
+ "file": "new-arrivals-2026-07-28-20-55.mp4",
+ "created_at": "2026-07-28T20:55:54.420Z",
+ "products": 6,
+ "seconds": 19,
+ "titles": [
+ "Flowerhaze / Blue",
+ "Dreamy Reed / Pink",
+ "Dreamy Reed / Black & White",
+ "Xander Avocado / ",
+ "Xander Ink / ",
+ "Vicarro Peacock / "
+ ],
+ "caption": "New arrivals just landed ✨ Flowerhaze, Dreamy Reed, Xander Avocado & more from Rebel Walls, Stout Textiles. Discover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗\n\n#DesignerWallcoverings #NewArrivals #Wallcovering #Wallcoverings #InteriorDesign #InteriorDecor #HomeDesign #LuxuryInteriors #DesignInspo #RebelWalls #StoutTextiles",
+ "hashtags": [
+ "#DesignerWallcoverings",
+ "#NewArrivals",
+ "#Wallcovering",
+ "#Wallcoverings",
+ "#InteriorDesign",
+ "#InteriorDecor",
+ "#HomeDesign",
+ "#LuxuryInteriors",
+ "#DesignInspo",
+ "#RebelWalls",
+ "#StoutTextiles"
+ ],
+ "publish": {
+ "instagram": {
+ "status": "pending"
+ },
+ "tiktok": {
+ "status": "pending"
+ }
+ }
+ },
+ {
+ "file": "new-arrivals-2026-07-28-20-55.mp4",
+ "created_at": "2026-07-28T20:55:09.729Z",
+ "products": 6,
+ "seconds": 19,
+ "titles": [
+ "Flowerhaze / Blue",
+ "Dreamy Reed / Pink",
+ "Dreamy Reed / Black & White",
+ "Xander Avocado / ",
+ "Xander Ink / ",
+ "Vicarro Peacock / "
+ ],
+ "caption": "New arrivals just landed ✨ Flowerhaze, Dreamy Reed, Xander Avocado & more from Rebel Walls, Stout Textiles. Discover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗\n\n#DesignerWallcoverings #NewArrivals #Wallcovering #Wallcoverings #InteriorDesign #InteriorDecor #HomeDesign #LuxuryInteriors #DesignInspo #RebelWalls #StoutTextiles",
+ "hashtags": [
+ "#DesignerWallcoverings",
+ "#NewArrivals",
+ "#Wallcovering",
+ "#Wallcoverings",
+ "#InteriorDesign",
+ "#InteriorDecor",
+ "#HomeDesign",
+ "#LuxuryInteriors",
+ "#DesignInspo",
+ "#RebelWalls",
+ "#StoutTextiles"
+ ],
+ "publish": {
+ "instagram": {
+ "status": "pending"
+ },
+ "tiktok": {
+ "status": "pending"
+ }
+ }
+ },
{
"file": "new-arrivals-2026-07-28-14-10.mp4",
"created_at": "2026-07-28T14:10:57.775Z",
diff --git a/scripts/build-reel-1x1.mjs b/scripts/build-reel-1x1.mjs
new file mode 100644
index 0000000..824792d
--- /dev/null
+++ b/scripts/build-reel-1x1.mjs
@@ -0,0 +1,193 @@
+#!/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); });
← ccd338e auto-save: 2026-07-28T07:28:16 (3 files) — data/new-arrivals
·
back to Dw Marketing Reels
·
auto-save: 2026-07-28T14:01:00 (2 files) — data/video-jobs.j 926c0a9 →