[object Object]

← 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

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 &amp; 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 &amp; 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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/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 →