creador_videos_ai — Marketing Video Pipeline
Turns any public website URL into a polished marketing MP4 by combining a self-playing HTML cinematic tour (live <iframe> scrolled by CSS keyframes) with whisper.cpp-derived burned-in subtitles, then renders to multiple social-media formats.
Source of truth: the project's PIPELINE.md has the full workflow with diagrams, FFmpeg flag rationale, voice presets per language, and troubleshooting. Read it before authoring a new template if the user is starting from scratch. This skill is the trigger + the rules; the doc has the long-form detail.
When to invoke
Trigger this skill when the user asks for any of:
- "Make a marketing/promo video for this site/URL"
- A cinematic scrolling tour of a webpage with voice off
- Burned-in subtitles synced to a voice over
- Multi-format social media video (square / vertical / horizontal) from a website
- "Vídeo para Reels/TikTok/LinkedIn" tied to a website URL
Do NOT invoke for:
- Animated infographics, kinetic typography, data-driven bar charts (different stack — HyperFrames or Remotion)
- Podcast / audio editing
- Videos longer than 60 seconds (out of scope — the pipeline is tuned for 25–35 s)
- Pure motion graphics with no real-website footage
Inputs to gather BEFORE starting
Confirm with the user; do NOT invent these. Six are REQUIRED — do NOT proceed without all of them. Claude cannot generate voice off or music itself; the user must produce these and place the files where Claude can read them.
Required (6) — block until provided
- Live website URL — must be public, no login walls, no localhost, no
X-Frame-Options: DENY
- Brand logo PNG with transparent background — ~1500×500 or square. File path required (e.g.
videos/<client>-logo.png)
- Contact info for the CTA — phone, WhatsApp, or email
- Voice script — text in any language, 45–55 words for ~25 s voice off. Used both to write the captions and as reference for the user's TTS generation
- Voice off MP3 —
videos/voiceover.mp3 already generated. Claude does NOT have ElevenLabs credentials and cannot generate this. If the user doesn't have it, walk them through the ElevenLabs steps (Voice presets table in section 9.1) and STOP — wait for them to download the MP3 and confirm before continuing
- Background music MP3 —
videos/music.mp3 already downloaded. Royalty-free source (YouTube Audio Library, Pixabay, Mixkit). If the user doesn't have one, give them search filters (Cinematic / Corporate / Inspirational, 1+ min) and STOP — wait for the file before continuing
Required (1 more) — block until decided
- Style pack (regla #23) — visual lenguage of the video. Four packs, one is mandatory. ALWAYS show the user the Style Pack Selector table (next section) and ASK which pack to use BEFORE customizing HTML. Default if the user has no opinion:
cinematic. The chosen pack drives palette, transitions, subtitle treatment, and callout entrance — it's a single coherent decision, not a checkbox list.
Optional (4) — sensible default if missing
Multi-page tour subpages — 2–3 URLs to include beyond the homepage (e.g., /products, /about, /contact). If the user has none in mind, OFFER auto-discovery (Playwright scrapes the nav menu — see "Auto-discovery of subpages" section). Default if they decline both: homepage-only.
SFX cues (regla #20) — sfx.json + sfx/*.mp3 files in the project folder. If the user wants the audio layer with whoosh / click / sub-bass, ASK them to drop SFX files into sfx/ and write the cue list. The cue palette and free SFX sources are in docs/sound-design.md. If missing, pipeline mixes voice + music only — DO NOT block on this.
Motion overlays / callouts (regla #21) — only relevant if the user wants to call attention to a specific feature, stat, or region of the scrolling page. The templates ship with a commented example block right before the subtitles. If the user has stats / claims worth surfacing visually, ask for them and uncomment+position the callouts; otherwise leave the block commented and ship without.
Channel target (regla #22) — --channel tiktok / linkedin / youtube flag on the recorder, applies only to vertical renders. If the user names TikTok / Reels / Shorts specifically, ALWAYS use --channel tiktok (subtitles must avoid the bottom UI). Default is fine for LinkedIn or general use.
Hard gate
If ANY of (1)–(6) is missing, STOP and ASK. Do NOT write a single line of HTML, do NOT modify the template, do NOT run a render. The pipeline depends on all six being on disk before Step 4 (HTML customization) begins.
Common user mistake: they say "ya tengo todo" but voiceover.mp3 is actually still in their Downloads folder, or music.mp3 doesn't exist yet. ALWAYS verify the files physically exist:
ls -lh videos/voiceover.mp3 videos/music.mp3 videos/<client>-logo.png
If any returns "No such file", stop and ask the user to provide it before proceeding.
Style Pack Selector (mandatory step)
Right after the 6 required inputs are collected and BEFORE you customize a single line of HTML, show this table to the user and ask which pack to use. The user picks ONE. The pack is then applied as body.style-<name> via record_video.py --style <name> — no manual CSS surgery per client.
| Pack |
For |
Personality |
| cinematic (default) |
SaaS, B2B, agencies, consultoras, fintechs, brands selling trust |
Dark navy + cyan, drone landing, restrained crossfades, breathing CTA. Looks like Apple's product launch trailers. |
| bold |
D2C, ecommerce, restaurantes, fitness, lifestyle, retail |
Magenta + coral palette, punch transitions, pop-entrance subtitles, elastic stat callouts. Looks like a Glossier / Gymshark / DTC video. |
| editorial |
Podcasts, newsletters, content brands, thought leadership, periodismo digital |
Warm sepia + amber palette, serif italic CTA, letterbox bars (top/bottom), lower-third subtitle band (no centered pill), slow cross-dissolves. Looks like a BBC documentary intro. |
| tech |
AI startups, dev tools, infra, ciberseguridad |
Matrix-green (#0AFF6A) on near-black, JetBrains Mono / Fira Code, scanline overlay, glitch-step subtitle reveal, > prompt prefix on CTA & subs, hue-rotate on inactive slides. Looks like a hacker movie. |
How to phrase the question:
"Vamos a elegir la dirección visual. Tenés 4 packs:
- cinematic — la apuesta segura: dark, cinematográfico, sobrio. Va con casi todo
- bold — magenta + coral, pop, energético. Para ecommerce y lifestyle
- editorial — lower-third tipo BBC, cinematic ratio, serif italic. Para contenido / podcasts
- tech — terminal, glitch, matrix-green mono. Para AI / dev tools
¿Cuál encaja con la marca?"
Rules for this step:
- Always show all 4 — don't pre-filter. Even if the brand looks "obviously cinematic," the user might be repositioning.
- Never invent a 5th pack on the fly. If the user wants something off-menu (e.g., "neon retro 80s synthwave"), explain that we'd add it as a real
style-<name> selector and that's a follow-up engagement, not a per-client customization.
- One pack per video. Don't try to mix
bold with cinematic subtitles — packs are coherent design systems, mixing them defeats the purpose.
Non-negotiable rules
These are mistakes that look correct in code but break the result. Never violate:
Subtitle CSS pattern: TWO animations per subtitle, never one. Use subFadeIn (fires at phrase start) + subFadeOut (fires at phrase end). Never use a single subFade keyframe with 0%/15%/85%/100% opacity stops — that ties fade duration to phrase duration, breaking short and long phrases differently.
.sub-1 {
animation:
subFadeIn 0.18s ease-out forwards 0.13s, /* phrase.start */
subFadeOut 0.22s ease-in forwards 1.60s; /* phrase.end */
}
Whisper model: multilingual small, NEVER small.en. .en models translate non-English audio into English instead of transcribing it. For Spanish/Portuguese/French/etc voice off, always use ggml-small.bin with -l <code>.
FFmpeg audio: amix=inputs=2:duration=longest, NOT -shortest. Music must continue past the voice end into the CTA outro. -shortest cuts at voice end and feels abrupt.
Trim Chromium init flash with -ss 0.3 on the VIDEO input only. Audio still starts at 0. Without this, you get a 0.3 s white frame at the start.
Camera tour easing: linear, never ease-in-out. Cubic-bezier eases make the scroll feel like an old PowerPoint. Linear is the cinematic choice.
Iframe width must MATCH the site's natural design width. Most modern responsive sites (Tailwind/Bootstrap/Next.js) render correctly at the wrapper width — use width: 1080px for both square and vertical (matches the wrapper, no horizontal crop, clean encuadre). Force width: 1920px ONLY for sites with strict desktop-only layouts that break below 1280px (rare). When in doubt, render at 1080 first and check the encuadre — if you see mobile/hamburger nav or stacked content where the desktop has a row, then bump to 1920 and add margin-left: -420px to crop. NEVER blindly force 1920 — it cuts off the left/right 420px of any responsive site that already adapts to 1080.
Two HTML files, one per format. Do NOT use CSS media queries for square↔vertical. Vertical needs subtitles 480 px from bottom (not 90 px), larger font (76 px not 1.7 rem), stacked CTA layout, and different iframe wrapper height. Splitting keeps each layout cleanly tunable.
Pixel format: yuv420p. Social platforms reject yuv444p. No exceptions.
Phone numbers spoken phonetically in the script. ElevenLabs reads +51 999 888 777 as "fifty-one nine hundred ninety-nine...". Write phonetically: más cincuenta y uno, nueve nueve nueve, ocho ocho ocho, siete siete siete (Spanish) or its English equivalent. Test with TTS preview before committing.
Windows file-lock retry stays in record_video.py. Playwright holds the .webm briefly after browser.close(). The latest.rename(raw_webm) call must be wrapped in a 10-attempt retry loop with time.sleep(0.5) on PermissionError. Never remove this.
Subtitle text must match the voice EXACTLY. Whisper transcribes what was actually said. If the script differs from the voice off (because ElevenLabs dropped a word, or the user re-recorded with edits), use whisper's transcription as truth, not the original script.
Hide subtitles during the CTA slide. The slideshow scheduler must add body.cta-active when the CTA slide activates, and body.cta-active .subtitles { opacity: 0 } must be in the CSS. Otherwise the last subtitle bleeds into the brand outro.
Multi-page tour: each subpage is its own <div class="slide demo-slide"> with its own iframe. Never put multiple URLs into one iframe with JS-driven src swaps — the browser fires a re-render flash, breaks the scroll animation, and may load a CORS-blocked frame mid-flight. Stack pages as separate slides; the existing setTimeout scheduler does the transitions.
Verify each subpage iframe-embeds before adding to the tour. Check X-Frame-Options and Content-Security-Policy: frame-ancestors with curl -I <url>. If a page is blocked, FALL BACK to a static full-page screenshot (Playwright page.screenshot({ fullPage: true })) and Ken-Burns it as a <img> instead of an <iframe>. NEVER ship a video with blank-frame slides.
Fake cursor overlay is VISUAL ONLY. Never call .click() or dispatch real events to the iframe. Cross-origin iframes block it; same-origin iframes that DO accept clicks would break the slideshow scheduler if you wired real navigation. The cursor's job is to suggest causality ("user clicked → next page loaded"), nothing more. Always hide it during the CTA slide via body.cta-active .fake-cursor { opacity: 0 }.
Auto-discovered subpages must be same-origin and same-language. When scraping <nav> / <header> <a href> values, filter to URLs whose hostname matches the input domain. Drop external links (social media, third-party tools, language switches), drop fragment-only anchors (#section), drop the homepage itself. ALWAYS show the discovered list to the user for approval before wiring it into the HTML — auto-discovery is a starting point, not a final answer.
Subpages must MATCH the brand's video aesthetic. Before adding a discovered page to the tour, eyeball its background. If the rest of the video is dark cinematic (#050a14 / dark navy) and a candidate subpage has a WHITE/LIGHT background, REJECT it — the dark→white→dark flash breaks the cinematic flow more than the breadth of pages helps. Either skip that subpage, or warn the user and offer alternatives (stay on a longer dark page, apply a CSS overlay/filter, or use a screenshot with a Ken Burns crop). Two consistent dark pages beat four with a jarring white page in the middle.
Fake cursor SVG must use a strong stroke (≥ 2.5 px) and dual drop-shadow so it stays visible on ANY background. White fill alone disappears on light pages; thin strokes vanish in compression. The combination fill: white; stroke: black; stroke-width: 3; stroke-linejoin: round plus filter: drop-shadow(0 0 6px rgba(0,0,0,0.9)) drop-shadow(0 4px 10px rgba(0,0,0,0.7)) is the proven pattern. Never use a single drop-shadow or stroke under 2 px — they fail on white-on-white.
Never declare a render "done" without running the QA review process (Step 7). A successful render exit code does NOT mean the video is correct — Playwright recordings frequently have iframe-load white frames, cursor invisibility on bright backgrounds, subtitle drift, misframing, and white-page flashes that pass the render but fail visually. The ONLY way to catch these is to extract critical frames with FFmpeg, READ them via the Read tool, and inspect them against the error pattern table in Step 7c. If you skip this step and tell the user "listo", you'll either: (a) ship a broken video they discover later, or (b) waste rounds when they ask "did you check?". Always inspect before declaring done. Rendering is fast; iteration on a missed bug is slow.
SFX layer is OPTIONAL and additive — never required. When the user provides sfx.json in the project folder, record_video.py mixes the cues into the audio via adelay + amix. SFX timings are in slideshow-time (t=0 = first frame the viewer sees), NOT wall-clock recording time. Voice = 1.0, music = 0.18, SFX should be ≤ 0.6 (sub-bass excepted up to 0.7). Click SFX must fire 0.05–0.10 s AFTER the cursor pulse start, never before — perfect sync feels mechanical. Never stack 3+ SFX in the same 0.5 s window. If sfx.json is missing, the pipeline runs as before with voice + music only. Full guide: docs/sound-design.md.
Callouts are accents, not decoration. The .callout--ring / .callout--box / .callout--stat system in the templates exists to point the viewer's eye at WHAT matters on the scrolling iframe. Rules: max 2 callouts on screen at once (more = noise), position OFF the iframe's visual center (the iframe is doing its job, the callout annotates it), and time the callout to the voice phrase that names it (the eye should arrive at the number while the ear is still hearing it). Callouts auto-hide during the CTA outro via body.cta-active .callout — never override this. If a slide has no specific element to point at, ship without callouts on that slide; an unmotivated callout reads as clutter.
Channel variants for vertical are body-class overrides, not template forks. When the user asks for a TikTok / Reels / Shorts video, run record_video.py --channel tiktok (or linkedin / youtube); the recorder injects body.channel-<name> via Playwright before the slideshow starts, and the vertical template's CSS shifts subtitle placement to avoid the platform's native UI. TikTok subtitles MUST move from bottom: 240px to top: 58% — the bottom 25% of a TikTok vertical frame is covered by the likes/share/comments rail, so default placement gets eaten. LinkedIn keeps defaults (clean bottom-feed UI). YouTube Shorts moves subtitles slightly lower (bottom: 140px) for a more cinematic frame since its bottom controls auto-hide on play. Render output filenames carry the suffix (marketing-vertical-tiktok.mp4) — never overwrite a channel render with a different channel render.
Style packs are mandatory and asked BEFORE HTML customization. The skill ships 4 visual style packs: cinematic (default — SaaS/B2B), bold (D2C/lifestyle/retail), editorial (content brands / podcasts — warm sepia + serif + letterbox), tech (AI/dev tools — matrix-green + monospace + glitch). Before editing the HTML template, ALWAYS show the Style Pack Selector table (next section) and ASK which pack to use. The pack maps to a body.style-<name> class injected by record_video.py --style <name>. One coherent pack per video — never mix. Render output filenames are suffixed when the pack is non-default (marketing-square-bold.mp4, marketing-vertical-tech.mp4).
Workflow (condensed — see PIPELINE.md for full detail)
1. Verify prerequisites
- Python +
playwright package + python -m playwright install chromium
- FFmpeg in
PATH (or imageio-ffmpeg Python package as fallback — the script auto-detects)
- whisper.cpp at
C:\whisper\whisper-cli.exe on Windows (or built locally on macOS/Linux)
- Whisper model
ggml-small.bin at C:\whisper\models\ (~466 MB)
- ElevenLabs free account at https://elevenlabs.io
2. Generate voice off (ElevenLabs)
Voice presets by language:
| Language |
Voice |
Style |
Speed |
Stability |
| Spanish (LATAM) |
Mateo Aragon |
30–40 |
0.9–0.95 |
50 |
| Spanish (Spain) |
Bea |
30–40 |
0.95 |
60 |
| English (US) |
Adam |
50–60 |
0.9 |
50 |
| English (UK) |
Dorothy |
30–40 |
0.95 |
60 |
| Portuguese (BR) |
Antoni |
40 |
0.95 |
55 |
| French |
Charlotte |
40 |
0.9 |
55 |
Save as videos/voiceover.mp3 in the project.
3. Transcribe with whisper.cpp
C:\whisper\whisper-cli.exe -m C:\whisper\models\ggml-small.bin -l <lang> -oj -of transcript voiceover.mp3
The whisper.cpp JSON has a nested schema (transcription[].tokens[]). Convert to flat [{text, start, end}]:
const data = require('./transcript.json');
const words = [];
for (const seg of data.transcription) {
for (const w of seg.tokens || []) {
if (!w.text || w.text.startsWith('[')) continue;
words.push({ text: w.text.trim(), start: w.offsets.from / 1000, end: w.offsets.to / 1000 });
}
}
require('fs').writeFileSync('transcript.json', JSON.stringify(words, null, 2));
4. Customize HTML template
Copy videos/video-marketing-services.html (square 1080×1080) and video-marketing-services-vertical.html (vertical 1080×1920). Per client, edit:
<iframe src="..."> → client URL
.demo-frame-wrapper iframe { height: ... } → match target site's full scroll length
@keyframes scroll-tour percentages → hand-tune (see Camera tour rules below)
<img class="cta-logo" src="..."> → client logo file
.cta-phone content → client contact
<div class="sub sub-N">... → match exact whisper transcription
.sub-N CSS rules → use subFadeIn/subFadeOut with whisper word timestamps
5. Phrase grouping for subtitles
- 3–5 words per phrase (conversational default)
- 2–3 words for hype/launch energy
- 4–6 words for educational/measured
- Break on sentence boundaries, commas with ≥150 ms pause, natural breath units
- ONE subtitle on screen at a time
- 12 phrases is the typical count for a 25 s voice off
Generate the CSS programmatically from transcript.json instead of typing by hand:
const words = require('./transcript.json');
const phrases = [
["Hay webs que se ven.", 0, 4],
["Hay webs que se sienten.", 5, 9],
["Y hay webs que venden por ti.", 10, 16],
// ...
];
phrases.forEach(([text, s, e], i) => {
const start = words[s].start.toFixed(2);
const end = words[e].end.toFixed(2);
console.log(`.sub-${i+1} { animation: subFadeIn 0.18s ease-out forwards ${start}s, subFadeOut 0.22s ease-in forwards ${end}s; }`);
});
6. Render
cd videos
python record_video.py # both formats, cinematic style, default channel
python record_video.py square # only square
python record_video.py vertical # only vertical
python record_video.py . --style bold # bold pack (both formats)
python record_video.py . vertical --style bold --channel tiktok # bold + TikTok placement
python record_video.py . vertical --channel linkedin
python record_video.py . vertical --channel youtube
Each format takes ~30 s. Both formats: ~80 s total. Each channel or style variant adds one extra pass; render multiple variants in sequence to produce platform-tuned + brand-tuned outputs without overwriting (filenames are suffixed: marketing-vertical-bold-tiktok.mp4).
If sfx.json is present in the project folder, SFX cues are mixed into the audio automatically — no extra flag needed.
--style and --channel are orthogonal: style controls the visual lenguage (palette, transitions, subtitle treatment), channel controls subtitle PLACEMENT on the frame (where the platform's native UI lives). Both can combine freely.
7. QA review process (MANDATORY — never skip)
Render exit code 0 does NOT guarantee a correct video. Playwright recording timing drift, iframe-load white frames, cursor invisibility on bright backgrounds, misframing, and subtitle drift all PASS the render but FAIL visually. Always run the four-step QA review below before declaring done.
7a. Metadata audit
FFMPEG=$(python -c "import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())")
"$FFMPEG" -i videos/<output>.mp4 2>&1 | grep -E "Duration|Stream"
Verify: duration matches (~28-30s), resolution matches format, h264/yuv420p/AAC ≥128kbps audio.
7b. Frame extraction at critical timestamps
Critical timestamps for a 28s 2-page video (adapt per structure):
| t |
What to verify |
| 0.3–0.5s |
First subtitle visible, drone landing, no white iframe-load frame |
| 1.5s |
Sub-1 fading out / sub-2 fading in (subtitle sync) |
| 4.5s |
Mid-home scroll, sub matches voice phrase |
| Just-before-transition (e.g. 8.6s) |
Fake cursor visible AND on/near a clickable element |
| Just-after-transition (e.g. 9.0s) |
Next page rendered (no blank), subtitle matches voice |
| Page-2 climax (e.g. 13.0s) |
Money shot — narrative pitch lands on most relevant content |
| Pre-CTA (e.g. 22.5s) |
Last sub of voice off, last frame of demo |
| CTA (e.g. 24-25s) |
Logo + tagline + phone visible, NO subtitle, NO cursor |
mkdir -p videos/qa-frames
for t in 0.3 1.5 4.5 8.6 9.0 13.0 18.5 22.5 24.0; do
"$FFMPEG" -y -ss "$t" -i videos/<output>.mp4 -frames:v 1 -q:v 2 "videos/qa-frames/t${t}.png"
done
Then READ each PNG via the Read tool. Visual inspection is the ONLY way to catch real bugs.
7c. Error patterns table — what to look for in each frame
| Error pattern |
What it looks like |
Root cause / fix |
| Pure white frame at start |
Blank white, no content |
Iframe still loading. Add onload="window.iframe1Loaded=true" to iframe + Python wait_for_function("() => window.iframe1Loaded") BEFORE window.startSlideshow(). Trim with -ss <offset> |
| Misframed page (sides cropped) |
Right/left UI cut off |
iframe width ≠ wrapper width, or margin-left: -420px on a responsive site. Match iframe width to wrapper (regla #6) |
| Sudden white page mid-video |
Bright page after dark |
A subpage has white bg → flash. REMOVE that page (regla #17). Keep only dark-themed pages |
| Cursor invisible |
No cursor at expected click moment |
White-on-white. Cursor SVG must have stroke-width: 3 + filter: drop-shadow(0 0 6px rgba(0,0,0,0.9)) drop-shadow(0 4px 10px rgba(0,0,0,0.7)) (regla #18) |
| Cursor on empty bg |
Cursor in dead space, not a button |
left/top estimate wrong. Adjust per cursor instance |
| Subtitle wrong text |
Sub says X, voice says Y |
Sub text doesn't match whisper transcript. Use whisper as truth |
| Subtitle wrong timing |
Sub appears too early/late vs voice |
Trim offset miscalculated. Adjust slideshow_start_offset or measure recording_start BEFORE page.goto() |
| Music abrupt cut at voice end |
Audio stops at ~23s |
Used -shortest. Switch to amix=inputs=2:duration=longest (regla #3) |
| White flash 0-0.3s |
Brief white at very start |
Iframe-load gating missing OR -ss trim too small |
| Subtitle visible during CTA |
Demo sub bleeds into outro |
Missing body.cta-active .subtitles { opacity: 0 } (regla #12) |
7d. Cursor visibility zoom
Cursors are 28-32px on a 1080-canvas — invisible at preview downscale. ALWAYS zoom:
"$FFMPEG" -y -i <frame>.png -vf "crop=400:200:<cursor-x>:<cursor-y>,scale=800:400" <frame>-zoom.png
Read the zoomed PNG. Cursor must be clearly visible against the background — both fill AND stroke contrasting with whatever's behind.
7e. Static checklist (after frame inspection passes)
7f. If anything fails
Do NOT declare done. Fix HTML or record_video.py, re-render, re-extract frames, re-inspect. Iterate until the video passes the visual inspection cleanly. The user pays you to catch errors, not to ship them and ask for forgiveness.
Camera tour tuning (the hardest creative part)
Spend 10–15 minutes per client tuning @keyframes scroll-tour. The structure:
@keyframes scroll-tour {
/* DRONE LANDING — open zoomed-in, settle */
0% { transform: translateY(0) scale(1.15); }
9% { transform: translateY(0) scale(1.0); } /* land at hero */
/* HOLD on hero */
18% { transform: translateY(0) scale(1.0); }
/* SCROLL through key sections */
30% { transform: translateY(-900px) scale(1.0); }
45% { transform: translateY(-2200px) scale(1.06); } /* slight zoom for emphasis */
65% { transform: translateY(-3600px) scale(1.0); }
/* LAND on brand-positive section (footer / contact / testimonials) */
100% { transform: translateY(-4720px) scale(1.0); }
}
Tuning rules:
- 0–9 % drone landing:
scale(1.10–1.20) → scale(1.0). This is the hook.
- Hold longer on visually-important sections (hero, featured products, CTAs).
- Slight zoom (
scale(1.05–1.08)) at 1–2 emphasis points — never on every section.
- Total animation duration must equal voiceover.mp3 duration so the CTA outro lands as the voice transitions to the brand close.
- End on something brand-positive — footer, contact section, testimonials, never mid-scroll.
Multi-page tour pattern
When the marketing video should reveal more than just the homepage (which is most of the time — single-page tours feel thin), stack each page as its own demo-slide and let the existing scheduler swap between them.
Structure
<!-- Slide 1: homepage (8 s) -->
<div class="slide demo-slide active" data-duration="8000">
<div class="demo-frame-wrapper">
<iframe src="https://client.com/" class="page-1"
sandbox="allow-same-origin allow-scripts" loading="eager"></iframe>
</div>
</div>
<!-- Slide 2: products (6 s) -->
<div class="slide demo-slide" data-duration="6000">
<div class="demo-frame-wrapper">
<iframe src="https://client.com/products" class="page-2"
sandbox="allow-same-origin allow-scripts" loading="eager"></iframe>
</div>
</div>
<!-- Slide 3: contact (5 s) -->
<div class="slide demo-slide" data-duration="5000">
<div class="demo-frame-wrapper">
<iframe src="https://client.com/contact" class="page-3"
sandbox="allow-same-origin allow-scripts" loading="eager"></iframe>
</div>
</div>
<!-- CTA outro (5 s) -->
<div class="slide cta-slide" data-duration="5000">...</div>
Each iframe gets its own scroll-tour keyframes, sized to its content:
.page-1 { animation: scroll-tour-1 8s linear forwards; }
.page-2 { animation: scroll-tour-2 6s linear forwards; }
.page-3 { animation: scroll-tour-3 5s linear forwards; }
@keyframes scroll-tour-1 {
0% { transform: translateY(0) scale(1.15); }
14% { transform: translateY(0) scale(1.0); }
100% { transform: translateY(-2400px) scale(1.0); }
}
@keyframes scroll-tour-2 {
0% { transform: translateY(0) scale(1.0); }
60% { transform: translateY(-1200px) scale(1.06); }
100% { transform: translateY(-1800px) scale(1.0); }
}
/* etc. */
Pacing
- 5–8 s per page is the sweet spot for product/service marketing.
- First page (homepage) gets the longest hold — 7–8 s with the drone landing intro.
- Last page should be action-oriented (contact, signup) so the visual narrative ends at "what to do next."
- Total page durations + CTA must equal voiceover duration so the voice off lands the brand close exactly when the CTA slide appears. Off-by-1-second is fine; off-by-3 looks broken.
Page selection — pick by narrative arc, not URL alphabet
| Slot |
Content type |
Why |
| 1 |
Homepage / hero |
First impression, brand vibe |
| 2 |
Primary value (products, services, work) |
What they actually sell |
| 3 |
Trust (testimonials, case studies, about) |
Why believe this brand |
| 4 |
Action (contact, booking, signup) |
What to do right now |
Skip subpages with login walls, half-built dashboards, "coming soon" placeholders, or noisy admin panels.
Fake cursor click overlay (recommended for multi-page tours)
A fake mouse-cursor SVG that animates onto a visible CTA button on screen ~0.3 s BEFORE the slide transitions to the next page. The viewer's brain reads it as "clicked → navigated" even though no actual click happened. Pure visual storytelling.
Structure
<!-- Strong stroke + dual drop-shadow keeps the cursor visible on ANY background.
White fill + 3px black stroke = high contrast on dark; black stroke + dark
drop-shadow keeps it readable on white. -->
<svg class="fake-cursor" width="32" height="46" viewBox="0 0 24 36">
<path d="M0,0 L0,28 L7,21 L11,30 L14,29 L10,20 L18,20 Z"
fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
</svg>
.fake-cursor {
position: fixed;
top: 0; left: 0;
z-index: 200;
opacity: 0;
pointer-events: none;
/* Dual drop-shadow: dark for contrast on light bg + cyan glow for brand pop */
filter:
drop-shadow(0 0 6px rgba(0, 0, 0, 0.9))
drop-shadow(0 4px 10px rgba(0, 0, 0, 0.7));
will-change: transform, opacity;
}
/* Animate cursor for handoff slide-1 → slide-2 (slide-1 is 8 s, transition fires at 8 s) */
.fake-cursor {
animation: cursor-click-1 1s ease-in-out forwards 6.7s;
}
@keyframes cursor-click-1 {
0% { left: 50%; top: 50%; opacity: 0; transform: scale(1); }
20% { left: 50%; top: 50%; opacity: 1; transform: scale(1); } /* fade in mid-screen */
70% { left: 760px; top: 820px; opacity: 1; transform: scale(1); } /* glide to button */
85% { left: 760px; top: 820px; opacity: 1; transform: scale(0.85);} /* click pulse */
100% { left: 760px; top: 820px; opacity: 0; transform: scale(1); } /* fade out */
}
body.cta-active .fake-cursor { opacity: 0; }
Rules
- Land on a real visible button (Hero CTA, "Ver productos", "Contacto"). Estimate
(left, top) based on the iframe's current scroll position at handoff time.
- Time the click pulse 0.3 s BEFORE the slide transition fires, not after. The transition feels "caused" only if the click happens first.
- One cursor element for the whole video, animated between scenes. Don't create one per slide.
- Always hide during the CTA outro (
body.cta-active .fake-cursor { opacity: 0 }).
- Skip the cursor on transitions where it would feel forced — e.g., a long product gallery scroll already feels like browsing; don't fake-click between gallery items.
Auto-discovery of subpages
If the user doesn't volunteer subpage URLs, run a Playwright pass to scrape the site's nav before recording, then ASK the user to approve the discovered list.
Discovery script — drop in videos/discover_pages.py
"""
Usage: python discover_pages.py https://client.com
Prints a JSON list of up to 4 same-origin URLs from the nav/header.
"""
from playwright.sync_api import sync_playwright
from urllib.parse import urlparse
import json, sys
base_url = sys.argv[1]
origin = urlparse(base_url).netloc
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1920, "height": 1080})
page.goto(base_url, wait_until="networkidle")
hrefs = page.eval_on_selector_all(
"nav a[href], header a[href]",
"els => els.map(e => e.href)"
)
browser.close()
unique = []
seen = set()
for h in hrefs:
parsed = urlparse(h)
if parsed.netloc != origin: continue
if parsed.fragment: continue
if parsed.path in ("", "/"): continue
key = parsed.path.rstrip("/")
if key in seen: continue
seen.add(key)
unique.append(h)
print(json.dumps(unique[:4], indent=2))
Use it
python videos/discover_pages.py https://client.com > videos/subpages.json
Then SHOW the discovered list to the user. Examples of how to phrase it:
"Encontré estas páginas en el menú: /products, /about, /contact. ¿Vamos con esas, o prefieres otras?"
Rules
- Always require user approval before wiring auto-discovered URLs into the HTML. The most marketing-relevant page is sometimes buried in the footer and the nav-scrape misses it.
- Trim to 3 max for a 30-second video. More than that = each page gets too little screen time to register.
- If discovery returns 0 results (single-page sites, hash-based routing, JS-rendered nav that didn't load in time), fall back to homepage-only mode and tell the user.
- Verify each discovered URL with
curl -I to filter out X-Frame-Options: DENY pages before showing the list.
Output spec to validate
| Property |
Square |
Vertical |
Horizontal (optional) |
| Resolution |
1080×1080 |
1080×1920 |
1920×1080 |
| Frame rate |
25 fps (Playwright default) |
25 fps |
25 fps |
| Codec |
H.264 (libx264, CRF 20) |
same |
same |
| Audio |
AAC, 192 kbps |
same |
same |
| Pixel format |
yuv420p |
yuv420p |
yuv420p |
| Container |
MP4 (+faststart) |
MP4 |
MP4 |
| Final size |
~5 MB |
~9 MB |
~15 MB |
| Native fit |
LinkedIn, IG feed, FB, X |
Reels, TikTok, Shorts, IG Stories |
YouTube, web hero |
Reference
PIPELINE.md at the project root — full workflow, FFmpeg flag table per flag, voice presets table, ElevenLabs gotchas table, troubleshooting matrix
videos/video-marketing-services.html — canonical square template (1080×1080)
videos/video-marketing-services-vertical.html — canonical vertical template (1080×1920)
videos/record_video.py — Playwright + FFmpeg orchestrator with Windows file-lock retry
When a step is ambiguous or the user asks for something the rules don't cover, read PIPELINE.md rather than improvising.
1---2name: creador-videos-ai3description: Build a 30-second cinematic marketing video for any website URL using the Causal AI Digital pipeline (Playwright iframe scroll tour + ElevenLabs voice off + whisper.cpp word-level synced subtitles + FFmpeg audio mux). Use when the user asks to generate a promotional/marketing video for a website, create a cinematic camera tour of a webpage, sync burned-in subtitles to a voice over from a transcript, or render multi-format social media videos (square, vertical, horizontal) from an HTML template. Outputs deterministic MP4s for LinkedIn, Instagram, TikTok, Reels, YouTube. Do NOT invoke for animated infographics, kinetic typography, data-driven slideshows, podcast/audio editing, or videos longer than 60 seconds — those need a different stack.4---56# creador_videos_ai — Marketing Video Pipeline78Turns any public website URL into a polished marketing MP4 by combining a self-playing HTML cinematic tour (live `<iframe>` scrolled by CSS keyframes) with whisper.cpp-derived burned-in subtitles, then renders to multiple social-media formats.910**Source of truth:** the project's `PIPELINE.md` has the full workflow with diagrams, FFmpeg flag rationale, voice presets per language, and troubleshooting. Read it before authoring a new template if the user is starting from scratch. This skill is the trigger + the rules; the doc has the long-form detail.1112## When to invoke1314Trigger this skill when the user asks for any of:1516- "Make a marketing/promo video for this site/URL"17- A cinematic scrolling tour of a webpage with voice off18- Burned-in subtitles synced to a voice over19- Multi-format social media video (square / vertical / horizontal) from a website20- "Vídeo para Reels/TikTok/LinkedIn" tied to a website URL2122Do NOT invoke for:2324- Animated infographics, kinetic typography, data-driven bar charts (different stack — HyperFrames or Remotion)25- Podcast / audio editing26- Videos longer than 60 seconds (out of scope — the pipeline is tuned for 25–35 s)27- Pure motion graphics with no real-website footage2829## Inputs to gather BEFORE starting3031Confirm with the user; do NOT invent these. Six are REQUIRED — do NOT proceed without all of them. Claude cannot generate voice off or music itself; the user must produce these and place the files where Claude can read them.3233### Required (6) — block until provided34351. **Live website URL** — must be public, no login walls, no localhost, no `X-Frame-Options: DENY`362. **Brand logo PNG with transparent background** — ~1500×500 or square. File path required (e.g. `videos/<client>-logo.png`)373. **Contact info** for the CTA — phone, WhatsApp, or email384. **Voice script** — text in any language, **45–55 words** for ~25 s voice off. Used both to write the captions and as reference for the user's TTS generation395. **Voice off MP3** — `videos/voiceover.mp3` already generated. Claude does NOT have ElevenLabs credentials and cannot generate this. If the user doesn't have it, walk them through the ElevenLabs steps (Voice presets table in section 9.1) and STOP — wait for them to download the MP3 and confirm before continuing406. **Background music MP3** — `videos/music.mp3` already downloaded. Royalty-free source (YouTube Audio Library, Pixabay, Mixkit). If the user doesn't have one, give them search filters (Cinematic / Corporate / Inspirational, 1+ min) and STOP — wait for the file before continuing4142### Required (1 more) — block until decided43447. **Style pack** (regla #23) — visual lenguage of the video. Four packs, one is mandatory. ALWAYS show the user the Style Pack Selector table (next section) and ASK which pack to use BEFORE customizing HTML. Default if the user has no opinion: `cinematic`. The chosen pack drives palette, transitions, subtitle treatment, and callout entrance — it's a single coherent decision, not a checkbox list.4546### Optional (4) — sensible default if missing47488. **Multi-page tour subpages** — 2–3 URLs to include beyond the homepage (e.g., `/products`, `/about`, `/contact`). If the user has none in mind, OFFER auto-discovery (Playwright scrapes the nav menu — see "Auto-discovery of subpages" section). Default if they decline both: homepage-only.49509. **SFX cues** (regla #20) — `sfx.json` + `sfx/*.mp3` files in the project folder. If the user wants the audio layer with whoosh / click / sub-bass, ASK them to drop SFX files into `sfx/` and write the cue list. The cue palette and free SFX sources are in `docs/sound-design.md`. If missing, pipeline mixes voice + music only — DO NOT block on this.515210. **Motion overlays / callouts** (regla #21) — only relevant if the user wants to call attention to a specific feature, stat, or region of the scrolling page. The templates ship with a commented example block right before the subtitles. If the user has stats / claims worth surfacing visually, ask for them and uncomment+position the callouts; otherwise leave the block commented and ship without.535411. **Channel target** (regla #22) — `--channel tiktok` / `linkedin` / `youtube` flag on the recorder, applies only to vertical renders. If the user names TikTok / Reels / Shorts specifically, ALWAYS use `--channel tiktok` (subtitles must avoid the bottom UI). Default is fine for LinkedIn or general use.5556### Hard gate5758If ANY of (1)–(6) is missing, STOP and ASK. Do NOT write a single line of HTML, do NOT modify the template, do NOT run a render. The pipeline depends on all six being on disk before Step 4 (HTML customization) begins.5960Common user mistake: they say "ya tengo todo" but `voiceover.mp3` is actually still in their Downloads folder, or `music.mp3` doesn't exist yet. ALWAYS verify the files physically exist:6162```bash63ls -lh videos/voiceover.mp3 videos/music.mp3 videos/<client>-logo.png64```6566If any returns "No such file", stop and ask the user to provide it before proceeding.6768## Style Pack Selector (mandatory step)6970Right after the 6 required inputs are collected and BEFORE you customize a single line of HTML, show this table to the user and ask which pack to use. The user picks ONE. The pack is then applied as `body.style-<name>` via `record_video.py --style <name>` — no manual CSS surgery per client.7172| Pack | For | Personality |73|---|---|---|74| **cinematic** (default) | SaaS, B2B, agencies, consultoras, fintechs, brands selling trust | Dark navy + cyan, drone landing, restrained crossfades, breathing CTA. Looks like Apple's product launch trailers. |75| **bold** | D2C, ecommerce, restaurantes, fitness, lifestyle, retail | Magenta + coral palette, punch transitions, pop-entrance subtitles, elastic stat callouts. Looks like a Glossier / Gymshark / DTC video. |76| **editorial** | Podcasts, newsletters, content brands, thought leadership, periodismo digital | Warm sepia + amber palette, serif italic CTA, letterbox bars (top/bottom), lower-third subtitle band (no centered pill), slow cross-dissolves. Looks like a BBC documentary intro. |77| **tech** | AI startups, dev tools, infra, ciberseguridad | Matrix-green (#0AFF6A) on near-black, JetBrains Mono / Fira Code, scanline overlay, glitch-step subtitle reveal, `> ` prompt prefix on CTA & subs, hue-rotate on inactive slides. Looks like a hacker movie. |7879How to phrase the question:8081> "Vamos a elegir la dirección visual. Tenés 4 packs:82> - **cinematic** — la apuesta segura: dark, cinematográfico, sobrio. Va con casi todo83> - **bold** — magenta + coral, pop, energético. Para ecommerce y lifestyle84> - **editorial** — lower-third tipo BBC, cinematic ratio, serif italic. Para contenido / podcasts85> - **tech** — terminal, glitch, matrix-green mono. Para AI / dev tools86>87> ¿Cuál encaja con la marca?"8889Rules for this step:9091- **Always show all 4 — don't pre-filter.** Even if the brand looks "obviously cinematic," the user might be repositioning.92- **Never invent a 5th pack on the fly.** If the user wants something off-menu (e.g., "neon retro 80s synthwave"), explain that we'd add it as a real `style-<name>` selector and that's a follow-up engagement, not a per-client customization.93- **One pack per video.** Don't try to mix `bold` with `cinematic` subtitles — packs are coherent design systems, mixing them defeats the purpose.9495## Non-negotiable rules9697These are mistakes that look correct in code but break the result. Never violate:98991. **Subtitle CSS pattern: TWO animations per subtitle, never one.** Use `subFadeIn` (fires at phrase start) + `subFadeOut` (fires at phrase end). Never use a single `subFade` keyframe with `0%/15%/85%/100%` opacity stops — that ties fade duration to phrase duration, breaking short and long phrases differently.100101 ```css102 .sub-1 {103 animation:104 subFadeIn 0.18s ease-out forwards 0.13s, /* phrase.start */105 subFadeOut 0.22s ease-in forwards 1.60s; /* phrase.end */106 }107 ```1081092. **Whisper model: multilingual `small`, NEVER `small.en`.** `.en` models *translate* non-English audio into English instead of transcribing it. For Spanish/Portuguese/French/etc voice off, always use `ggml-small.bin` with `-l <code>`.1101113. **FFmpeg audio: `amix=inputs=2:duration=longest`, NOT `-shortest`.** Music must continue past the voice end into the CTA outro. `-shortest` cuts at voice end and feels abrupt.1121134. **Trim Chromium init flash with `-ss 0.3` on the VIDEO input only.** Audio still starts at 0. Without this, you get a 0.3 s white frame at the start.1141155. **Camera tour easing: `linear`, never `ease-in-out`.** Cubic-bezier eases make the scroll feel like an old PowerPoint. Linear is the cinematic choice.1161176. **Iframe width must MATCH the site's natural design width.** Most modern responsive sites (Tailwind/Bootstrap/Next.js) render correctly at the wrapper width — use `width: 1080px` for both square and vertical (matches the wrapper, no horizontal crop, clean encuadre). Force `width: 1920px` ONLY for sites with strict desktop-only layouts that break below 1280px (rare). When in doubt, render at 1080 first and check the encuadre — if you see mobile/hamburger nav or stacked content where the desktop has a row, then bump to 1920 and add `margin-left: -420px` to crop. NEVER blindly force 1920 — it cuts off the left/right 420px of any responsive site that already adapts to 1080.1181197. **Two HTML files, one per format. Do NOT use CSS media queries for square↔vertical.** Vertical needs subtitles 480 px from bottom (not 90 px), larger font (76 px not 1.7 rem), stacked CTA layout, and different iframe wrapper height. Splitting keeps each layout cleanly tunable.1201218. **Pixel format: `yuv420p`.** Social platforms reject `yuv444p`. No exceptions.1221239. **Phone numbers spoken phonetically in the script.** ElevenLabs reads `+51 999 888 777` as "fifty-one nine hundred ninety-nine...". Write phonetically: `más cincuenta y uno, nueve nueve nueve, ocho ocho ocho, siete siete siete` (Spanish) or its English equivalent. Test with TTS preview before committing.12412510. **Windows file-lock retry stays in `record_video.py`.** Playwright holds the `.webm` briefly after `browser.close()`. The `latest.rename(raw_webm)` call must be wrapped in a 10-attempt retry loop with `time.sleep(0.5)` on `PermissionError`. Never remove this.12612711. **Subtitle text must match the voice EXACTLY.** Whisper transcribes what was actually said. If the script differs from the voice off (because ElevenLabs dropped a word, or the user re-recorded with edits), use whisper's transcription as truth, not the original script.12812912. **Hide subtitles during the CTA slide.** The slideshow scheduler must add `body.cta-active` when the CTA slide activates, and `body.cta-active .subtitles { opacity: 0 }` must be in the CSS. Otherwise the last subtitle bleeds into the brand outro.13013113. **Multi-page tour: each subpage is its own `<div class="slide demo-slide">` with its own iframe.** Never put multiple URLs into one iframe with JS-driven `src` swaps — the browser fires a re-render flash, breaks the scroll animation, and may load a CORS-blocked frame mid-flight. Stack pages as separate slides; the existing `setTimeout` scheduler does the transitions.13213314. **Verify each subpage iframe-embeds before adding to the tour.** Check `X-Frame-Options` and `Content-Security-Policy: frame-ancestors` with `curl -I <url>`. If a page is blocked, FALL BACK to a static full-page screenshot (Playwright `page.screenshot({ fullPage: true })`) and Ken-Burns it as a `<img>` instead of an `<iframe>`. NEVER ship a video with blank-frame slides.13413515. **Fake cursor overlay is VISUAL ONLY. Never call `.click()` or dispatch real events to the iframe.** Cross-origin iframes block it; same-origin iframes that DO accept clicks would break the slideshow scheduler if you wired real navigation. The cursor's job is to suggest causality ("user clicked → next page loaded"), nothing more. Always hide it during the CTA slide via `body.cta-active .fake-cursor { opacity: 0 }`.13613716. **Auto-discovered subpages must be same-origin and same-language.** When scraping `<nav>` / `<header>` `<a href>` values, filter to URLs whose hostname matches the input domain. Drop external links (social media, third-party tools, language switches), drop fragment-only anchors (`#section`), drop the homepage itself. ALWAYS show the discovered list to the user for approval before wiring it into the HTML — auto-discovery is a starting point, not a final answer.13813917. **Subpages must MATCH the brand's video aesthetic.** Before adding a discovered page to the tour, eyeball its background. If the rest of the video is dark cinematic (`#050a14` / dark navy) and a candidate subpage has a WHITE/LIGHT background, REJECT it — the dark→white→dark flash breaks the cinematic flow more than the breadth of pages helps. Either skip that subpage, or warn the user and offer alternatives (stay on a longer dark page, apply a CSS overlay/filter, or use a screenshot with a Ken Burns crop). Two consistent dark pages beat four with a jarring white page in the middle.14014118. **Fake cursor SVG must use a strong stroke (≥ 2.5 px) and dual drop-shadow** so it stays visible on ANY background. White fill alone disappears on light pages; thin strokes vanish in compression. The combination `fill: white; stroke: black; stroke-width: 3; stroke-linejoin: round` plus `filter: drop-shadow(0 0 6px rgba(0,0,0,0.9)) drop-shadow(0 4px 10px rgba(0,0,0,0.7))` is the proven pattern. Never use a single drop-shadow or stroke under 2 px — they fail on white-on-white.14214319. **Never declare a render "done" without running the QA review process (Step 7).** A successful render exit code does NOT mean the video is correct — Playwright recordings frequently have iframe-load white frames, cursor invisibility on bright backgrounds, subtitle drift, misframing, and white-page flashes that pass the render but fail visually. The ONLY way to catch these is to extract critical frames with FFmpeg, READ them via the Read tool, and inspect them against the error pattern table in Step 7c. If you skip this step and tell the user "listo", you'll either: (a) ship a broken video they discover later, or (b) waste rounds when they ask "did you check?". Always inspect before declaring done. Rendering is fast; iteration on a missed bug is slow.14414520. **SFX layer is OPTIONAL and additive — never required.** When the user provides `sfx.json` in the project folder, `record_video.py` mixes the cues into the audio via `adelay` + `amix`. SFX timings are in **slideshow-time** (t=0 = first frame the viewer sees), NOT wall-clock recording time. Voice = 1.0, music = 0.18, SFX should be ≤ 0.6 (sub-bass excepted up to 0.7). Click SFX must fire 0.05–0.10 s AFTER the cursor pulse start, never before — perfect sync feels mechanical. Never stack 3+ SFX in the same 0.5 s window. If `sfx.json` is missing, the pipeline runs as before with voice + music only. Full guide: `docs/sound-design.md`.14614721. **Callouts are accents, not decoration.** The `.callout--ring` / `.callout--box` / `.callout--stat` system in the templates exists to point the viewer's eye at WHAT matters on the scrolling iframe. Rules: max **2 callouts on screen at once** (more = noise), position OFF the iframe's visual center (the iframe is doing its job, the callout annotates it), and **time the callout to the voice phrase that names it** (the eye should arrive at the number while the ear is still hearing it). Callouts auto-hide during the CTA outro via `body.cta-active .callout` — never override this. If a slide has no specific element to point at, ship without callouts on that slide; an unmotivated callout reads as clutter.14814922. **Channel variants for vertical are body-class overrides, not template forks.** When the user asks for a TikTok / Reels / Shorts video, run `record_video.py --channel tiktok` (or `linkedin` / `youtube`); the recorder injects `body.channel-<name>` via Playwright before the slideshow starts, and the vertical template's CSS shifts subtitle placement to avoid the platform's native UI. **TikTok subtitles MUST move from `bottom: 240px` to `top: 58%`** — the bottom 25% of a TikTok vertical frame is covered by the likes/share/comments rail, so default placement gets eaten. LinkedIn keeps defaults (clean bottom-feed UI). YouTube Shorts moves subtitles slightly lower (`bottom: 140px`) for a more cinematic frame since its bottom controls auto-hide on play. Render output filenames carry the suffix (`marketing-vertical-tiktok.mp4`) — never overwrite a channel render with a different channel render.15015123. **Style packs are mandatory and asked BEFORE HTML customization.** The skill ships 4 visual style packs: `cinematic` (default — SaaS/B2B), `bold` (D2C/lifestyle/retail), `editorial` (content brands / podcasts — warm sepia + serif + letterbox), `tech` (AI/dev tools — matrix-green + monospace + glitch). Before editing the HTML template, ALWAYS show the Style Pack Selector table (next section) and ASK which pack to use. The pack maps to a `body.style-<name>` class injected by `record_video.py --style <name>`. **One coherent pack per video — never mix.** Render output filenames are suffixed when the pack is non-default (`marketing-square-bold.mp4`, `marketing-vertical-tech.mp4`).152153## Workflow (condensed — see PIPELINE.md for full detail)154155### 1. Verify prerequisites156157- Python + `playwright` package + `python -m playwright install chromium`158- FFmpeg in `PATH` (or `imageio-ffmpeg` Python package as fallback — the script auto-detects)159- whisper.cpp at `C:\whisper\whisper-cli.exe` on Windows (or built locally on macOS/Linux)160- Whisper model `ggml-small.bin` at `C:\whisper\models\` (~466 MB)161- ElevenLabs free account at https://elevenlabs.io162163### 2. Generate voice off (ElevenLabs)164165Voice presets by language:166167| Language | Voice | Style | Speed | Stability |168|---|---|---|---|---|169| Spanish (LATAM) | Mateo Aragon | 30–40 | 0.9–0.95 | 50 |170| Spanish (Spain) | Bea | 30–40 | 0.95 | 60 |171| English (US) | Adam | 50–60 | 0.9 | 50 |172| English (UK) | Dorothy | 30–40 | 0.95 | 60 |173| Portuguese (BR) | Antoni | 40 | 0.95 | 55 |174| French | Charlotte | 40 | 0.9 | 55 |175176Save as `videos/voiceover.mp3` in the project.177178### 3. Transcribe with whisper.cpp179180```powershell181C:\whisper\whisper-cli.exe -m C:\whisper\models\ggml-small.bin -l <lang> -oj -of transcript voiceover.mp3182```183184The whisper.cpp JSON has a nested schema (`transcription[].tokens[]`). Convert to flat `[{text, start, end}]`:185186```javascript187const data = require('./transcript.json');188const words = [];189for (const seg of data.transcription) {190 for (const w of seg.tokens || []) {191 if (!w.text || w.text.startsWith('[')) continue;192 words.push({ text: w.text.trim(), start: w.offsets.from / 1000, end: w.offsets.to / 1000 });193 }194}195require('fs').writeFileSync('transcript.json', JSON.stringify(words, null, 2));196```197198### 4. Customize HTML template199200Copy `videos/video-marketing-services.html` (square 1080×1080) and `video-marketing-services-vertical.html` (vertical 1080×1920). Per client, edit:201202- `<iframe src="...">` → client URL203- `.demo-frame-wrapper iframe { height: ... }` → match target site's full scroll length204- `@keyframes scroll-tour` percentages → hand-tune (see Camera tour rules below)205- `<img class="cta-logo" src="...">` → client logo file206- `.cta-phone` content → client contact207- `<div class="sub sub-N">...` → match exact whisper transcription208- `.sub-N` CSS rules → use `subFadeIn`/`subFadeOut` with whisper word timestamps209210### 5. Phrase grouping for subtitles211212- **3–5 words per phrase** (conversational default)213- 2–3 words for hype/launch energy214- 4–6 words for educational/measured215- Break on sentence boundaries, commas with ≥150 ms pause, natural breath units216- ONE subtitle on screen at a time217- 12 phrases is the typical count for a 25 s voice off218219Generate the CSS programmatically from `transcript.json` instead of typing by hand:220221```javascript222const words = require('./transcript.json');223const phrases = [224 ["Hay webs que se ven.", 0, 4],225 ["Hay webs que se sienten.", 5, 9],226 ["Y hay webs que venden por ti.", 10, 16],227 // ...228];229phrases.forEach(([text, s, e], i) => {230 const start = words[s].start.toFixed(2);231 const end = words[e].end.toFixed(2);232 console.log(`.sub-${i+1} { animation: subFadeIn 0.18s ease-out forwards ${start}s, subFadeOut 0.22s ease-in forwards ${end}s; }`);233});234```235236### 6. Render237238```bash239cd videos240python record_video.py # both formats, cinematic style, default channel241python record_video.py square # only square242python record_video.py vertical # only vertical243python record_video.py . --style bold # bold pack (both formats)244python record_video.py . vertical --style bold --channel tiktok # bold + TikTok placement245python record_video.py . vertical --channel linkedin246python record_video.py . vertical --channel youtube247```248249Each format takes ~30 s. Both formats: ~80 s total. Each channel or style variant adds one extra pass; render multiple variants in sequence to produce platform-tuned + brand-tuned outputs without overwriting (filenames are suffixed: `marketing-vertical-bold-tiktok.mp4`).250251If `sfx.json` is present in the project folder, SFX cues are mixed into the audio automatically — no extra flag needed.252253`--style` and `--channel` are orthogonal: style controls the visual lenguage (palette, transitions, subtitle treatment), channel controls subtitle PLACEMENT on the frame (where the platform's native UI lives). Both can combine freely.254255### 7. QA review process (MANDATORY — never skip)256257Render exit code 0 does NOT guarantee a correct video. Playwright recording timing drift, iframe-load white frames, cursor invisibility on bright backgrounds, misframing, and subtitle drift all PASS the render but FAIL visually. Always run the four-step QA review below before declaring done.258259#### 7a. Metadata audit260261```bash262FFMPEG=$(python -c "import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())")263"$FFMPEG" -i videos/<output>.mp4 2>&1 | grep -E "Duration|Stream"264```265266Verify: duration matches (~28-30s), resolution matches format, h264/yuv420p/AAC ≥128kbps audio.267268#### 7b. Frame extraction at critical timestamps269270Critical timestamps for a 28s 2-page video (adapt per structure):271272| t | What to verify |273|---|---|274| 0.3–0.5s | First subtitle visible, drone landing, no white iframe-load frame |275| 1.5s | Sub-1 fading out / sub-2 fading in (subtitle sync) |276| 4.5s | Mid-home scroll, sub matches voice phrase |277| Just-before-transition (e.g. 8.6s) | Fake cursor visible AND on/near a clickable element |278| Just-after-transition (e.g. 9.0s) | Next page rendered (no blank), subtitle matches voice |279| Page-2 climax (e.g. 13.0s) | Money shot — narrative pitch lands on most relevant content |280| Pre-CTA (e.g. 22.5s) | Last sub of voice off, last frame of demo |281| CTA (e.g. 24-25s) | Logo + tagline + phone visible, NO subtitle, NO cursor |282283```bash284mkdir -p videos/qa-frames285for t in 0.3 1.5 4.5 8.6 9.0 13.0 18.5 22.5 24.0; do286 "$FFMPEG" -y -ss "$t" -i videos/<output>.mp4 -frames:v 1 -q:v 2 "videos/qa-frames/t${t}.png"287done288```289290Then READ each PNG via the Read tool. Visual inspection is the ONLY way to catch real bugs.291292#### 7c. Error patterns table — what to look for in each frame293294| Error pattern | What it looks like | Root cause / fix |295|---|---|---|296| Pure white frame at start | Blank white, no content | Iframe still loading. Add `onload="window.iframe1Loaded=true"` to iframe + Python `wait_for_function("() => window.iframe1Loaded")` BEFORE `window.startSlideshow()`. Trim with `-ss <offset>` |297| Misframed page (sides cropped) | Right/left UI cut off | iframe `width` ≠ wrapper width, or `margin-left: -420px` on a responsive site. Match iframe width to wrapper (regla #6) |298| Sudden white page mid-video | Bright page after dark | A subpage has white bg → flash. REMOVE that page (regla #17). Keep only dark-themed pages |299| Cursor invisible | No cursor at expected click moment | White-on-white. Cursor SVG must have `stroke-width: 3` + `filter: drop-shadow(0 0 6px rgba(0,0,0,0.9)) drop-shadow(0 4px 10px rgba(0,0,0,0.7))` (regla #18) |300| Cursor on empty bg | Cursor in dead space, not a button | `left`/`top` estimate wrong. Adjust per cursor instance |301| Subtitle wrong text | Sub says X, voice says Y | Sub text doesn't match whisper transcript. Use whisper as truth |302| Subtitle wrong timing | Sub appears too early/late vs voice | Trim offset miscalculated. Adjust `slideshow_start_offset` or measure `recording_start` BEFORE `page.goto()` |303| Music abrupt cut at voice end | Audio stops at ~23s | Used `-shortest`. Switch to `amix=inputs=2:duration=longest` (regla #3) |304| White flash 0-0.3s | Brief white at very start | Iframe-load gating missing OR `-ss` trim too small |305| Subtitle visible during CTA | Demo sub bleeds into outro | Missing `body.cta-active .subtitles { opacity: 0 }` (regla #12) |306307#### 7d. Cursor visibility zoom308309Cursors are 28-32px on a 1080-canvas — invisible at preview downscale. ALWAYS zoom:310311```bash312"$FFMPEG" -y -i <frame>.png -vf "crop=400:200:<cursor-x>:<cursor-y>,scale=800:400" <frame>-zoom.png313```314315Read the zoomed PNG. Cursor must be clearly visible against the background — both fill AND stroke contrasting with whatever's behind.316317#### 7e. Static checklist (after frame inspection passes)318319- [ ] Subtitle text matches the voice EXACTLY (whisper-transcribed words)320- [ ] Each subtitle appears as the phrase starts, disappears as it ends321- [ ] No subtitle visible during the CTA slide322- [ ] No white flash at start (`-ss <offset>` working, iframe gating working)323- [ ] Music continues past voice end (no abrupt cut)324- [ ] Logo + phone fully visible during CTA325- [ ] Total duration ≈ slideshow + 1-2s buffer326- [ ] File sizes reasonable: square ~5-9 MB, vertical ~9-14 MB327- [ ] **Multi-page**: every iframe loaded fully, no blank frames after transition328- [ ] **Multi-page**: page transitions feel natural — crossfade default329- [ ] **Multi-page**: total page durations + CTA = voiceover + CTA duration330- [ ] **Fake cursor**: visible (zoom-checked) AND lands on/near clickable element331- [ ] **Auto-discovered pages**: user approved AND no light/white-bg pages in dark video332333#### 7f. If anything fails334335Do NOT declare done. Fix HTML or `record_video.py`, re-render, re-extract frames, re-inspect. Iterate until the video passes the visual inspection cleanly. The user pays you to catch errors, not to ship them and ask for forgiveness.336337## Camera tour tuning (the hardest creative part)338339Spend 10–15 minutes per client tuning `@keyframes scroll-tour`. The structure:340341```css342@keyframes scroll-tour {343 /* DRONE LANDING — open zoomed-in, settle */344 0% { transform: translateY(0) scale(1.15); }345 9% { transform: translateY(0) scale(1.0); } /* land at hero */346347 /* HOLD on hero */348 18% { transform: translateY(0) scale(1.0); }349350 /* SCROLL through key sections */351 30% { transform: translateY(-900px) scale(1.0); }352 45% { transform: translateY(-2200px) scale(1.06); } /* slight zoom for emphasis */353 65% { transform: translateY(-3600px) scale(1.0); }354355 /* LAND on brand-positive section (footer / contact / testimonials) */356 100% { transform: translateY(-4720px) scale(1.0); }357}358```359360Tuning rules:361362- **0–9 % drone landing:** `scale(1.10–1.20)` → `scale(1.0)`. This is the hook.363- **Hold longer** on visually-important sections (hero, featured products, CTAs).364- **Slight zoom** (`scale(1.05–1.08)`) at 1–2 emphasis points — never on every section.365- **Total animation duration must equal voiceover.mp3 duration** so the CTA outro lands as the voice transitions to the brand close.366- **End on something brand-positive** — footer, contact section, testimonials, never mid-scroll.367368## Multi-page tour pattern369370When the marketing video should reveal more than just the homepage (which is most of the time — single-page tours feel thin), stack each page as its own `demo-slide` and let the existing scheduler swap between them.371372### Structure373374```html375<!-- Slide 1: homepage (8 s) -->376<div class="slide demo-slide active" data-duration="8000">377 <div class="demo-frame-wrapper">378 <iframe src="https://client.com/" class="page-1"379 sandbox="allow-same-origin allow-scripts" loading="eager"></iframe>380 </div>381</div>382383<!-- Slide 2: products (6 s) -->384<div class="slide demo-slide" data-duration="6000">385 <div class="demo-frame-wrapper">386 <iframe src="https://client.com/products" class="page-2"387 sandbox="allow-same-origin allow-scripts" loading="eager"></iframe>388 </div>389</div>390391<!-- Slide 3: contact (5 s) -->392<div class="slide demo-slide" data-duration="5000">393 <div class="demo-frame-wrapper">394 <iframe src="https://client.com/contact" class="page-3"395 sandbox="allow-same-origin allow-scripts" loading="eager"></iframe>396 </div>397</div>398399<!-- CTA outro (5 s) -->400<div class="slide cta-slide" data-duration="5000">...</div>401```402403Each iframe gets its own scroll-tour keyframes, sized to its content:404405```css406.page-1 { animation: scroll-tour-1 8s linear forwards; }407.page-2 { animation: scroll-tour-2 6s linear forwards; }408.page-3 { animation: scroll-tour-3 5s linear forwards; }409410@keyframes scroll-tour-1 {411 0% { transform: translateY(0) scale(1.15); }412 14% { transform: translateY(0) scale(1.0); }413 100% { transform: translateY(-2400px) scale(1.0); }414}415@keyframes scroll-tour-2 {416 0% { transform: translateY(0) scale(1.0); }417 60% { transform: translateY(-1200px) scale(1.06); }418 100% { transform: translateY(-1800px) scale(1.0); }419}420/* etc. */421```422423### Pacing424425- **5–8 s per page** is the sweet spot for product/service marketing.426- **First page (homepage) gets the longest hold** — 7–8 s with the drone landing intro.427- **Last page should be action-oriented** (contact, signup) so the visual narrative ends at "what to do next."428- **Total page durations + CTA must equal voiceover duration** so the voice off lands the brand close exactly when the CTA slide appears. Off-by-1-second is fine; off-by-3 looks broken.429430### Page selection — pick by narrative arc, not URL alphabet431432| Slot | Content type | Why |433|---|---|---|434| 1 | Homepage / hero | First impression, brand vibe |435| 2 | Primary value (products, services, work) | What they actually sell |436| 3 | Trust (testimonials, case studies, about) | Why believe this brand |437| 4 | Action (contact, booking, signup) | What to do right now |438439Skip subpages with login walls, half-built dashboards, "coming soon" placeholders, or noisy admin panels.440441## Fake cursor click overlay (recommended for multi-page tours)442443A fake mouse-cursor SVG that animates onto a visible CTA button on screen ~0.3 s BEFORE the slide transitions to the next page. The viewer's brain reads it as "clicked → navigated" even though no actual click happened. Pure visual storytelling.444445### Structure446447```html448<!-- Strong stroke + dual drop-shadow keeps the cursor visible on ANY background.449 White fill + 3px black stroke = high contrast on dark; black stroke + dark450 drop-shadow keeps it readable on white. -->451<svg class="fake-cursor" width="32" height="46" viewBox="0 0 24 36">452 <path d="M0,0 L0,28 L7,21 L11,30 L14,29 L10,20 L18,20 Z"453 fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>454</svg>455```456457```css458.fake-cursor {459 position: fixed;460 top: 0; left: 0;461 z-index: 200;462 opacity: 0;463 pointer-events: none;464 /* Dual drop-shadow: dark for contrast on light bg + cyan glow for brand pop */465 filter:466 drop-shadow(0 0 6px rgba(0, 0, 0, 0.9))467 drop-shadow(0 4px 10px rgba(0, 0, 0, 0.7));468 will-change: transform, opacity;469}470471/* Animate cursor for handoff slide-1 → slide-2 (slide-1 is 8 s, transition fires at 8 s) */472.fake-cursor {473 animation: cursor-click-1 1s ease-in-out forwards 6.7s;474}475476@keyframes cursor-click-1 {477 0% { left: 50%; top: 50%; opacity: 0; transform: scale(1); }478 20% { left: 50%; top: 50%; opacity: 1; transform: scale(1); } /* fade in mid-screen */479 70% { left: 760px; top: 820px; opacity: 1; transform: scale(1); } /* glide to button */480 85% { left: 760px; top: 820px; opacity: 1; transform: scale(0.85);} /* click pulse */481 100% { left: 760px; top: 820px; opacity: 0; transform: scale(1); } /* fade out */482}483484body.cta-active .fake-cursor { opacity: 0; }485```486487### Rules488489- **Land on a real visible button** (Hero CTA, "Ver productos", "Contacto"). Estimate `(left, top)` based on the iframe's current scroll position at handoff time.490- **Time the click pulse 0.3 s BEFORE the slide transition fires**, not after. The transition feels "caused" only if the click happens first.491- **One cursor element for the whole video**, animated between scenes. Don't create one per slide.492- **Always hide during the CTA outro** (`body.cta-active .fake-cursor { opacity: 0 }`).493- **Skip the cursor on transitions where it would feel forced** — e.g., a long product gallery scroll already feels like browsing; don't fake-click between gallery items.494495## Auto-discovery of subpages496497If the user doesn't volunteer subpage URLs, run a Playwright pass to scrape the site's nav before recording, then ASK the user to approve the discovered list.498499### Discovery script — drop in `videos/discover_pages.py`500501```python502"""503Usage: python discover_pages.py https://client.com504Prints a JSON list of up to 4 same-origin URLs from the nav/header.505"""506from playwright.sync_api import sync_playwright507from urllib.parse import urlparse508import json, sys509510base_url = sys.argv[1]511origin = urlparse(base_url).netloc512513with sync_playwright() as p:514 browser = p.chromium.launch(headless=True)515 page = browser.new_page(viewport={"width": 1920, "height": 1080})516 page.goto(base_url, wait_until="networkidle")517518 hrefs = page.eval_on_selector_all(519 "nav a[href], header a[href]",520 "els => els.map(e => e.href)"521 )522 browser.close()523524unique = []525seen = set()526for h in hrefs:527 parsed = urlparse(h)528 if parsed.netloc != origin: continue529 if parsed.fragment: continue530 if parsed.path in ("", "/"): continue531 key = parsed.path.rstrip("/")532 if key in seen: continue533 seen.add(key)534 unique.append(h)535536print(json.dumps(unique[:4], indent=2))537```538539### Use it540541```bash542python videos/discover_pages.py https://client.com > videos/subpages.json543```544545Then SHOW the discovered list to the user. Examples of how to phrase it:546547> "Encontré estas páginas en el menú: `/products`, `/about`, `/contact`. ¿Vamos con esas, o prefieres otras?"548549### Rules550551- **Always require user approval** before wiring auto-discovered URLs into the HTML. The most marketing-relevant page is sometimes buried in the footer and the nav-scrape misses it.552- **Trim to 3 max** for a 30-second video. More than that = each page gets too little screen time to register.553- **If discovery returns 0 results** (single-page sites, hash-based routing, JS-rendered nav that didn't load in time), fall back to homepage-only mode and tell the user.554- **Verify each discovered URL with `curl -I`** to filter out `X-Frame-Options: DENY` pages before showing the list.555556## Output spec to validate557558| Property | Square | Vertical | Horizontal (optional) |559|---|---|---|---|560| Resolution | 1080×1080 | 1080×1920 | 1920×1080 |561| Frame rate | 25 fps (Playwright default) | 25 fps | 25 fps |562| Codec | H.264 (libx264, CRF 20) | same | same |563| Audio | AAC, 192 kbps | same | same |564| Pixel format | yuv420p | yuv420p | yuv420p |565| Container | MP4 (`+faststart`) | MP4 | MP4 |566| Final size | ~5 MB | ~9 MB | ~15 MB |567| Native fit | LinkedIn, IG feed, FB, X | Reels, TikTok, Shorts, IG Stories | YouTube, web hero |568569## Reference570571- **`PIPELINE.md`** at the project root — full workflow, FFmpeg flag table per flag, voice presets table, ElevenLabs gotchas table, troubleshooting matrix572- **`videos/video-marketing-services.html`** — canonical square template (1080×1080)573- **`videos/video-marketing-services-vertical.html`** — canonical vertical template (1080×1920)574- **`videos/record_video.py`** — Playwright + FFmpeg orchestrator with Windows file-lock retry575576When a step is ambiguous or the user asks for something the rules don't cover, read `PIPELINE.md` rather than improvising.