[object Object]

← back to Rentv Master Reel

RENTV master reel rebuild (Boomer, expanded 22-scene, Steve voice, current captures)

5188b27944ad165ad85e54d95ee65b088a1c0502 · 2026-07-28 15:09:37 -0700 · Steve Abrams

Files touched

Diff

commit 5188b27944ad165ad85e54d95ee65b088a1c0502
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 15:09:37 2026 -0700

    RENTV master reel rebuild (Boomer, expanded 22-scene, Steve voice, current captures)
---
 .gitignore                         |   5 +
 AGENTS.md                          |  93 ++++++++++++++++
 BRIEF.md                           |  44 ++++++++
 CLAUDE.md                          |  93 ++++++++++++++++
 SCRIPT.md                          |  97 +++++++++++++++++
 hyperframes.json                   |  12 ++
 index.html                         | 135 +++++++++++++++++++++++
 master-gen.mjs                     |  80 ++++++++++++++
 media/captures/admin-analytics.png | Bin 0 -> 116181 bytes
 media/captures/admin-command.png   | Bin 0 -> 244066 bytes
 media/captures/admin-index.png     | Bin 0 -> 208886 bytes
 media/captures/admin-kanban.png    | Bin 0 -> 365639 bytes
 media/captures/article.png         | Bin 0 -> 1441312 bytes
 media/captures/blog.png            | Bin 0 -> 223806 bytes
 media/captures/cretalk.png         | Bin 0 -> 684906 bytes
 media/captures/desk.png            | Bin 0 -> 246770 bytes
 media/captures/home.png            | Bin 0 -> 1105637 bytes
 media/captures/markets.png         | Bin 0 -> 207274 bytes
 media/captures/publish.png         | Bin 0 -> 208886 bytes
 media/captures/pulse.png           | Bin 0 -> 235076 bytes
 media/captures/review.png          | Bin 0 -> 665763 bytes
 media/captures/social.png          | Bin 0 -> 278037 bytes
 media/captures/theme-midnight.png  | Bin 0 -> 1105676 bytes
 media/captures/theme-salmon.png    | Bin 0 -> 1104796 bytes
 media/captures/v-broadsheet.png    | Bin 0 -> 1645620 bytes
 media/captures/v-dashboard.png     | Bin 0 -> 423048 bytes
 media/captures/v-magazine.png      | Bin 0 -> 1799107 bytes
 media/captures/v-terminal.png      | Bin 0 -> 318016 bytes
 media/captures/v-wire.png          | Bin 0 -> 1450907 bytes
 media/captures/versions.png        | Bin 0 -> 224773 bytes
 media/vo/concat.txt                |  23 ++++
 media/vo/durations.json            |  13 +++
 meta.json                          |   5 +
 package.json                       |  11 ++
 scripts/build-composition.mjs      | 217 +++++++++++++++++++++++++++++++++++++
 scripts/capture.mjs                |  58 ++++++++++
 scripts/tts.mjs                    |  32 ++++++
 37 files changed, 918 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d51c639
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+renders/
+media/*.m4a
+*.wav
+.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..a63562c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,93 @@
+# HyperFrames Composition Project
+
+## Skills — USE THESE FIRST
+
+**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
+
+**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
+
+- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
+- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
+- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
+- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
+- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
+- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
+- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
+- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
+- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
+
+**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
+
+The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
+
+> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
+
+> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
+> the specific skill you need (the `/hyperframes` router does this automatically before
+> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
+> everything already installed — neither pulls the full set. Restart the agent session so
+> newly installed skills load.
+
+## Commands
+
+```bash
+npm run dev          # start the preview server (long-running — keep it alive in background)
+npm run check        # lint + runtime + layout + motion + contrast (one command)
+npm run render       # render to MP4
+npm run publish      # publish and get a shareable link
+npx hyperframes lint --verbose  # include info-level findings
+npx hyperframes lint --json     # machine-readable output for CI
+npx hyperframes docs <topic> # reference docs in terminal
+```
+
+> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped.
+> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
+> command — it will time out and the server will die, breaking the browser preview.
+
+> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
+
+## Documentation
+
+**For quick reference**, use the local CLI docs command (no network required):
+
+```bash
+npx hyperframes docs <topic>
+```
+
+Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
+
+**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
+
+```
+https://hyperframes.heygen.com/llms.txt
+```
+
+## Project Structure
+
+- `index.html` — main composition (root timeline)
+- `compositions/` — sub-compositions referenced via `data-composition-src`
+- `meta.json` — project metadata (id, name)
+- `transcript.json` — whisper word-level transcript (if generated)
+
+## Linting — ALWAYS RUN AFTER CHANGES
+
+After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
+
+```bash
+npm run check
+```
+
+Fix all errors before presenting the result. Warnings should be reviewed before rendering.
+
+## Key Rules
+
+1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
+2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
+3. Timelines must be paused and registered on `window.__timelines`:
+   ```js
+   window.__timelines = window.__timelines || {};
+   window.__timelines["composition-id"] = gsap.timeline({ paused: true });
+   ```
+4. Videos use `muted` with a separate `<audio>` element for the audio track
+5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
+6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
diff --git a/BRIEF.md b/BRIEF.md
new file mode 100644
index 0000000..a925297
--- /dev/null
+++ b/BRIEF.md
@@ -0,0 +1,44 @@
+# BRIEF
+
+workflow: general-video
+flow: automation
+storyboard: no
+
+## Message
+
+A warm, detailed ~5-minute narrated tour of the rebuilt RENTV.com platform — celebrating what Boomer's team has built at rentv.com and showing how the new build (live at rentv-v1.agentabrams.com) extends it, with every improvement framed as a friendly suggestion, never a criticism.
+
+## Audience
+
+Boomer (RENTV.com owner) and the RENTV team. Non-technical, industry-savvy commercial real estate media people. Warm, respectful, collegial tone — Steve speaking to a peer whose work he admires.
+
+## Deliverable
+
+- Single MP4, 1920x1080, ~5 minutes (target 4:45–5:15).
+- Voiceover: Steve's cloned ElevenLabs voice (voice_id Xa9qV4wNbvSkdUWsYLzq, "Steve Abrams").
+- Visuals: live captured screenshots of rentv-v1.agentabrams.com (basic auth admin / DW2024!), presented with slow pans/zooms and clean titling. All data shown is live rentv.com data.
+
+## Content sequence (from Steve's email)
+
+1. Opening — compliment RENTV.com and Boomer's decades of CRE journalism; this build is a tribute + set of suggestions, built in a day on their live data.
+2. Rebuilt front page — RENTV Originals leading the live wire.
+3. In-shell article reader.
+4. The REview.
+5. Live Markets page.
+6. CRE Talk.
+7. Ten complete site versions (Wire, Broadsheet, Terminal, Magazine, Dashboard, ...).
+8. Ten professional themes.
+9. Ten admin dashboards.
+10. Blog backend / publishing desk.
+11. Close — recap, gratitude, "these are just suggestions — your call entirely," where to see it live + logins.
+
+## Tone rules (hard)
+
+- Kind and complimentary about Boomer's existing work throughout.
+- Every new feature is "a suggestion" / "one idea" / "if it's useful" — never "we fixed" or "the old site lacked."
+- No jargon; plain spoken English suitable for a voice read.
+
+## Constraints
+
+- Narration drives timing; scenes cut on narration beats.
+- RENTV brand feel: newsroom-serious, dark-slate + red accent, editorial typography.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a63562c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,93 @@
+# HyperFrames Composition Project
+
+## Skills — USE THESE FIRST
+
+**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
+
+**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
+
+- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
+- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
+- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
+- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
+- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
+- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
+- `/music-to-video` — a **music track** (audio file, or video to pull audio from) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
+- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
+- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
+
+**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
+
+The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
+
+> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
+
+> **Skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
+> the specific skill you need (the `/hyperframes` router does this automatically before
+> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
+> everything already installed — neither pulls the full set. Restart the agent session so
+> newly installed skills load.
+
+## Commands
+
+```bash
+npm run dev          # start the preview server (long-running — keep it alive in background)
+npm run check        # lint + runtime + layout + motion + contrast (one command)
+npm run render       # render to MP4
+npm run publish      # publish and get a shareable link
+npx hyperframes lint --verbose  # include info-level findings
+npx hyperframes lint --json     # machine-readable output for CI
+npx hyperframes docs <topic> # reference docs in terminal
+```
+
+> **`npm run dev` is a long-running server, not a one-shot command.** It blocks until stopped.
+> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
+> command — it will time out and the server will die, breaking the browser preview.
+
+> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
+
+## Documentation
+
+**For quick reference**, use the local CLI docs command (no network required):
+
+```bash
+npx hyperframes docs <topic>
+```
+
+Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
+
+**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
+
+```
+https://hyperframes.heygen.com/llms.txt
+```
+
+## Project Structure
+
+- `index.html` — main composition (root timeline)
+- `compositions/` — sub-compositions referenced via `data-composition-src`
+- `meta.json` — project metadata (id, name)
+- `transcript.json` — whisper word-level transcript (if generated)
+
+## Linting — ALWAYS RUN AFTER CHANGES
+
+After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
+
+```bash
+npm run check
+```
+
+Fix all errors before presenting the result. Warnings should be reviewed before rendering.
+
+## Key Rules
+
+1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
+2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
+3. Timelines must be paused and registered on `window.__timelines`:
+   ```js
+   window.__timelines = window.__timelines || {};
+   window.__timelines["composition-id"] = gsap.timeline({ paused: true });
+   ```
+4. Videos use `muted` with a separate `<audio>` element for the audio track
+5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
+6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
diff --git a/SCRIPT.md b/SCRIPT.md
new file mode 100644
index 0000000..8a0858b
--- /dev/null
+++ b/SCRIPT.md
@@ -0,0 +1,97 @@
+# RENTV Master Reel — expanded narration (Steve's cloned voice)
+
+Long & detailed cut. Target ~13–15 min. One block per scene; each block is one TTS call
+(< 1024 chars — split long blocks into a/b). Voice = Steve's ElevenLabs clone (Xa9qV4wNbvSkdUWsYLzq).
+The FIRST ~20s (scene 01a) also renders as a Steve HeyGen-avatar PIP intro.
+
+## 01a-intro  [capture: home (top) · avatar PIP first 20s]
+
+Boomer — Steve here. Before anything else: what you've built with RENTV is remarkable. For decades it's been the place the West Coast commercial real estate world goes to find out what actually closed, who financed it, and what it traded for. That reputation is the hard part — and you already own it.
+
+## 01b-intro  [capture: home (top)]
+
+So everything you're about to see is just a set of suggestions — ideas we sketched on top of your live feed, running on your real stories, built in a single day. None of it is a critique. It's a fan letter with working code attached. Take what's useful, ignore the rest, and know that every call stays yours.
+
+## 02a-frontpage  [capture: home (top)]
+
+Let's start where your readers start — the front page. The idea here is simple: let the brand lead, and let the wire run underneath it. RENTV Originals sits right up top, your editorial voice front and center, and then the live wire begins.
+
+## 02b-frontpage  [capture: home (pan down)]
+
+Every headline below it is your actual feed, pulled live from rentv dot com and updating all day — The Swig Company's refinancing in San Francisco, Tailwind's Sacramento buy, the deals your desk is already reporting. And the full section map is right there in the navigation: Sales, Leases, Financing, Development, Multifamily, Retail, Industrial, and L.A. Commercial — so a broker who only cares about one lane gets straight to it.
+
+## 03-reader  [capture: article]
+
+When a reader clicks a headline, the story opens right inside the site — same masthead, same navigation, no jumping away to a different-looking page. The photo's there, the category tag is there, and related stories wait at the bottom, so the natural next click is another RENTV story. It's a small touch, but it keeps people in your world — reading one more piece, and then another. Your writing already earns that time; this just makes it easy to spend.
+
+## 04-review  [capture: review]
+
+The REview deserves its own stage, so we gave it one. Face to face with the dealmakers — your interview franchise, presented like the flagship it is. This is content nobody else in the space has: the people who actually move the buildings, on camera, in their own words. We think it's worth putting a real spotlight on it, right alongside the news.
+
+## 05-markets  [capture: markets]
+
+Here's a live Markets page — the ten-year treasury, capital-markets rates, the REIT board, the numbers a commercial real estate reader checks every single morning. It's built on public data feeds, so it refreshes on its own — no data entry, no upkeep, it just runs. A page like this gives people a reason to open RENTV before their first phone call of the day, even on a morning when they're not chasing a specific story.
+
+## 05b-pulse  [capture: pulse]
+
+And layered on top of the deals you already track, a Market Pulse — total transaction volume, deal count, the biggest trades, sliced by property type and by market. It turns your reporting into a dashboard the whole industry would check. Same data you already gather; a sharper way to see it.
+
+## 06-cretalk  [capture: cretalk]
+
+CRE Talk — your video conversations, collected in one clean place, easy to browse and easy to play. The interviews are already great; this just gives them a proper home next to everything else.
+
+## 07-versions-intro  [capture: versions]
+
+Now the fun part. Instead of asking you to imagine a redesign, we built ten complete versions of the site — whole directions, not mockups, every one of them running your live stories right now. Let me walk you through all ten, so you can just point at the one that feels like RENTV.
+
+## 07a-wire  [capture: v-wire]
+
+The Wire — the modern newsroom. A lead story, a three-column river of headlines, a right rail for what's trending. Clean, fast, familiar to anyone who reads news online.
+
+## 07b-broadsheet  [capture: v-broadsheet]
+
+Broadsheet — this one reads like a printed paper on the web. Dense serif columns, small photos, a front page that feels like the business section you grew up with.
+
+## 07c-terminal  [capture: v-terminal]
+
+Terminal — a dark, data-first deal desk. If your reader lives in a Bloomberg window all day, this is the RENTV that feels like home.
+
+## 07d-magazine  [capture: v-magazine]
+
+Magazine — big and editorial. One oversized feature up top, generous photography, the look of a glossy quarterly.
+
+## 07e-dashboard  [capture: v-dashboard]
+
+Dashboard — a midnight command view. Every surface at a glance, for the reader who wants the whole market on one screen.
+
+## 07f-more  [capture: versions]
+
+And there are more: Daily Digest, a numbered headline list that reads like the morning email. Photo Grid, image-forward, every story equal. Split, with that salmon-paper feel. Minimal, quiet and typographic. And Feed, a dark modern river. Ten directions — click through them side by side and pick the one that's you.
+
+## 08-themes  [captures: theme-midnight, theme-salmon]
+
+On top of the versions, ten professional themes. One click in the toolbar restyles the entire site — RENTV Classic, Broadsheet, F.T. Salmon, Bloomberg Terminal, Midnight Capital, The Transcript, Western Dispatch, Gunmetal Report, Legal Pad, Institutional White. Same content, different suit — real financial-publication palettes and typography, and the site remembers each reader's choice.
+
+## 09a-admin  [capture: admin-index]
+
+For your team, we sketched ten admin dashboards — because the newsroom behind the site matters as much as the site. A command center that puts every surface on one screen: subscribers, stories, videos, the feed.
+
+## 09b-admin  [captures: admin-command, admin-kanban]
+
+A kanban board for moving work through stages, from pitch to published. Analytics for what readers actually open. A classic layout, a sidebar layout, a split view, a console, cards, a clean table, and a minimal desk. Same live data in every one of them — the suggestion is that you pick the workflow that matches how your people actually like to work, rather than adapting to whatever a vendor shipped.
+
+## 09c-desk  [capture: desk]
+
+And underneath it, the real asset: a Broker and Owner Desk sitting on a live registry — hundreds of thousands of brokers, tens of thousands of firms, and a sublease desk, the kind of data most publishers would kill for and you already have in the backyard.
+
+## 09d-social  [capture: social]
+
+Plus a social command center — LinkedIn, X, and Instagram in one place, a content calendar, and draft posts ready to go, so every deal you report can go out to the audiences that live on those platforms.
+
+## 10-publish  [captures: blog, publish]
+
+And a simple publishing desk. Write a post, hit publish, and it's live on the blog — no plugins, no fighting a content system. A subscriber list builds alongside it, so the newsletter audience you've earned over the years has a place to keep growing. Just a quiet backend that stays out of the way of the journalism — which is the whole point.
+
+## 11-close  [capture: home top → title card]
+
+So that's the tour — a rebuilt front page, an in-shell reader, The REview, live markets and market pulse, CRE Talk, ten site versions, ten themes, ten dashboards, the intelligence desk, a social command center, and a publishing desk — all running on your live data. Boomer, none of this is a critique; it's a fan letter with working code attached. These are suggestions, and every call is yours. It's all live at rentv dot agentabrams dot com. Take a look when you have a minute. Talk soon.
diff --git a/hyperframes.json b/hyperframes.json
new file mode 100644
index 0000000..dc9ec2e
--- /dev/null
+++ b/hyperframes.json
@@ -0,0 +1,12 @@
+{
+  "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
+  "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
+  "paths": {
+    "blocks": "compositions",
+    "components": "compositions/components",
+    "assets": "assets"
+  },
+  "media": {
+    "autoProxy": true
+  }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f4b6ac7
--- /dev/null
+++ b/index.html
@@ -0,0 +1,135 @@
+<!doctype html><html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=1920, height=1080" />
+<title>RENTV — Master Reel</title>
+<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+<style>
+  body{margin:0;background:#070b10;font-family:system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif}
+  #root{position:relative;width:1920px;height:1080px;overflow:hidden}
+  .clip{position:absolute;inset:0}
+  #bg{background:#0b1017}
+  .shot{overflow:hidden}.simg{width:100%;height:100%;object-fit:cover;object-position:top center;display:block;will-change:transform,opacity}
+  .label{left:56px;bottom:60px;right:auto;top:auto;width:auto;height:auto}
+  .li{display:flex;align-items:center;gap:14px}
+  .li .num{color:#fff;font-size:18px;font-weight:700;letter-spacing:.06em;padding:9px 13px;border-radius:8px;background:#0a6cff}
+  .li .txt{color:#fff;font-size:30px;font-weight:500;background:rgba(9,15,24,.9);padding:9px 20px;border-radius:10px}
+</style></head><body>
+  <div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="336.31" data-fps="30">
+  <div id="bg" class="clip" data-start="0" data-duration="336.31" data-track-index="0"></div>
+  <audio id="vo" src="media/narration.m4a" data-start="0" data-volume="1"></audio>
+  <div id="s0" class="clip shot" data-start="0.000" data-duration="18.286" data-track-index="1"><img id="s0i" class="simg" src="media/captures/home.png" alt="A note for Boomer"></div>
+  <div id="s0l" class="clip label" data-start="0.000" data-duration="18.286" data-track-index="2"><div class="li"><span class="num">01</span><span class="txt">A note for Boomer</span></div></div>
+  <div id="s1" class="clip shot" data-start="18.286" data-duration="18.751" data-track-index="1"><img id="s1i" class="simg" src="media/captures/home.png" alt="A note for Boomer"></div>
+  <div id="s1l" class="clip label" data-start="18.286" data-duration="18.751" data-track-index="2"><div class="li"><span class="num">02</span><span class="txt">A note for Boomer</span></div></div>
+  <div id="s2" class="clip shot" data-start="37.037" data-duration="15.825" data-track-index="1"><img id="s2i" class="simg" src="media/captures/home.png" alt="The front page"></div>
+  <div id="s2l" class="clip label" data-start="37.037" data-duration="15.825" data-track-index="2"><div class="li"><span class="num">03</span><span class="txt">The front page</span></div></div>
+  <div id="s3" class="clip shot" data-start="52.862" data-duration="22.327" data-track-index="1"><img id="s3i" class="simg" src="media/captures/home.png" alt="The front page"></div>
+  <div id="s3l" class="clip label" data-start="52.862" data-duration="22.327" data-track-index="2"><div class="li"><span class="num">04</span><span class="txt">The front page</span></div></div>
+  <div id="s4" class="clip shot" data-start="75.189" data-duration="18.983" data-track-index="1"><img id="s4i" class="simg" src="media/captures/article.png" alt="In-shell reader"></div>
+  <div id="s4l" class="clip label" data-start="75.189" data-duration="18.983" data-track-index="2"><div class="li"><span class="num">05</span><span class="txt">In-shell reader</span></div></div>
+  <div id="s5" class="clip shot" data-start="94.172" data-duration="15.268" data-track-index="1"><img id="s5i" class="simg" src="media/captures/review.png" alt="The REview"></div>
+  <div id="s5l" class="clip label" data-start="94.172" data-duration="15.268" data-track-index="2"><div class="li"><span class="num">06</span><span class="txt">The REview</span></div></div>
+  <div id="s6" class="clip shot" data-start="109.440" data-duration="18.844" data-track-index="1"><img id="s6i" class="simg" src="media/captures/markets.png" alt="Live markets"></div>
+  <div id="s6l" class="clip label" data-start="109.440" data-duration="18.844" data-track-index="2"><div class="li"><span class="num">07</span><span class="txt">Live markets</span></div></div>
+  <div id="s7" class="clip shot" data-start="128.284" data-duration="13.457" data-track-index="1"><img id="s7i" class="simg" src="media/captures/pulse.png" alt="Market Pulse"></div>
+  <div id="s7l" class="clip label" data-start="128.284" data-duration="13.457" data-track-index="2"><div class="li"><span class="num">08</span><span class="txt">Market Pulse</span></div></div>
+  <div id="s8" class="clip shot" data-start="141.741" data-duration="11.367" data-track-index="1"><img id="s8i" class="simg" src="media/captures/cretalk.png" alt="CRE Talk"></div>
+  <div id="s8l" class="clip label" data-start="141.741" data-duration="11.367" data-track-index="2"><div class="li"><span class="num">09</span><span class="txt">CRE Talk</span></div></div>
+  <div id="s9" class="clip shot" data-start="153.108" data-duration="15.221" data-track-index="1"><img id="s9i" class="simg" src="media/captures/versions.png" alt="10 site versions"></div>
+  <div id="s9l" class="clip label" data-start="153.108" data-duration="15.221" data-track-index="2"><div class="li"><span class="num">10</span><span class="txt">10 site versions</span></div></div>
+  <div id="s10" class="clip shot" data-start="168.329" data-duration="10.577" data-track-index="1"><img id="s10i" class="simg" src="media/captures/v-wire.png" alt="The Wire"></div>
+  <div id="s10l" class="clip label" data-start="168.329" data-duration="10.577" data-track-index="2"><div class="li"><span class="num">11</span><span class="txt">The Wire</span></div></div>
+  <div id="s11" class="clip shot" data-start="178.906" data-duration="9.509" data-track-index="1"><img id="s11i" class="simg" src="media/captures/v-broadsheet.png" alt="Broadsheet"></div>
+  <div id="s11l" class="clip label" data-start="178.906" data-duration="9.509" data-track-index="2"><div class="li"><span class="num">12</span><span class="txt">Broadsheet</span></div></div>
+  <div id="s12" class="clip shot" data-start="188.415" data-duration="9.091" data-track-index="1"><img id="s12i" class="simg" src="media/captures/v-terminal.png" alt="Terminal"></div>
+  <div id="s12l" class="clip label" data-start="188.415" data-duration="9.091" data-track-index="2"><div class="li"><span class="num">13</span><span class="txt">Terminal</span></div></div>
+  <div id="s13" class="clip shot" data-start="197.506" data-duration="6.955" data-track-index="1"><img id="s13i" class="simg" src="media/captures/v-magazine.png" alt="Magazine"></div>
+  <div id="s13l" class="clip label" data-start="197.506" data-duration="6.955" data-track-index="2"><div class="li"><span class="num">14</span><span class="txt">Magazine</span></div></div>
+  <div id="s14" class="clip shot" data-start="204.461" data-duration="15.593" data-track-index="1"><img id="s14i" class="simg" src="media/captures/v-dashboard.png" alt="Dashboard"></div>
+  <div id="s14l" class="clip label" data-start="204.461" data-duration="15.593" data-track-index="2"><div class="li"><span class="num">15</span><span class="txt">Dashboard</span></div></div>
+  <div id="s15" class="clip shot" data-start="220.054" data-duration="17.776" data-track-index="1"><img id="s15i" class="simg" src="media/captures/theme-midnight.png" alt="10 themes"></div>
+  <div id="s15l" class="clip label" data-start="220.054" data-duration="17.776" data-track-index="2"><div class="li"><span class="num">16</span><span class="txt">10 themes</span></div></div>
+  <div id="s16" class="clip shot" data-start="237.830" data-duration="11.878" data-track-index="1"><img id="s16i" class="simg" src="media/captures/admin-index.png" alt="Admin dashboards"></div>
+  <div id="s16l" class="clip label" data-start="237.830" data-duration="11.878" data-track-index="2"><div class="li"><span class="num">17</span><span class="txt">Admin dashboards</span></div></div>
+  <div id="s17" class="clip shot" data-start="249.708" data-duration="17.311" data-track-index="1"><img id="s17i" class="simg" src="media/captures/admin-command.png" alt="Admin dashboards"></div>
+  <div id="s17l" class="clip label" data-start="249.708" data-duration="17.311" data-track-index="2"><div class="li"><span class="num">18</span><span class="txt">Admin dashboards</span></div></div>
+  <div id="s18" class="clip shot" data-start="267.019" data-duration="14.478" data-track-index="1"><img id="s18i" class="simg" src="media/captures/desk.png" alt="Broker &amp; Owner Desk"></div>
+  <div id="s18l" class="clip label" data-start="267.019" data-duration="14.478" data-track-index="2"><div class="li"><span class="num">19</span><span class="txt">Broker &amp; Owner Desk</span></div></div>
+  <div id="s19" class="clip shot" data-start="281.497" data-duration="11.181" data-track-index="1"><img id="s19i" class="simg" src="media/captures/social.png" alt="Social command center"></div>
+  <div id="s19l" class="clip label" data-start="281.497" data-duration="11.181" data-track-index="2"><div class="li"><span class="num">20</span><span class="txt">Social command center</span></div></div>
+  <div id="s20" class="clip shot" data-start="292.678" data-duration="13.596" data-track-index="1"><img id="s20i" class="simg" src="media/captures/blog.png" alt="Publishing desk"></div>
+  <div id="s20l" class="clip label" data-start="292.678" data-duration="13.596" data-track-index="2"><div class="li"><span class="num">21</span><span class="txt">Publishing desk</span></div></div>
+  <div id="s21" class="clip shot" data-start="306.274" data-duration="30.036" data-track-index="1"><img id="s21i" class="simg" src="media/captures/home.png" alt="rentv.agentabrams.com"></div>
+  <div id="s21l" class="clip label" data-start="306.274" data-duration="30.036" data-track-index="2"><div class="li"><span class="num">22</span><span class="txt">rentv.agentabrams.com</span></div></div>
+  </div>
+  <script>
+    window.__timelines = window.__timelines || {};
+    const tl = gsap.timeline({ paused: true });
+    tl.from('#s0i',{opacity:0,duration:0.4,ease:'power1.out'},0.000);
+    tl.fromTo('#s0i',{scale:1.05},{scale:1.0,duration:18.286,ease:'none'},0.000);
+    tl.from('#s0l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},0.150);
+    tl.from('#s1i',{opacity:0,duration:0.4,ease:'power1.out'},18.286);
+    tl.fromTo('#s1i',{scale:1.05},{scale:1.0,duration:18.751,ease:'none'},18.286);
+    tl.from('#s1l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},18.436);
+    tl.from('#s2i',{opacity:0,duration:0.4,ease:'power1.out'},37.037);
+    tl.fromTo('#s2i',{scale:1.05},{scale:1.0,duration:15.825,ease:'none'},37.037);
+    tl.from('#s2l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},37.187);
+    tl.from('#s3i',{opacity:0,duration:0.4,ease:'power1.out'},52.862);
+    tl.fromTo('#s3i',{scale:1.05},{scale:1.0,duration:22.327,ease:'none'},52.862);
+    tl.from('#s3l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},53.012);
+    tl.from('#s4i',{opacity:0,duration:0.4,ease:'power1.out'},75.189);
+    tl.fromTo('#s4i',{scale:1.05},{scale:1.0,duration:18.983,ease:'none'},75.189);
+    tl.from('#s4l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},75.339);
+    tl.from('#s5i',{opacity:0,duration:0.4,ease:'power1.out'},94.172);
+    tl.fromTo('#s5i',{scale:1.05},{scale:1.0,duration:15.268,ease:'none'},94.172);
+    tl.from('#s5l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},94.322);
+    tl.from('#s6i',{opacity:0,duration:0.4,ease:'power1.out'},109.440);
+    tl.fromTo('#s6i',{scale:1.05},{scale:1.0,duration:18.844,ease:'none'},109.440);
+    tl.from('#s6l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},109.590);
+    tl.from('#s7i',{opacity:0,duration:0.4,ease:'power1.out'},128.284);
+    tl.fromTo('#s7i',{scale:1.05},{scale:1.0,duration:13.457,ease:'none'},128.284);
+    tl.from('#s7l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},128.434);
+    tl.from('#s8i',{opacity:0,duration:0.4,ease:'power1.out'},141.741);
+    tl.fromTo('#s8i',{scale:1.05},{scale:1.0,duration:11.367,ease:'none'},141.741);
+    tl.from('#s8l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},141.891);
+    tl.from('#s9i',{opacity:0,duration:0.4,ease:'power1.out'},153.108);
+    tl.fromTo('#s9i',{scale:1.05},{scale:1.0,duration:15.221,ease:'none'},153.108);
+    tl.from('#s9l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},153.258);
+    tl.from('#s10i',{opacity:0,duration:0.4,ease:'power1.out'},168.329);
+    tl.fromTo('#s10i',{scale:1.05},{scale:1.0,duration:10.577,ease:'none'},168.329);
+    tl.from('#s10l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},168.479);
+    tl.from('#s11i',{opacity:0,duration:0.4,ease:'power1.out'},178.906);
+    tl.fromTo('#s11i',{scale:1.05},{scale:1.0,duration:9.509,ease:'none'},178.906);
+    tl.from('#s11l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},179.056);
+    tl.from('#s12i',{opacity:0,duration:0.4,ease:'power1.out'},188.415);
+    tl.fromTo('#s12i',{scale:1.05},{scale:1.0,duration:9.091,ease:'none'},188.415);
+    tl.from('#s12l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},188.565);
+    tl.from('#s13i',{opacity:0,duration:0.4,ease:'power1.out'},197.506);
+    tl.fromTo('#s13i',{scale:1.05},{scale:1.0,duration:6.955,ease:'none'},197.506);
+    tl.from('#s13l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},197.656);
+    tl.from('#s14i',{opacity:0,duration:0.4,ease:'power1.out'},204.461);
+    tl.fromTo('#s14i',{scale:1.05},{scale:1.0,duration:15.593,ease:'none'},204.461);
+    tl.from('#s14l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},204.611);
+    tl.from('#s15i',{opacity:0,duration:0.4,ease:'power1.out'},220.054);
+    tl.fromTo('#s15i',{scale:1.05},{scale:1.0,duration:17.776,ease:'none'},220.054);
+    tl.from('#s15l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},220.204);
+    tl.from('#s16i',{opacity:0,duration:0.4,ease:'power1.out'},237.830);
+    tl.fromTo('#s16i',{scale:1.05},{scale:1.0,duration:11.878,ease:'none'},237.830);
+    tl.from('#s16l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},237.980);
+    tl.from('#s17i',{opacity:0,duration:0.4,ease:'power1.out'},249.708);
+    tl.fromTo('#s17i',{scale:1.05},{scale:1.0,duration:17.311,ease:'none'},249.708);
+    tl.from('#s17l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},249.858);
+    tl.from('#s18i',{opacity:0,duration:0.4,ease:'power1.out'},267.019);
+    tl.fromTo('#s18i',{scale:1.05},{scale:1.0,duration:14.478,ease:'none'},267.019);
+    tl.from('#s18l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},267.169);
+    tl.from('#s19i',{opacity:0,duration:0.4,ease:'power1.out'},281.497);
+    tl.fromTo('#s19i',{scale:1.05},{scale:1.0,duration:11.181,ease:'none'},281.497);
+    tl.from('#s19l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},281.647);
+    tl.from('#s20i',{opacity:0,duration:0.4,ease:'power1.out'},292.678);
+    tl.fromTo('#s20i',{scale:1.05},{scale:1.0,duration:13.596,ease:'none'},292.678);
+    tl.from('#s20l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},292.828);
+    tl.from('#s21i',{opacity:0,duration:0.4,ease:'power1.out'},306.274);
+    tl.fromTo('#s21i',{scale:1.05},{scale:1.0,duration:30.036,ease:'none'},306.274);
+    tl.from('#s21l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},306.424);
+    window.__timelines['main'] = tl;
+  </script>
+</body></html>
\ No newline at end of file
diff --git a/master-gen.mjs b/master-gen.mjs
new file mode 100644
index 0000000..07562f7
--- /dev/null
+++ b/master-gen.mjs
@@ -0,0 +1,80 @@
+import fs from 'fs';
+const W = 1920, H = 1080;
+const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+// id, capture file (in media/captures/), on-screen label, narration text (Steve voice)
+export const SCENES = [
+  ['01a', 'home', 'A note for Boomer', "Boomer — Steve here. Before anything else: what you've built with RENTV is remarkable. For decades it's been the place the West Coast commercial real estate world goes to find out what actually closed, who financed it, and what it traded for. That reputation is the hard part — and you already own it."],
+  ['01b', 'home', 'A note for Boomer', "So everything you're about to see is just a set of suggestions — ideas we sketched on top of your live feed, running on your real stories, built in a single day. None of it is a critique. It's a fan letter with working code attached. Take what's useful, ignore the rest, and know that every call stays yours."],
+  ['02a', 'home', 'The front page', "Let's start where your readers start — the front page. The idea here is simple: let the brand lead, and let the wire run underneath it. RENTV Originals sits right up top, your editorial voice front and center, and then the live wire begins."],
+  ['02b', 'home', 'The front page', "Every headline below it is your actual feed, pulled live from rentv dot com and updating all day — The Swig Company's refinancing in San Francisco, Tailwind's Sacramento buy. And the full section map is right there: Sales, Leases, Financing, Development, Multifamily, Retail, Industrial, and L.A. Commercial — so a broker who only cares about one lane gets straight to it."],
+  ['03', 'article', 'In-shell reader', "When a reader clicks a headline, the story opens right inside the site — same masthead, same navigation, no jumping away. The photo's there, the category tag is there, and related stories wait at the bottom, so the natural next click is another RENTV story. It keeps people in your world — reading one more piece, and then another."],
+  ['04', 'review', 'The REview', "The REview deserves its own stage, so we gave it one. Face to face with the dealmakers — your interview franchise, presented like the flagship it is. This is content nobody else in the space has: the people who actually move the buildings, on camera, in their own words."],
+  ['05', 'markets', 'Live markets', "Here's a live Markets page — the ten-year treasury, capital-markets rates, the REIT board, the numbers a commercial real estate reader checks every single morning. It's built on public data feeds, so it refreshes on its own — no data entry, no upkeep. A reason to open RENTV before the first phone call of the day."],
+  ['05b', 'pulse', 'Market Pulse', "And layered on top of the deals you already track, a Market Pulse — total transaction volume, deal count, the biggest trades, sliced by property type and by market. It turns your reporting into a dashboard the whole industry would check."],
+  ['06', 'cretalk', 'CRE Talk', "CRE Talk — your video conversations, collected in one clean place, easy to browse and easy to play. The interviews are already great; this just gives them a proper home next to everything else."],
+  ['07i', 'versions', '10 site versions', "Now the fun part. Instead of asking you to imagine a redesign, we built ten complete versions of the site — whole directions, not mockups, every one running your live stories right now. Let me walk you through them, so you can just point at the one that feels like RENTV."],
+  ['07a', 'v-wire', 'The Wire', "The Wire — the modern newsroom. A lead story, a three-column river of headlines, a right rail for what's trending. Clean, fast, familiar to anyone who reads news online."],
+  ['07b', 'v-broadsheet', 'Broadsheet', "Broadsheet — this one reads like a printed paper on the web. Dense serif columns, small photos, a front page that feels like the business section you grew up with."],
+  ['07c', 'v-terminal', 'Terminal', "Terminal — a dark, data-first deal desk. If your reader lives in a Bloomberg window all day, this is the RENTV that feels like home."],
+  ['07d', 'v-magazine', 'Magazine', "Magazine — big and editorial. One oversized feature up top, generous photography, the look of a glossy quarterly."],
+  ['07e', 'v-dashboard', 'Dashboard', "Dashboard — a midnight command view. Every surface at a glance, for the reader who wants the whole market on one screen. And there are more still — Daily Digest, Photo Grid, Split, Minimal, and Feed. Ten directions; pick the one that's you."],
+  ['08', 'theme-midnight', '10 themes', "On top of the versions, ten professional themes. One click in the toolbar restyles the entire site — RENTV Classic, Broadsheet, F.T. Salmon, Bloomberg Terminal, Midnight Capital, and more. Same content, different suit — real financial-publication palettes, and the site remembers each reader's choice."],
+  ['09a', 'admin-index', 'Admin dashboards', "For your team, we sketched ten admin dashboards — because the newsroom behind the site matters as much as the site. A command center that puts every surface on one screen: subscribers, stories, videos, the feed."],
+  ['09b', 'admin-command', 'Admin dashboards', "A kanban board for moving work from pitch to published. Analytics for what readers actually open. A classic layout, a sidebar, a split view, a console, cards, a clean table, and a minimal desk. Same live data in every one — pick the workflow that matches how your people actually work."],
+  ['09c', 'desk', 'Broker & Owner Desk', "And underneath it, the real asset: a Broker and Owner Desk sitting on a live registry — hundreds of thousands of brokers, tens of thousands of firms, and a sublease desk. The kind of data most publishers would kill for, and you already have it in the backyard."],
+  ['09d', 'social', 'Social command center', "Plus a social command center — LinkedIn, X, and Instagram in one place, a content calendar, and draft posts ready to go, so every deal you report can reach the audiences that live on those platforms."],
+  ['10', 'blog', 'Publishing desk', "And a simple publishing desk. Write a post, hit publish, and it's live on the blog — no plugins, no fighting a content system. A subscriber list builds alongside it, so the newsletter audience you've earned has a place to keep growing."],
+  ['11', 'home', 'rentv.agentabrams.com', "So that's the tour — a rebuilt front page, an in-shell reader, The REview, live markets and market pulse, CRE Talk, ten site versions, ten themes, ten dashboards, the intelligence desk, a social command center, and a publishing desk, all running on your live data. Boomer, none of this is a critique; it's a fan letter with working code attached. Every call is yours. It's all live at rentv dot agentabrams dot com. Talk soon."],
+];
+
+function emit(durs) {
+  // durs: array of per-scene durations (block audio + gap), aligned to narration.m4a
+  const clips = [], tw = [];
+  let t = 0;
+  const TOTAL = +durs.reduce((a, b) => a + b, 0).toFixed(2);
+  SCENES.forEach(([id, cap, label], i) => {
+    const d = durs[i], st = t.toFixed(3), du = d.toFixed(3);
+    const isCard = false;
+    clips.push(`  <div id="s${i}" class="clip shot" data-start="${st}" data-duration="${du}" data-track-index="1"><img id="s${i}i" class="simg" src="media/captures/${cap}.png" alt="${esc(label)}"></div>`);
+    clips.push(`  <div id="s${i}l" class="clip label" data-start="${st}" data-duration="${du}" data-track-index="2"><div class="li"><span class="num">${String(i + 1).padStart(2, '0')}</span><span class="txt">${esc(label)}</span></div></div>`);
+    tw.push(`tl.from('#s${i}i',{opacity:0,duration:0.4,ease:'power1.out'},${st});`);
+    tw.push(`tl.fromTo('#s${i}i',{scale:1.05},{scale:1.0,duration:${du},ease:'none'},${st});`);
+    tw.push(`tl.from('#s${i}l',{y:24,autoAlpha:0,duration:0.45,ease:'power2.out'},${(t + 0.15).toFixed(3)});`);
+    t += d;
+  });
+  const html = `<!doctype html><html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=1920, height=1080" />
+<title>RENTV — Master Reel</title>
+<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+<style>
+  body{margin:0;background:#070b10;font-family:system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif}
+  #root{position:relative;width:${W}px;height:${H}px;overflow:hidden}
+  .clip{position:absolute;inset:0}
+  #bg{background:#0b1017}
+  .shot{overflow:hidden}.simg{width:100%;height:100%;object-fit:cover;object-position:top center;display:block;will-change:transform,opacity}
+  .label{left:56px;bottom:60px;right:auto;top:auto;width:auto;height:auto}
+  .li{display:flex;align-items:center;gap:14px}
+  .li .num{color:#fff;font-size:18px;font-weight:700;letter-spacing:.06em;padding:9px 13px;border-radius:8px;background:#0a6cff}
+  .li .txt{color:#fff;font-size:30px;font-weight:500;background:rgba(9,15,24,.9);padding:9px 20px;border-radius:10px}
+</style></head><body>
+  <div id="root" data-composition-id="main" data-start="0" data-width="${W}" data-height="${H}" data-duration="${TOTAL}" data-fps="30">
+  <div id="bg" class="clip" data-start="0" data-duration="${TOTAL}" data-track-index="0"></div>
+  <audio id="vo" src="media/narration.m4a" data-start="0" data-volume="1"></audio>
+${clips.join('\n')}
+  </div>
+  <script>
+    window.__timelines = window.__timelines || {};
+    const tl = gsap.timeline({ paused: true });
+${tw.map(x => '    ' + x).join('\n')}
+    window.__timelines['main'] = tl;
+  </script>
+</body></html>`;
+  fs.writeFileSync('index.html', html);
+  return TOTAL;
+}
+
+const [cmd, a] = process.argv.slice(2);
+if (cmd === 'count') console.log(SCENES.length);
+else if (cmd === 'text') process.stdout.write(SCENES[+a][3]);
+else if (cmd === 'emit') console.log('TOTAL=' + emit(JSON.parse(fs.readFileSync(a, 'utf8'))));
diff --git a/media/captures/admin-analytics.png b/media/captures/admin-analytics.png
new file mode 100644
index 0000000..354ab11
Binary files /dev/null and b/media/captures/admin-analytics.png differ
diff --git a/media/captures/admin-command.png b/media/captures/admin-command.png
new file mode 100644
index 0000000..62243a5
Binary files /dev/null and b/media/captures/admin-command.png differ
diff --git a/media/captures/admin-index.png b/media/captures/admin-index.png
new file mode 100644
index 0000000..8c81933
Binary files /dev/null and b/media/captures/admin-index.png differ
diff --git a/media/captures/admin-kanban.png b/media/captures/admin-kanban.png
new file mode 100644
index 0000000..2cd5e90
Binary files /dev/null and b/media/captures/admin-kanban.png differ
diff --git a/media/captures/article.png b/media/captures/article.png
new file mode 100644
index 0000000..a301f26
Binary files /dev/null and b/media/captures/article.png differ
diff --git a/media/captures/blog.png b/media/captures/blog.png
new file mode 100644
index 0000000..7a6d7f4
Binary files /dev/null and b/media/captures/blog.png differ
diff --git a/media/captures/cretalk.png b/media/captures/cretalk.png
new file mode 100644
index 0000000..fe21ff2
Binary files /dev/null and b/media/captures/cretalk.png differ
diff --git a/media/captures/desk.png b/media/captures/desk.png
new file mode 100644
index 0000000..2c05b47
Binary files /dev/null and b/media/captures/desk.png differ
diff --git a/media/captures/home.png b/media/captures/home.png
new file mode 100644
index 0000000..7aca902
Binary files /dev/null and b/media/captures/home.png differ
diff --git a/media/captures/markets.png b/media/captures/markets.png
new file mode 100644
index 0000000..cddcdd1
Binary files /dev/null and b/media/captures/markets.png differ
diff --git a/media/captures/publish.png b/media/captures/publish.png
new file mode 100644
index 0000000..8c81933
Binary files /dev/null and b/media/captures/publish.png differ
diff --git a/media/captures/pulse.png b/media/captures/pulse.png
new file mode 100644
index 0000000..bb68046
Binary files /dev/null and b/media/captures/pulse.png differ
diff --git a/media/captures/review.png b/media/captures/review.png
new file mode 100644
index 0000000..a656e53
Binary files /dev/null and b/media/captures/review.png differ
diff --git a/media/captures/social.png b/media/captures/social.png
new file mode 100644
index 0000000..7c3ef6a
Binary files /dev/null and b/media/captures/social.png differ
diff --git a/media/captures/theme-midnight.png b/media/captures/theme-midnight.png
new file mode 100644
index 0000000..8be9599
Binary files /dev/null and b/media/captures/theme-midnight.png differ
diff --git a/media/captures/theme-salmon.png b/media/captures/theme-salmon.png
new file mode 100644
index 0000000..6ea6e5e
Binary files /dev/null and b/media/captures/theme-salmon.png differ
diff --git a/media/captures/v-broadsheet.png b/media/captures/v-broadsheet.png
new file mode 100644
index 0000000..60b54a6
Binary files /dev/null and b/media/captures/v-broadsheet.png differ
diff --git a/media/captures/v-dashboard.png b/media/captures/v-dashboard.png
new file mode 100644
index 0000000..7cf4156
Binary files /dev/null and b/media/captures/v-dashboard.png differ
diff --git a/media/captures/v-magazine.png b/media/captures/v-magazine.png
new file mode 100644
index 0000000..3de8359
Binary files /dev/null and b/media/captures/v-magazine.png differ
diff --git a/media/captures/v-terminal.png b/media/captures/v-terminal.png
new file mode 100644
index 0000000..7ab67cb
Binary files /dev/null and b/media/captures/v-terminal.png differ
diff --git a/media/captures/v-wire.png b/media/captures/v-wire.png
new file mode 100644
index 0000000..c9aa463
Binary files /dev/null and b/media/captures/v-wire.png differ
diff --git a/media/captures/versions.png b/media/captures/versions.png
new file mode 100644
index 0000000..756bc9d
Binary files /dev/null and b/media/captures/versions.png differ
diff --git a/media/vo/concat.txt b/media/vo/concat.txt
new file mode 100644
index 0000000..04a8b19
--- /dev/null
+++ b/media/vo/concat.txt
@@ -0,0 +1,23 @@
+file '_sil-lead.wav'
+file '01-intro.wav'
+file '_sil-gap.wav'
+file '02-frontpage.wav'
+file '_sil-gap.wav'
+file '03-reader.wav'
+file '_sil-gap.wav'
+file '04-review.wav'
+file '_sil-gap.wav'
+file '05-markets.wav'
+file '_sil-gap.wav'
+file '06-cretalk.wav'
+file '_sil-gap.wav'
+file '07-versions.wav'
+file '_sil-gap.wav'
+file '08-themes.wav'
+file '_sil-gap.wav'
+file '09-admin.wav'
+file '_sil-gap.wav'
+file '10-publish.wav'
+file '_sil-gap.wav'
+file '11-close.wav'
+file '_sil-tail.wav'
\ No newline at end of file
diff --git a/media/vo/durations.json b/media/vo/durations.json
new file mode 100644
index 0000000..8a07bbf
--- /dev/null
+++ b/media/vo/durations.json
@@ -0,0 +1,13 @@
+{
+  "01-intro": 27.82,
+  "02-frontpage": 34.69,
+  "03-reader": 24.52,
+  "04-review": 13.7,
+  "05-markets": 23.96,
+  "06-cretalk": 9.8,
+  "07-versions": 56.33,
+  "08-themes": 22.52,
+  "09-admin": 30,
+  "10-publish": 20.81,
+  "11-close": 28.65
+}
\ No newline at end of file
diff --git a/meta.json b/meta.json
new file mode 100644
index 0000000..5a53b87
--- /dev/null
+++ b/meta.json
@@ -0,0 +1,5 @@
+{
+  "id": "my-video",
+  "name": "my-video",
+  "createdAt": "2026-07-22T20:41:14.117Z"
+}
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..152d9bc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "my-video",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "npx --yes hyperframes@0.7.68 preview",
+    "check": "npx --yes hyperframes@0.7.68 check",
+    "render": "npx --yes hyperframes@0.7.68 render",
+    "publish": "npx --yes hyperframes@0.7.68 publish"
+  }
+}
diff --git a/scripts/build-composition.mjs b/scripts/build-composition.mjs
new file mode 100644
index 0000000..cc3d55d
--- /dev/null
+++ b/scripts/build-composition.mjs
@@ -0,0 +1,217 @@
+#!/usr/bin/env node
+/* Build master narration track + generate index.html for the RENTV recap.
+   Deterministic: all timing baked statically from media/vo/durations.json. */
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+
+const ROOT = new URL('..', import.meta.url).pathname;
+const FF = '/opt/homebrew/bin/ffmpeg';
+const durs = JSON.parse(readFileSync(ROOT + 'media/vo/durations.json', 'utf8'));
+
+const ORDER = ['01-intro','02-frontpage','03-reader','04-review','05-markets','06-cretalk',
+               '07-versions','08-themes','09-admin','10-publish','11-close'];
+const LEAD = 0.8, GAP = 0.8, TAIL = 3.5;
+
+// ---- timeline math: scene i visuals start when its VO block starts minus LEAD-ish pad
+const r2 = (x) => Math.round(x * 100) / 100;
+let t = LEAD;
+const vo = {};                       // vo start per scene
+for (const id of ORDER) { vo[id] = t; t += durs[id] + GAP; }
+const TOTAL = Math.ceil((t - GAP + TAIL) * 10) / 10;
+const sceneStart = {};
+ORDER.forEach((id, i) => { sceneStart[id] = i === 0 ? 0 : r2(vo[id] - GAP); });
+const sceneEnd = {};
+ORDER.forEach((id, i) => { sceneEnd[id] = i === ORDER.length - 1 ? TOTAL : sceneStart[ORDER[i + 1]]; });
+
+// ---- master.wav: silence LEAD + vo + GAP ... + TAIL   (all 44.1k mono pcm)
+mkdirSync(ROOT + 'media', { recursive: true });
+const mk = (f, secs) => execFileSync(FF, ['-y','-loglevel','error','-f','lavfi','-i',
+  `anullsrc=r=44100:cl=mono`, '-t', String(secs), '-c:a','pcm_s16le', f]);
+mk(ROOT + 'media/vo/_sil-lead.wav', LEAD);
+mk(ROOT + 'media/vo/_sil-gap.wav', GAP);
+mk(ROOT + 'media/vo/_sil-tail.wav', TAIL);
+const list = [];
+list.push('_sil-lead.wav');
+ORDER.forEach((id, i) => { list.push(id + '.wav'); if (i < ORDER.length - 1) list.push('_sil-gap.wav'); });
+list.push('_sil-tail.wav');
+writeFileSync(ROOT + 'media/vo/concat.txt', list.map(f => `file '${f}'`).join('\n'));
+execFileSync(FF, ['-y','-loglevel','error','-f','concat','-safe','0','-i', ROOT + 'media/vo/concat.txt',
+  '-ar','44100','-ac','1','-c:a','pcm_s16le', ROOT + 'media/narration.wav']);
+
+// ---- shot plan.  Each shot: [img, h(px), label?]  laid sequentially inside its scene.
+const IMG = (n) => `media/captures/${n}.png`;
+const shotsByScene = {
+  '02-frontpage': [['home', 2593]],
+  '03-reader':    [['article', 2242]],
+  '04-review':    [['review', 3328]],
+  '05-markets':   [['markets', 1545]],
+  '06-cretalk':   [['cretalk', 2760]],
+  '07-versions':  [['versions', 1186, 'The Versions Picker'], ['v-wire', 1080, 'The Wire'],
+                   ['v-broadsheet', 1080, 'Broadsheet'], ['v-terminal', 1080, 'Terminal'],
+                   ['v-magazine', 1080, 'Magazine'], ['v-dashboard', 1080, 'Dashboard']],
+  '08-themes':    [['theme-midnight', 1080, 'Theme · Midnight Capital'], ['theme-salmon', 1080, 'Theme · FT Salmon']],
+  '09-admin':     [['admin-index', 1080, 'All 10 Dashboards'], ['admin-command', 1080, 'Command Center'],
+                   ['admin-kanban', 1080, 'Kanban'], ['admin-analytics', 1080, 'Analytics']],
+  '10-publish':   [['blog', 1080, 'The Blog'], ['publish', 1080, 'The Publishing Desk']],
+};
+const chips = {
+  '02-frontpage': ['The Front Page', 'RENTV Originals · leading the live wire'],
+  '03-reader':    ['The Article Reader', 'stories open in-shell — readers stay on RENTV'],
+  '04-review':    ['The REview', 'face to face with the dealmakers'],
+  '05-markets':   ['Markets — Live', 'rates + REITs · maintains itself'],
+  '06-cretalk':   ['CRE Talk', 'the video conversations, in one place'],
+  '07-versions':  ['Ten Complete Site Versions', 'whole directions — all on live data'],
+  '08-themes':    ['Ten Professional Themes', 'one click restyles the entire site'],
+  '09-admin':     ['Ten Admin Dashboards', 'same live data · pick your workflow'],
+  '10-publish':   ['The Publishing Desk', 'write → publish → live, plus subscribers'],
+};
+
+let clips = '', tw = '';
+const esc = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;');
+
+// scene 01: title card
+{
+  const s = 0, d = sceneEnd['01-intro'];
+  clips += `
+  <section id="sc01" class="clip card" data-start="0" data-duration="${d.toFixed(2)}" data-track-index="1">
+    <div class="cardin" id="sc01in">
+      <div class="kicker">A BUILD RECAP · WITH ADMIRATION</div>
+      <h1>RENTV<span class="red">.com</span></h1>
+      <p class="sub">For Boomer — the new build, on your live feed.<br/>Ten versions · ten themes · ten dashboards · built in a day.</p>
+    </div>
+  </section>`;
+  tw += `
+  tl.to("#sc01", { opacity: 1, duration: 0.35 }, 0);
+  tl.from("#sc01in", { opacity: 0, y: 40, duration: 1.2, ease: "power3.out" }, ${(s + 0.3).toFixed(2)});
+  tl.fromTo("#sc01in", { scale: 1 }, { scale: 1.045, duration: ${(d - 1).toFixed(2)}, ease: "none" }, ${(s + 0.5).toFixed(2)});
+  tl.to("#sc01in", { opacity: 0, duration: 0.6, ease: "power2.in" }, ${(d - 0.6).toFixed(2)});
+  tl.set("#sc01in", { opacity: 0 }, ${d.toFixed(2)});`;
+}
+
+// scenes 02..10: image shots
+for (const id of ORDER.slice(1, 10)) {
+  const s0 = sceneStart[id], s1 = sceneEnd[id], dur = s1 - s0;
+  const shots = shotsByScene[id];
+  const per = dur / shots.length;
+  // rounded shot boundaries so adjacent clips never overlap by float dust
+  const bounds = shots.map((_, i) => r2(s0 + i * per)).concat([s1]);
+  shots.forEach((sh, i) => {
+    const [name, h, label] = sh;
+    const cs = bounds[i], cd = r2(bounds[i + 1] - bounds[i]);
+    const cid = `s${id.slice(0, 2)}i${i}`;
+    clips += `
+  <section id="${cid}" class="clip shot" data-start="${cs.toFixed(2)}" data-duration="${cd.toFixed(2)}" data-track-index="1">
+    <img id="${cid}m" src="${IMG(name)}" alt=""/>
+  </section>`;
+    tw += `
+  tl.to("#${cid}", { opacity: 1, duration: 0.45, ease: "power2.out" }, ${cs.toFixed(2)});`;
+    if (h > 1120) {
+      const travel = Math.min(h - 1080, Math.max(60, 78 * cd));
+      tw += `
+  tl.fromTo("#${cid}m", { y: 0 }, { y: ${-Math.round(travel)}, duration: ${(cd - 0.2).toFixed(2)}, ease: "sine.inOut" }, ${(cs + 0.1).toFixed(2)});`;
+    } else {
+      const zin = i % 2 === 0;
+      tw += `
+  tl.fromTo("#${cid}m", { scale: ${zin ? 1 : 1.07}, transformOrigin: "50% 30%" }, { scale: ${zin ? 1.07 : 1}, duration: ${cd.toFixed(2)}, ease: "none" }, ${cs.toFixed(2)});`;
+    }
+    if (label) {
+      const ld = Math.min(4.5, cd - 0.4);
+      clips += `
+  <div class="clip sublabel" id="${cid}l" data-start="${(cs + 0.3).toFixed(2)}" data-duration="${ld.toFixed(2)}" data-track-index="3"><span>${esc(label)}</span></div>`;
+      tw += `
+  tl.from("#${cid}l", { opacity: 0, x: -18, duration: 0.4, ease: "power2.out" }, ${(cs + 0.3).toFixed(2)});
+  tl.to("#${cid}l", { opacity: 0, duration: 0.35 }, ${(cs + 0.3 + ld - 0.35).toFixed(2)});`;
+    }
+  });
+  // scene chip (lower-third)
+  const c = chips[id];
+  const chd = Math.min(7, dur - 1);
+  clips += `
+  <div class="clip chip" id="ch${id.slice(0, 2)}" data-start="${(s0 + 0.7).toFixed(2)}" data-duration="${chd.toFixed(2)}" data-track-index="4">
+    <div class="chipin" id="ch${id.slice(0, 2)}in"><div class="bar"></div><div class="txt"><b>${esc(c[0])}</b><span>${esc(c[1])}</span></div></div>
+  </div>`;
+  tw += `
+  tl.from("#ch${id.slice(0, 2)}", { opacity: 0, y: 26, duration: 0.55, ease: "power3.out" }, ${(s0 + 0.7).toFixed(2)});
+  tl.to("#ch${id.slice(0, 2)}in", { opacity: 0, duration: 0.4 }, ${(s0 + 0.7 + chd - 0.4).toFixed(2)});
+  tl.set("#ch${id.slice(0, 2)}in", { opacity: 0 }, ${(s0 + 0.7 + chd).toFixed(2)});`;
+}
+
+// scene 11: home glimpse then close card
+{
+  const s0 = sceneStart['11-close'];
+  const glimpse = 7.5;
+  clips += `
+  <section id="s11a" class="clip shot" data-start="${s0.toFixed(2)}" data-duration="${glimpse.toFixed(2)}" data-track-index="1">
+    <img id="s11am" src="${IMG('home')}" alt=""/>
+  </section>
+  <section id="s11b" class="clip card" data-start="${(s0 + glimpse).toFixed(2)}" data-duration="${(TOTAL - s0 - glimpse).toFixed(2)}" data-track-index="1">
+    <div class="cardin" id="s11bin">
+      <div class="kicker">THANK YOU, BOOMER — THESE ARE JUST SUGGESTIONS</div>
+      <h2>rentv-v1<span class="red">.agentabrams.com</span></h2>
+      <p class="logins">login&nbsp;&nbsp;<b>admin / DW2024!</b>&nbsp;&nbsp;or&nbsp;&nbsp;<b>Boomer / Rentv2024!</b></p>
+      <p class="sub2">Every headline you saw is live rentv.com data · built in a day · your call, always.</p>
+    </div>
+  </section>`;
+  tw += `
+  tl.to("#s11a", { opacity: 1, duration: 0.5 }, ${s0.toFixed(2)});
+  tl.fromTo("#s11am", { y: 0 }, { y: -420, duration: ${(glimpse - 0.2).toFixed(2)}, ease: "sine.inOut" }, ${(s0 + 0.1).toFixed(2)});
+  tl.to("#s11b", { opacity: 1, duration: 0.5 }, ${(s0 + glimpse).toFixed(2)});
+  tl.from("#s11bin", { opacity: 0, y: 36, duration: 1, ease: "power3.out" }, ${(s0 + glimpse + 0.1).toFixed(2)});
+  tl.fromTo("#s11bin", { scale: 1 }, { scale: 1.035, duration: ${(TOTAL - s0 - glimpse - 1).toFixed(2)}, ease: "none" }, ${(s0 + glimpse + 0.4).toFixed(2)});`;
+}
+
+const html = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="UTF-8"/>
+<meta name="viewport" content="width=1920, height=1080"/>
+<title>RENTV Recap for Boomer</title>
+<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+<style>
+  html,body{margin:0;background:#0c0f13}
+  #root{position:relative;width:1920px;height:1080px;overflow:hidden;background:#0c0f13;
+        font-family:'Inter','Helvetica Neue',system-ui,sans-serif;color:#fff}
+  .clip{position:absolute}
+  .shot{inset:0;overflow:hidden;background:#0c0f13;opacity:0}
+  .shot img{display:block;width:1920px;height:auto;will-change:transform}
+  .card{inset:0;display:grid;place-items:center;opacity:0;background:
+        radial-gradient(1200px 700px at 50% 38%, #171d26 0%, #0c0f13 70%)}
+  .cardin{text-align:center;max-width:1400px}
+  .kicker{font-size:26px;letter-spacing:.42em;color:#c8102e;font-weight:800;margin-bottom:34px}
+  h1{font-family:Georgia,'Times New Roman',serif;font-size:170px;margin:0;font-weight:700;letter-spacing:.01em}
+  h2{font-family:Georgia,'Times New Roman',serif;font-size:96px;margin:0;font-weight:700}
+  .red{color:#c8102e}
+  .sub{font-size:34px;line-height:1.55;color:#aeb6c2;margin-top:38px;font-weight:300}
+  .sub2{font-size:28px;color:#aeb6c2;margin-top:26px;font-weight:300}
+  .logins{font-size:36px;margin-top:44px;color:#e8ecf2}
+  .logins b{color:#fff;background:#1a212c;border:1px solid #2c3644;border-radius:8px;padding:8px 18px}
+  .chip{left:80px;bottom:76px;display:flex;align-items:stretch;background:rgba(10,13,17,.82);
+        border:1px solid rgba(255,255,255,.14);border-radius:10px;overflow:hidden;
+        box-shadow:0 12px 40px rgba(0,0,0,.45)}
+  .chipin{display:flex;align-items:stretch}
+  .chip .bar{width:10px;background:#c8102e}
+  .chip .txt{padding:20px 30px}
+  .chip b{display:block;font-family:Georgia,serif;font-size:40px;font-weight:700}
+  .chip span{display:block;font-size:23px;color:#aeb6c2;margin-top:6px}
+  .sublabel{right:80px;top:64px}
+  .sublabel span{background:#c8102e;color:#fff;font-size:24px;font-weight:800;letter-spacing:.08em;
+        padding:12px 22px;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.4)}
+</style>
+</head>
+<body>
+<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="${TOTAL}">
+${clips}
+</div>
+<audio id="vo" src="media/narration.wav" data-start="0" data-duration="${TOTAL}" data-track-index="10" data-volume="1"></audio>
+<script>
+  window.__timelines = window.__timelines || {};
+  const tl = gsap.timeline({ paused: true });
+${tw}
+  window.__timelines["main"] = tl;
+</script>
+</body>
+</html>
+`;
+writeFileSync(ROOT + 'index.html', html);
+console.log('TOTAL duration', TOTAL + 's');
+console.log(ORDER.map(id => `${id} @ ${sceneStart[id].toFixed(1)}s`).join('\n'));
diff --git a/scripts/capture.mjs b/scripts/capture.mjs
new file mode 100644
index 0000000..3e69dbb
--- /dev/null
+++ b/scripts/capture.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+/* Capture live rentv-v1.agentabrams.com screenshots for the recap video.
+   Uses httpCredentials (URL-embedded creds don't reach page fetch()). */
+import { createRequire } from 'node:module';
+import { mkdirSync } from 'node:fs';
+const require = createRequire('/Users/macstudio3/Projects/consulting-rentv-com/node_modules/');
+const { chromium } = require('playwright');
+
+const BASE = 'https://rentv-v1.agentabrams.com';
+const OUT = new URL('../media/captures/', import.meta.url).pathname;
+mkdirSync(OUT, { recursive: true });
+
+const SHOTS = [
+  // [name, path, {full, theme, waitMs}]
+  ['home',            '/',                        { full: true,  waitMs: 6000 }],
+  ['article',         '/news/34236',              { full: true,  waitMs: 6000 }],
+  ['review',          '/review',                  { full: true,  waitMs: 6000 }],
+  ['markets',         '/markets',                 { full: true,  waitMs: 8000 }],
+  ['cretalk',         '/cre-talk',                { full: true,  waitMs: 6000 }],
+  ['versions',        '/versions',                { full: true,  waitMs: 4000 }],
+  ['v-wire',          '/versions/wire.html',      { full: false, waitMs: 6000 }],
+  ['v-broadsheet',    '/versions/broadsheet.html',{ full: false, waitMs: 6000 }],
+  ['v-terminal',      '/versions/terminal.html',  { full: false, waitMs: 6000 }],
+  ['v-magazine',      '/versions/magazine.html',  { full: false, waitMs: 6000 }],
+  ['v-dashboard',     '/versions/dashboard.html', { full: false, waitMs: 6000 }],
+  ['theme-midnight',  '/',                        { full: false, theme: 4, waitMs: 6000 }],
+  ['theme-salmon',    '/',                        { full: false, theme: 2, waitMs: 6000 }],
+  ['admin-index',     '/admin',                   { full: true,  waitMs: 5000 }],
+  ['admin-command',   '/admin/command.html',      { full: false, waitMs: 6000 }],
+  ['admin-kanban',    '/admin/kanban.html',       { full: false, waitMs: 6000 }],
+  ['admin-analytics', '/admin/analytics.html',    { full: false, waitMs: 6000 }],
+  ['blog',            '/blog',                    { full: true,  waitMs: 5000 }],
+  ['publish',         '/admin/publish.html',      { full: false, waitMs: 6000 }],
+];
+
+const browser = await chromium.launch();
+const ctx = await browser.newContext({
+  httpCredentials: { username: 'admin', password: 'DW2024!' },
+  viewport: { width: 1920, height: 1080 },
+  deviceScaleFactor: 1,
+});
+
+for (const [name, path, opt] of SHOTS) {
+  const page = await ctx.newPage();
+  try {
+    if (opt.theme !== undefined) {
+      await page.addInitScript(t => localStorage.setItem('rentv-theme', String(t)), opt.theme);
+    }
+    await page.goto(BASE + path, { waitUntil: 'load', timeout: 45000 });
+    await page.waitForTimeout(opt.waitMs || 5000);
+    await page.screenshot({ path: `${OUT}${name}.png`, fullPage: !!opt.full });
+    console.log('ok', name);
+  } catch (e) {
+    console.log('FAIL', name, e.message.split('\n')[0]);
+  }
+  await page.close();
+}
+await browser.close();
diff --git a/scripts/tts.mjs b/scripts/tts.mjs
new file mode 100644
index 0000000..3947002
--- /dev/null
+++ b/scripts/tts.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/* Parse SCRIPT.md blocks → ElevenLabs (Steve clone) via clone-voice say.sh →
+   media/vo/NN-name.wav + media/vo/durations.json */
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+
+const ROOT = new URL('..', import.meta.url).pathname;
+const OUT = ROOT + 'media/vo/';
+mkdirSync(OUT, { recursive: true });
+
+const md = readFileSync(ROOT + 'SCRIPT.md', 'utf8');
+const blocks = [...md.matchAll(/^## (\d\d-[a-z]+)[^\n]*\n\n([\s\S]*?)(?=\n## |\n*$)/gm)]
+  .map(m => [m[1], m[2].trim().replace(/\s+/g, ' ')]);
+
+const SAY = process.env.HOME + '/.claude/skills/clone-voice/scripts/say.sh';
+const durations = {};
+let chars = 0;
+for (const [id, text] of blocks) {
+  chars += text.length;
+  const wav = OUT + id + '.wav';
+  execFileSync('bash', [SAY, text, wav, 'elevenlabs'], {
+    stdio: ['ignore', 'inherit', 'inherit'],
+    env: { ...process.env, PATH: '/opt/homebrew/bin:' + process.env.PATH },
+  });
+  const d = parseFloat(execFileSync('/opt/homebrew/bin/ffprobe',
+    ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', wav]).toString());
+  durations[id] = Math.round(d * 100) / 100;
+  console.log(id, durations[id] + 's', text.length + 'ch');
+}
+writeFileSync(OUT + 'durations.json', JSON.stringify(durations, null, 2));
+const total = Object.values(durations).reduce((a, b) => a + b, 0);
+console.log('TOTAL', Math.round(total) + 's', chars + ' chars');

(oldest)  ·  back to Rentv Master Reel  ·  Long 14.3min pan version (full-page captures, narration gaps 570b58d →