[object Object]

← back to Dw Marketing Reels

Reel Studio: render a single photo + song → mp4 (Composer handoff target)

9de1fc96ae57d68d01d713e1f25405cc0a0c4daf · 2026-08-26 08:34:49 -0700 · Steve

New render path behind the MCC Composer's 'Build this reel' handoff. build-photo-song-reel.mjs
takes ONE photo + a song + caption and renders a 1080x1920 reel (photo full-frame + slow
ken-burns + caption overlay + DW outro) via HyperFrames ($0 local), then muxes the REAL
song as the audio track (build-reel.mjs muxes silence; here the music is the point) with a
silent-AAC fallback so it's always IG-Reels-publishable. New POST /api/build-photo-reel
endpoint: same ALLOW_BUILD gate + single-job lock as run(), inputs via env, validates the
photo is an http(s) URL.

Verified: rendered a real 15.0s mp4 = h264 1080x1920 + aac (song muxed); endpoint 202 on
valid input, 409 while a build runs; manifest entry written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 9de1fc96ae57d68d01d713e1f25405cc0a0c4daf
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 26 08:34:49 2026 -0700

    Reel Studio: render a single photo + song → mp4 (Composer handoff target)
    
    New render path behind the MCC Composer's 'Build this reel' handoff. build-photo-song-reel.mjs
    takes ONE photo + a song + caption and renders a 1080x1920 reel (photo full-frame + slow
    ken-burns + caption overlay + DW outro) via HyperFrames ($0 local), then muxes the REAL
    song as the audio track (build-reel.mjs muxes silence; here the music is the point) with a
    silent-AAC fallback so it's always IG-Reels-publishable. New POST /api/build-photo-reel
    endpoint: same ALLOW_BUILD gate + single-job lock as run(), inputs via env, validates the
    photo is an http(s) URL.
    
    Verified: rendered a real 15.0s mp4 = h264 1080x1920 + aac (song muxed); endpoint 202 on
    valid input, 409 while a build runs; manifest entry written.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/build-photo-song-reel.mjs | 189 ++++++++++++++++++++++++++++++++++++++
 server.js                         |  21 +++++
 2 files changed, 210 insertions(+)

