Claude Code skill that turns any website URL into a 30s cinematic marketing video — drone tour, voice off, word-synced subtitles, square/vertical/horizontal. HTML + Python + FFmpeg.
SaferSkills independently audited creador_videos_ai (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
Trigger this skill when the user asks for any of:
Do NOT invoke for:
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.
X-Frame-Options: DENYvideos/<client>-logo.png)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 continuingvideos/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 continuingcinematic. The chosen pack drives palette, transitions, subtitle treatment, and callout entrance — it's a single coherent decision, not a checkbox list./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.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.--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.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.pngIf any returns "No such file", stop and ask the user to provide it before proceeding.
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:
style-<name> selector and that's a follow-up engagement, not a per-client customization.bold with cinematic subtitles — packs are coherent design systems, mixing them defeats the purpose.These are mistakes that look correct in code but break the result. Never violate:
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 */
}.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>.-shortest cuts at voice end and feels abrupt.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.yuv444p. No exceptions.+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..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.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.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.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.body.cta-active .fake-cursor { opacity: 0 }.<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.#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.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.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..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.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.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).playwright package + python -m playwright install chromiumPATH (or imageio-ffmpeg Python package as fallback — the script auto-detects)C:\whisper\whisper-cli.exe on Windows (or built locally on macOS/Linux)ggml-small.bin at C:\whisper\models\ (~466 MB)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.
C:\whisper\whisper-cli.exe -m C:\whisper\models\ggml-small.bin -l <lang> -oj -of transcript voiceover.mp3The 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));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 timestampsGenerate 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; }`);
});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 youtubeEach 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.
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"
doneThen 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.pngRead 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)
-ss <offset> working, iframe gating working)#### 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.
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:
scale(1.10–1.20) → scale(1.0). This is the hook.scale(1.05–1.08)) at 1–2 emphasis points — never on every section.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.
<!-- 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. */| 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.
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.
<!-- 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; }(left, top) based on the iframe's current scroll position at handoff time.body.cta-active .fake-cursor { opacity: 0 }).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.
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))python videos/discover_pages.py https://client.com > videos/subpages.jsonThen 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?"
X-Frame-Options: DENY pages before showing the list.| 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 |
When a step is ambiguous or the user asks for something the rules don't cover, read PIPELINE.md rather than improvising.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.