← back to Prestige Car Wash
Add HyperFrames promo videos to the growth-center backend
557b5cde0fb1a408fa2a7344f0efc8fea21c8c90 · 2026-07-22 07:07:44 -0700 · Steve Abrams
New 🎬 Promos admin tab surfacing the four HyperFrames-generated brand films
(full tour, story cut, 30s motion promo, backend demo) staged under
media/promos/. Backed by data/promos.json + /api/admin/promos (normalized URLs,
mm:ss, on-disk ready flag). Each promo streams on demand (preload=none) and can
be handed to Compose & Post as an attachable asset in one click. Large .mp4s
gitignored (regenerable from the sibling video pipelines); posters + manifest
tracked. Health/Overview now counts promos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA data/promos.jsonA media/promos/promo-backend-demo.jpgA media/promos/promo-full-tour.jpgA media/promos/promo-motion.jpgA media/promos/promo-story-cut.jpgM public/admin/index.htmlM server.js
Diff
commit 557b5cde0fb1a408fa2a7344f0efc8fea21c8c90
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 07:07:44 2026 -0700
Add HyperFrames promo videos to the growth-center backend
New 🎬 Promos admin tab surfacing the four HyperFrames-generated brand films
(full tour, story cut, 30s motion promo, backend demo) staged under
media/promos/. Backed by data/promos.json + /api/admin/promos (normalized URLs,
mm:ss, on-disk ready flag). Each promo streams on demand (preload=none) and can
be handed to Compose & Post as an attachable asset in one click. Large .mp4s
gitignored (regenerable from the sibling video pipelines); posters + manifest
tracked. Health/Overview now counts promos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 5 ++++
data/promos.json | 53 ++++++++++++++++++++++++++++++++++++
media/promos/promo-backend-demo.jpg | Bin 0 -> 47960 bytes
media/promos/promo-full-tour.jpg | Bin 0 -> 152638 bytes
media/promos/promo-motion.jpg | Bin 0 -> 62853 bytes
media/promos/promo-story-cut.jpg | Bin 0 -> 70036 bytes
public/admin/index.html | 52 +++++++++++++++++++++++++++++++++++
server.js | 27 +++++++++++++++++-
8 files changed, 136 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 9a60c34..7b49a78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,11 @@ media/*.mp4
media/*.png
media/*.jpg
media/.cache/
+# HyperFrames promo renders are large + regenerable from the sibling
+# prestige-car-wash-video / prestige-hyperframes-promo pipelines. Keep the .mp4s
+# out of git but TRACK the small posters (*.jpg) + data/promos.json so the
+# promos surface is reproducible. (deploy still rsyncs the .mp4s — they're content.)
+media/promos/*.mp4
# runtime lead capture (PII) — never commit
reports/leads.jsonl
diff --git a/data/promos.json b/data/promos.json
new file mode 100644
index 0000000..35611e3
--- /dev/null
+++ b/data/promos.json
@@ -0,0 +1,53 @@
+{
+ "_note": "HyperFrames-generated brand films for Prestige Car Wash. Rendered via the sibling prestige-car-wash-video + prestige-hyperframes-promo pipelines and staged here for the growth admin. Large .mp4s are gitignored (regenerable); posters + this manifest are tracked. src/poster are repo-relative and normalized to absolute /media URLs by /api/admin/promos.",
+ "promos": [
+ {
+ "id": "promo-full-tour",
+ "title": "Full Guided Tour",
+ "kind": "brand-promo",
+ "blurb": "The complete narrated walkthrough — the no-appointment model, all six lines, and every service from the $19 Express Wash to the $999 Ceramic Coating. The flagship film for the site hero, YouTube, and paid social.",
+ "duration_sec": 178,
+ "aspect": "16:9",
+ "src": "media/promos/promo-full-tour.mp4",
+ "poster": "media/promos/promo-full-tour.jpg",
+ "best_for": ["YouTube", "Website hero", "Facebook"],
+ "created_at": "2026-07-12T08:25:00-07:00"
+ },
+ {
+ "id": "promo-story-cut",
+ "title": "Story Cut",
+ "kind": "brand-promo",
+ "blurb": "A tighter story-driven edit of the tour — same brand beats, faster pacing. Built for feed placement where a shorter watch wins.",
+ "duration_sec": 172,
+ "aspect": "16:9",
+ "src": "media/promos/promo-story-cut.mp4",
+ "poster": "media/promos/promo-story-cut.jpg",
+ "best_for": ["Instagram", "Facebook", "Nextdoor"],
+ "created_at": "2026-07-12T08:56:00-07:00"
+ },
+ {
+ "id": "promo-motion",
+ "title": "Motion Promo (30s)",
+ "kind": "hyperframes-motion",
+ "blurb": "Short kinetic HyperFrames cut — punchy, music-led, no long narration. The scroll-stopper for Reels, TikTok, and Stories.",
+ "duration_sec": 28,
+ "aspect": "16:9",
+ "src": "media/promos/promo-motion.mp4",
+ "poster": "media/promos/promo-motion.jpg",
+ "best_for": ["Instagram Reels", "TikTok", "Stories"],
+ "created_at": "2026-07-12T10:21:19-07:00"
+ },
+ {
+ "id": "promo-backend-demo",
+ "title": "Growth Center — Backend Demo",
+ "kind": "internal-pitch",
+ "blurb": "Behind-the-scenes walkthrough of this growth command center — competitors, campaigns, ads, and leads in one place. An internal / owner-pitch asset, not a customer-facing ad.",
+ "duration_sec": 67,
+ "aspect": "16:9",
+ "src": "media/promos/promo-backend-demo.mp4",
+ "poster": "media/promos/promo-backend-demo.jpg",
+ "best_for": ["Owner pitch", "Internal"],
+ "created_at": "2026-07-12T11:17:00-07:00"
+ }
+ ]
+}
diff --git a/media/promos/promo-backend-demo.jpg b/media/promos/promo-backend-demo.jpg
new file mode 100644
index 0000000..333d5f7
Binary files /dev/null and b/media/promos/promo-backend-demo.jpg differ
diff --git a/media/promos/promo-full-tour.jpg b/media/promos/promo-full-tour.jpg
new file mode 100644
index 0000000..0e3aa6c
Binary files /dev/null and b/media/promos/promo-full-tour.jpg differ
diff --git a/media/promos/promo-motion.jpg b/media/promos/promo-motion.jpg
new file mode 100644
index 0000000..50565bd
Binary files /dev/null and b/media/promos/promo-motion.jpg differ
diff --git a/media/promos/promo-story-cut.jpg b/media/promos/promo-story-cut.jpg
new file mode 100644
index 0000000..d881e79
Binary files /dev/null and b/media/promos/promo-story-cut.jpg differ
diff --git a/public/admin/index.html b/public/admin/index.html
index 550a37d..53bb995 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -40,6 +40,19 @@
.asset .k{position:absolute;top:4px;right:4px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;border-radius:6px;padding:1px 5px;font-weight:700}
.cmpbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.hint{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12.5px;color:var(--mut);margin-top:10px}
+ /* Promos */
+ .promos{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:16px}
+ .promo{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column}
+ .promo .pv{position:relative;background:#000}
+ .promo video{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000}
+ .promo .dur{position:absolute;bottom:8px;right:8px;font-size:11px;font-weight:800;background:rgba(0,0,0,.72);color:#fff;border-radius:6px;padding:2px 7px;font-variant-numeric:tabular-nums}
+ .promo .pb{padding:12px 14px 14px}
+ .promo .pttl{font-size:16px;font-weight:800;margin:2px 0 6px}
+ .promo .pbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
+ .promo .pbtns .btn{font-size:12.5px;padding:7px 11px}
+ .k-brand-promo{background:rgba(10,132,255,.16);color:var(--brand)}
+ .k-hyperframes-motion{background:rgba(255,180,84,.16);color:var(--warn)}
+ .k-internal-pitch{background:var(--panel2);color:var(--mut)}
</style>
</head>
<body>
@@ -78,6 +91,7 @@ let t;$('#q').oninput=()=>{clearTimeout(t);t=setTimeout(render,200)};
// ---- tab definitions -------------------------------------------------------
const TABS=[
{id:'overview',label:'📊 Overview'},
+ {id:'promos',label:'🎬 Promos'},
{id:'compose',label:'✨ Compose & Post'},
{id:'competitors',label:'🥊 Competitors',api:'/api/admin/competitors',search:['name','city','notes','format'],
card:c=>cardWrap(`<div class="row"><div class="cat">${enc(c.city)} · ${enc(c.format)}</div>${rating(c.rating)}</div>
@@ -138,6 +152,7 @@ function render(){
const tab=TABS.find(t=>t.id===CUR);
const showCtl=!!tab.api; $('#ctl').style.display=showCtl?'':'none';
if(tab.id==='overview') return renderOverview();
+ if(tab.id==='promos') return renderPromos();
if(tab.id==='compose') return renderCompose();
if(tab.id==='best-post') return renderBestPost();
if(tab.id==='best-market') return renderBestMarket();
@@ -244,6 +259,43 @@ function renderCreds(){
$('#count').textContent='';
});
}
+// ---- Promos: HyperFrames-generated brand films ----------------------------
+const KIND_LABEL={'brand-promo':'brand promo','hyperframes-motion':'motion cut','internal-pitch':'internal · pitch'};
+function renderPromos(){
+ $('#count').textContent='';
+ $('#view').innerHTML='<p style="color:var(--mut)">Loading promos…</p>';
+ fetch('/api/admin/promos').then(r=>r.json()).then(list=>{
+ $('#count').textContent=list.length+' film'+(list.length!==1?'s':'');
+ if(!list.length){ $('#view').innerHTML='<p style="color:var(--mut)">No promos yet. Drop HyperFrames renders into <code>media/promos/</code> and register them in <code>data/promos.json</code>.</p>'; return; }
+ const card=p=>{
+ const kindClass='k-'+enc(p.kind||'brand-promo');
+ const media=p.ready
+ ? `<div class="pv"><video src="${enc(p.src)}" ${p.poster?`poster="${enc(p.poster)}"`:''} controls preload="none" playsinline></video>${p.duration_label?`<span class="dur">${enc(p.duration_label)}</span>`:''}</div>`
+ : `<div class="pv" style="aspect-ratio:16/9;display:grid;place-items:center;color:var(--bad);font-size:13px">⚠ file missing on disk</div>`;
+ const best=(p.best_for||[]).map(b=>`<span class="tag">${enc(b)}</span>`).join('');
+ return `<div class="promo">
+ ${media}
+ <div class="pb">
+ <div class="row"><span class="badge ${kindClass}">${enc(KIND_LABEL[p.kind]||p.kind||'promo')}</span>${p.ready?'':'<span class="badge b-bad">unavailable</span>'}</div>
+ <div class="pttl">${enc(p.title)}</div>
+ <div class="blurb">${enc(p.blurb)}</div>
+ ${best?`<div class="tags" style="margin-top:8px">${best}</div>`:''}
+ <div class="pbtns">
+ ${p.ready?`<a class="btn ghost" href="${enc(p.src)}" download="${enc(p.id)}.mp4">⬇ Download</a>
+ <button class="btn" onclick='promoUse(${JSON.stringify({url:p.src,poster:p.poster,name:p.title})})'>✨ Use in a post</button>`:''}
+ </div>
+ ${when(p.created_at)}
+ </div>
+ </div>`;
+ };
+ $('#view').innerHTML=`
+ <div class="hint" style="margin:0 0 14px">HyperFrames-generated brand films, staged for the growth center. Each also appears as an attachable asset in <b>✨ Compose & Post</b>. Files are large — the player streams on demand (nothing preloads).</div>
+ <div class="promos">${list.map(card).join('')}</div>`;
+ }).catch(()=>{ $('#view').innerHTML='<p style="color:var(--bad)">Could not load promos.</p>'; });
+}
+// Hand a promo to the composer as the selected asset, then jump to Compose.
+window.promoUse=asset=>{ CMP.sel={url:asset.url,kind:'video',name:asset.name,poster:asset.poster||''}; go('compose'); };
+
// ---- Compose & Post: create socials + post from ONE place -----------------
function renderCompose(){
$('#count').textContent='';
diff --git a/server.js b/server.js
index 13940ba..aff1b3e 100644
--- a/server.js
+++ b/server.js
@@ -35,6 +35,7 @@ const ads = () => readJSON('ads.json', []);
const bestTimes = () => readJSON('best-times.json', {});
const places = () => readJSON('places.json', {});
const socialTpl = () => readJSON('social-templates.json', { platforms: [], hashtag_sets: {}, templates: [] });
+const promos = () => readJSON('promos.json', { promos: [] });
// Mask a secret to a "present (…last4)" descriptor — never returns the value.
const maskEnv = (key) => {
@@ -100,7 +101,8 @@ app.get('/api/health', (req, res) => {
counts: {
services: services().length, competitors: competitors().length,
suggestions: suggestions().length, holidays: holidays().length,
- directories: directories().length, ads: ads().length
+ directories: directories().length, ads: ads().length,
+ promos: (promos().promos || []).length
},
ts: new Date().toISOString()
});
@@ -189,6 +191,29 @@ function mediaLibrary() {
app.get('/api/admin/media', (req, res) => res.json(mediaLibrary()));
app.get('/api/admin/social/templates', (req, res) => res.json(socialTpl()));
+// ---- Promos: HyperFrames-generated brand films -----------------------------
+// Curated, first-class list of the finished brand videos (distinct from the raw
+// svc-* service clips in the media library). Repo-relative src/poster are
+// normalized to absolute /media URLs so the client stays format-agnostic; any
+// promo whose file is missing on disk is flagged (ready=false) rather than
+// served as a broken <video>. mm:ss is precomputed for the card badge.
+const abs = (p) => '/' + String(p || '').replace(/^\//, '');
+const mmss = (s) => { s = Math.max(0, parseInt(s, 10) || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
+app.get('/api/admin/promos', (req, res) => {
+ const list = (promos().promos || []).map(p => {
+ const rel = String(p.src || '').replace(/^\//, '');
+ const ready = rel ? fs.existsSync(path.join(__dirname, rel)) : false;
+ return {
+ ...p,
+ src: abs(p.src),
+ poster: p.poster ? abs(p.poster) : '',
+ duration_label: p.duration_sec ? mmss(p.duration_sec) : '',
+ ready
+ };
+ });
+ res.json(list);
+});
+
// Save a composed post as a DRAFT (append-only). This NEVER posts to any network —
// the front-end deep-links into each platform's native composer for the human to post.
app.post('/api/admin/social/draft', (req, res) => {
← d9afc41 chore: v0.2.0 (Compose & Post feature — session close)
·
back to Prestige Car Wash
·
chore: lint (harden promoUse onclick), refactor (mmss), v0.3 02fe543 →