← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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); });