← back to Dw Marketing Reels
reels: 15/30s runtime + open on a product image (no blank IG index thumbnail)
692459d30ba7f1d3b7b725c4ba333c47f78b24c0 · 2026-08-10 11:47:36 -0700 · Steve
Steve 2026-08-10: every reel must be exactly 15s or 30s and its FIRST frame must be
a real product image, never a blank card — Instagram uses frame 0 as the grid/Reels
('index mode') thumbnail, and the old solid-cream intro (text fading in from opacity 0)
rendered a blank cream tile. Now: REEL_TARGET (15|30, default 30) derives PER so total
lands exactly on target; the intro is image-backed (items[0] full-bleed + scrim + title
overlay, image visible at frame 0 with a gentle ken-burns). Verified: 30.0s render, frame0
is a product photo. REEL_TARGET=15 for a 15s cut.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 692459d30ba7f1d3b7b725c4ba333c47f78b24c0
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 10 11:47:36 2026 -0700
reels: 15/30s runtime + open on a product image (no blank IG index thumbnail)
Steve 2026-08-10: every reel must be exactly 15s or 30s and its FIRST frame must be
a real product image, never a blank card — Instagram uses frame 0 as the grid/Reels
('index mode') thumbnail, and the old solid-cream intro (text fading in from opacity 0)
rendered a blank cream tile. Now: REEL_TARGET (15|30, default 30) derives PER so total
lands exactly on target; the intro is image-backed (items[0] full-bleed + scrim + title
overlay, image visible at frame 0 with a gentle ken-burns). Verified: 30.0s render, frame0
is a product photo. REEL_TARGET=15 for a 15s cut.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/build-reel.mjs | 35 +++++++++++++++++++++++++++--------
1 file changed, 27 insertions(+), 8 deletions(-)
diff --git a/scripts/build-reel.mjs b/scripts/build-reel.mjs
index 452e2af..599f456 100644
--- a/scripts/build-reel.mjs
+++ b/scripts/build-reel.mjs
@@ -11,8 +11,11 @@ import { LEAK_DENY, safe } from './leak-deny.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WS = join(ROOT, 'hf-workspace');
const N = Number(process.env.REEL_COUNT || 6); // products per reel
-const PER = Number(process.env.REEL_SECS || 2.4); // seconds per product
-const INTRO = 2.0, OUTRO = 2.6;
+// HARD runtime spec (Steve 2026-08-10): every reel is exactly 15s or 30s. PER is
+// DERIVED from the target so the total lands on it; INTRO/OUTRO are fixed. Default 30s.
+const TARGET = [15, 30].includes(Number(process.env.REEL_TARGET)) ? Number(process.env.REEL_TARGET) : 30;
+const INTRO = 2.5, OUTRO = 2.5;
+const PER = Math.max(1.5, +(((TARGET - INTRO - OUTRO) / N)).toFixed(3)); // seconds per product
const SLUG = (process.env.REEL_SLUG || 'new-arrivals').replace(/[^a-z0-9-]+/gi, '-').toLowerCase(); // output filename prefix
// Reel theming — all env-overridable, defaulting to the generic "New Arrivals" reel.
@@ -135,17 +138,30 @@ async function main() {
.card .sub { font-size:34px; letter-spacing:.16em; text-transform:uppercase; color:#5a544c; }
.card .rule { width:120px; height:2px; background:var(--gold); margin:44px 0; }
.card .url { position:absolute; bottom:150px; font-size:30px; letter-spacing:.24em; text-transform:uppercase; color:var(--ink); }
+ /* image-backed intro — the FIRST frame must be a product image, never a blank card,
+ so the Instagram grid / Reels-tab ("index mode") thumbnail is a real product. */
+ .intro-img { background:var(--ink); }
+ .intro-img .introph { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transform-origin:center; }
+ .intro-img .introscrim { position:absolute; inset:0; background:linear-gradient(180deg,rgba(0,0,0,.42) 0%,rgba(0,0,0,.15) 42%,rgba(0,0,0,.60) 100%); }
+ .intro-img .introtext { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; }
+ .intro-img .kicker { color:#e8c98f; }
+ .intro-img .big { color:#fff; text-shadow:0 2px 24px rgba(0,0,0,.6); margin:26px 60px; }
+ .intro-img .sub { color:#f4efe6; }
</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="card" id="intro">
- <div class="kicker">${esc(THEME.kicker)}</div>
- <div class="big">${esc(THEME.title)}</div>
- <div class="rule"></div>
- <div class="sub">${esc(introSub)}</div>
+ <div class="card intro-img" id="intro">
+ <img class="introph" src="${esc(items[0]._local)}" alt="${esc(items[0].pattern)}" />
+ <div class="introscrim"></div>
+ <div class="introtext">
+ <div class="kicker">${esc(THEME.kicker)}</div>
+ <div class="big">${esc(THEME.title)}</div>
+ <div class="rule"></div>
+ <div class="sub">${esc(introSub)}</div>
+ </div>
</div>
${slides}
@@ -160,7 +176,10 @@ async function main() {
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
- // intro
+ // intro — the product image is visible at frame 0 (NO opacity:0 on .introph), so the
+ // IG index-mode thumbnail is a real product; a gentle ken-burns + text fade rides on top.
+ gsap.set("#intro .introph", { scale: 1.08 });
+ tl.to("#intro .introph", { scale: 1.0, duration: ${INTRO}, ease: "none" }, 0);
gsap.set("#intro .big", { y: 40, opacity: 0 });
gsap.set("#intro .kicker, #intro .sub, #intro .rule", { opacity: 0 });
tl.to("#intro .kicker", { opacity: 1, duration: 0.5 }, 0.2);
← c2672a4 Fix Majilite flip book Shop CTA -> /collections/all/majilite
·
back to Dw Marketing Reels
·
Majilite flip book: real walls+furniture room renders in ver 105bde3 →