diff --git a/scripts/build-photo-song-reel.mjs b/scripts/build-photo-song-reel.mjs
new file mode 100644
index 0000000..99d9f1b
--- /dev/null
+++ b/scripts/build-photo-song-reel.mjs
@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+// Build a single-photo "photo + song" reel — the render path behind the Marketing
+// Command Center Composer's "build in Reel Studio" handoff. Takes ONE photo + a song
+// + a caption and renders a 9:16 MP4: the photo full-frame with a slow ken-burns, an
+// optional caption/title overlay, a DW outro, and the REAL song muxed as the audio
+// track (build-reel.mjs muxes silence; here the whole point is the music). $0 —
+// local ffmpeg/Chrome render, no paid API.
+//
+// Inputs (env):
+//   PHOTO_URL   (required) — the still image the reel is built from
+//   SONG_URL    (optional) — audio track to mux; falls back to silent if missing/undownloadable
+//   SONG_NAME   (optional) — display name for the manifest
+//   REEL_CAPTION(optional) — caption carried from the composer
+//   REEL_TITLE  (optional) — big overlay title (default: none / DW)
+//   REEL_TARGET (optional) — 15 or 30 seconds (default 15 — a single photo doesn't need 30)
+import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { execSync } from 'node:child_process';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const WS = join(ROOT, 'hf-workspace');
+const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+
+const PHOTO_URL = process.env.PHOTO_URL || '';
+const SONG_URL = process.env.SONG_URL || '';
+const SONG_NAME = process.env.SONG_NAME || '';
+const CAPTION = process.env.REEL_CAPTION || '';
+const TITLE = process.env.REEL_TITLE || '';
+const TARGET = [15, 30].includes(Number(process.env.REEL_TARGET)) ? Number(process.env.REEL_TARGET) : 15;
+
+if (!PHOTO_URL) { console.error('✗ PHOTO_URL is required'); process.exit(1); }
+
+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 < 500) return false;
+    writeFileSync(dest, buf);
+    return true;
+  } catch { return false; }
+}
+
+async function main() {
+  const imgDir = join(WS, 'assets', 'img');
+  mkdirSync(imgDir, { recursive: true });
+
+  // 1. photo (local for a deterministic render; fall back to remote URL)
+  const photoDest = join(imgDir, 'photo.jpg');
+  const photoOk = await dl(PHOTO_URL, photoDest);
+  const photoSrc = photoOk ? 'assets/img/photo.jpg' : PHOTO_URL;
+  console.log(`  photo: ${photoOk ? '✓ local' : '↯ remote'} ${PHOTO_URL.slice(0, 80)}`);
+
+  // 2. song (local audio for the ffmpeg mux). If it isn't a real downloadable audio
+  //    file (e.g. a TikTok reference link), we fall back to a silent track so the
+  //    reel is still Reels-publishable — never fail the whole render on a bad song.
+  let songLocal = '';
+  if (SONG_URL) {
+    const songDest = join(imgDir, 'song.audio');
+    if (await dl(SONG_URL, songDest)) songLocal = songDest;
+    console.log(`  song: ${songLocal ? '✓ local' : '↯ not downloadable — silent fallback'} ${SONG_NAME || SONG_URL.slice(0, 60)}`);
+  }
+
+  const INTRO = 0.5, OUTRO = 2.0;
+  const total = TARGET;
+  const capLine = (CAPTION || '').split('\n')[0].slice(0, 90);   // first line, trimmed, as the on-photo caption
+
+  // --- single-photo composition: full-frame ken-burns + optional caption + DW outro ---
+  const html = `<!doctype html>
+<html lang="en" data-resolution="portrait">
+<head>
+<meta charset="UTF-8" />
+<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+<style>
+  :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; }
+  html,body { margin:0; padding:0; width:1080px; height:1920px; overflow:hidden;
+    background:var(--ink); font-family:"Helvetica Neue",system-ui,sans-serif; }
+  #main-composition { position:relative; width:1080px; height:1920px; overflow:hidden; background:var(--ink); }
+  .ph { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transform-origin:center; }
+  .scrim { position:absolute; inset:0; background:linear-gradient(180deg,rgba(0,0,0,.25) 0%,rgba(0,0,0,0) 40%,rgba(0,0,0,.70) 100%); }
+  .cap { position:absolute; left:72px; right:72px; bottom:170px; }
+  .cap .headline { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:${TITLE ? 108 : 84}px; line-height:1.03;
+    color:#fff; letter-spacing:.01em; text-shadow:0 2px 20px rgba(0,0,0,.55); }
+  .cap .sub { margin-top:18px; font-size:34px; line-height:1.35; color:#f4efe6; text-shadow:0 2px 14px rgba(0,0,0,.6); }
+  .card { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
+    text-align:center; background:var(--cream); color:var(--ink); }
+  .card .kicker { font-size:32px; letter-spacing:.42em; text-transform:uppercase; color:var(--gold); }
+  .card .big { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:150px; line-height:.98; margin:26px 60px; }
+  .card .url { position:absolute; bottom:150px; font-size:30px; letter-spacing:.24em; text-transform:uppercase; color:var(--ink); }
+</style>
+</head>
+<body>
+  <div id="main-composition" data-composition-id="main-video"
+       data-width="1080" data-height="1920" data-start="0" data-duration="${total}">
+    <div class="slide" id="slide">
+      <img class="ph" src="${esc(photoSrc)}" alt="reel" />
+      <div class="scrim"></div>
+      <div class="cap">
+        ${TITLE ? `<div class="headline">${esc(TITLE)}</div>` : (capLine ? `<div class="headline">${esc(capLine)}</div>` : '')}
+        ${TITLE && capLine ? `<div class="sub">${esc(capLine)}</div>` : ''}
+      </div>
+    </div>
+    <div class="card" id="outro">
+      <div class="kicker">Shop the collection</div>
+      <div class="big">Designer<br/>Wallcoverings</div>
+      <div class="url">designerwallcoverings.com</div>
+    </div>
+    <script>
+      const tl = gsap.timeline({ paused: true });
+      window.__timelines = window.__timelines || {};
+      // photo visible at frame 0 (real product thumbnail), slow ken-burns across the whole clip
+      gsap.set("#slide .ph", { scale: 1.10 });
+      tl.to("#slide .ph", { scale: 1.0, duration: ${(total - OUTRO).toFixed(2)}, ease: "none" }, 0);
+      gsap.set("#slide .cap", { y: 40, opacity: 0 });
+      tl.to("#slide .cap", { y: 0, opacity: 1, duration: 0.8, ease: "power2.out" }, ${INTRO});
+      tl.to("#slide", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${(total - OUTRO).toFixed(2)});
+      // outro
+      gsap.set("#outro", { opacity: 0 });
+      gsap.set("#outro .big", { y: 40, opacity: 0 });
+      gsap.set("#outro .url", { opacity: 0 });
+      tl.to("#outro", { opacity: 1, duration: 0.5 }, ${(total - OUTRO).toFixed(2)});
+      tl.to("#outro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, ${(total - OUTRO + 0.2).toFixed(2)});
+      tl.to("#outro .url", { opacity: 1, duration: 0.5 }, ${(total - OUTRO + 0.7).toFixed(2)});
+      window.__timelines["main-video"] = tl;
+    </script>
+  </div>
+</body>
+</html>`;
+
+  writeFileSync(join(WS, 'index.html'), html);
+  for (const f of ['compositions/intro.html', 'compositions/graphics.html', 'compositions/captions.html']) {
+    const p = join(WS, f); if (existsSync(p)) writeFileSync(p, '<div></div>');
+  }
+  console.log(`✓ composition written (single photo, ${total}s portrait)`);
+
+  // --- render ---
+  console.log('→ rendering (local ffmpeg/Chrome, $0)…');
+  execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
+
+  const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
+  const outName = `photo-song-${stamp}.mp4`;
+  const found = findMp4(WS);
+  if (!found) { console.error('✗ no MP4 produced'); process.exit(2); }
+  mkdirSync(join(ROOT, 'reels'), { recursive: true });
+  const outPath = join(ROOT, 'reels', outName);
+
+  // --- audio: mux the REAL song; silent AAC fallback (IG Reels require an audio track) ---
+  try {
+    if (songLocal) {
+      // real song → loop/trim to the video length, faststart for streaming
+      execSync(`ffmpeg -y -i ${JSON.stringify(found)} -stream_loop -1 -i ${JSON.stringify(songLocal)} -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac -b:a 160k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
+    } else {
+      execSync(`ffmpeg -y -i ${JSON.stringify(found)} -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -c:v copy -c:a aac -b:a 128k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
+    }
+  } catch {
+    console.warn('  (ffmpeg mux failed — raw copy; reel may not be IG-Reels-publishable)');
+    copyFileSync(found, outPath);
+  }
+
+  // --- manifest for the gallery ---
+  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(), products: 1, seconds: total,
+    source: 'composer-photo-song', song: SONG_NAME || (SONG_URL ? 'custom track' : null),
+    titles: [TITLE || capLine || 'Photo reel'],
+    caption: CAPTION || '', hashtags: [],
+    publish: { instagram: { status: 'pending' }, tiktok: { status: 'pending' } },
+  });
+  writeFileSync(manPath, JSON.stringify(man, null, 2));
+  console.log(`✓ reel -> reels/${outName}${songLocal ? ' (with song)' : ' (silent)'}`);
+}
+
+function findMp4(dir) {
+  let newest = null, mt = 0;
+  const walk = d => {
+    for (const e of readdirSync(d, { withFileTypes: true })) {
+      if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
+      const p = join(d, e.name);
+      if (e.isDirectory()) walk(p);
+      else if (e.name.endsWith('.mp4')) { const t = statSync(p).mtimeMs; if (t > mt) { mt = t; newest = p; } }
+    }
+  };
+  walk(dir);
+  return newest;
+}
+
+main().catch(e => { console.error('✗', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 5573b08..e94dc7d 100644
--- a/server.js
+++ b/server.js
@@ -204,6 +204,27 @@ const server = http.createServer(async (req, res) => {
       .end(JSON.stringify({ building, log: lastLog.slice(-4000) }));
     if (p === '/api/refresh' && req.method === 'POST') return run('fetch-new-arrivals.mjs', res);
     if (p === '/api/build' && req.method === 'POST') return run('build-reel.mjs', res);
+    if (p === '/api/build-photo-reel' && req.method === 'POST') {
+      // Marketing Command Center Composer handoff: render ONE photo + a song → a 9:16
+      // reel. Same ALLOW_BUILD gate + single-job lock as run(); passes inputs via env.
+      if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
+        .end(JSON.stringify({ error: 'Generation runs on the render host (Mac). This deploy serves reels only.' }));
+      if (building) return res.writeHead(409).end(JSON.stringify({ error: 'a job is already running' }));
+      const b = await readBody(req);
+      const photo = String(b.photo || '');
+      if (!/^https?:\/\//i.test(photo)) return res.writeHead(400).end(JSON.stringify({ error: 'photo must be an http(s) image URL' }));
+      const song = /^https?:\/\//i.test(b.song || '') ? String(b.song) : '';
+      building = true; lastLog = '';
+      const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}`, HYPERFRAMES_SKIP_SKILLS: '1',
+        PHOTO_URL: photo, SONG_URL: song, SONG_NAME: String(b.songName || '').slice(0, 200),
+        REEL_CAPTION: String(b.caption || '').slice(0, 2200), REEL_TITLE: String(b.title || '').slice(0, 120),
+        REEL_TARGET: [15, 30].includes(Number(b.seconds)) ? String(Number(b.seconds)) : '15' };
+      const ch = spawn(process.execPath, [join(ROOT, 'scripts', 'build-photo-song-reel.mjs')], { cwd: ROOT, env });
+      ch.stdout.on('data', d => { lastLog += d; });
+      ch.stderr.on('data', d => { lastLog += d; });
+      ch.on('close', code => { building = false; lastLog += `\n[exit ${code}]`; });
+      return res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ started: 'build-photo-song-reel.mjs' }));
+    }
     if (p === '/api/publish' && req.method === 'POST') {
       // Publish a reel to Instagram via scripts/publish-ig.mjs. The real post
       // only fires if this server process has SOCIAL_LIVE_ARMED=1 — otherwise

← 99c71a8 novasuede color post (TK-10866): drop designerwallcoverings  ·  back to Dw Marketing Reels  ·  Add glitter_walls_design as a tracked (non-postable) IG sour c0c76ea →