← back to Dw Marketing Reels

scripts/build-photo-song-reel.mjs

190 lines

#!/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); });