
Video Edit
- 29 installs
- 262 repo stars
- Updated July 11, 2026
- hoodini/ai-agents-skills
Edit a video into a captioned showcase by transcribing, pausing for transcript approval, then rendering liquid-glass captions in both 16:9 and 9:16.
About
Runs a transcribe to review to render pipeline on HyperFrames with a mandatory transcript-approval pause and dual horizontal/vertical output. A developer uses it to caption or edit a video into a reel, promo, or tutorial.
- Whisper large-v3 transcription with browser review before render
- Renders 16:9 and 9:16 from one source with liquid-glass caption pills
Video Edit by the numbers
- 29 all-time installs (skills.sh)
- +6 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #975 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hoodini/ai-agents-skills --skill video-editAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 262 |
| Last updated | July 11, 2026 |
| Repository | hoodini/ai-agents-skills ↗ |
What it does
Edit a video into a captioned showcase by transcribing, pausing for transcript approval, then rendering liquid-glass captions in both 16:9 and 9:16.
Files
Video Edit — Captioned Showcase Pipeline
End-to-end captioned video editor on top of HyperFrames. The user gives you a video; you orchestrate transcribe → review → render and ALWAYS pause for transcript approval before the long render.
Where this skill sits in the YUV.AI pyramid
video-edit is in the middle tier of the YUV.AI skills pyramid alongside yuv-design-system, yuv-decks, yuv-viral-video, parallax-landing-page, and video-to-landing-page. The top-tier orchestrator yuv-pilot routes here whenever the user wants a captioned showcase, tutorial, or talking-head edit with subtitles.
This is the more general video sibling to yuv-viral-video. The split:
yuv-viral-video— opinionated YUV.AI viral-short pipeline (MrBeast pacing, signature editorial style)video-edit— general captioned editor with transcript-review-before-render (Hebrew + English + any Whisper language)
For YUV.AI-branded captioned video, pair this skill with yuv-design-system (Neon mode for type/palette decisions). For generic / third-party captioned video, this skill works standalone.
When to invoke
- A path to a video file (mp4/mov/mkv) + a request to "edit", "caption", "add subtitles", "make a reel/promo", "do the same"
- "Fix the captions / Hebrew misspells" — re-enter at the review step on an existing project
- Any captioned tutorial / talking-head / promo build
Save location
Default: ~/Documents/yuv-projects/videos/<slug>/ — always save captioned video projects here so renders are findable. The <slug> is short, derived from the topic or source filename.
mkdir -p ~/Documents/yuv-projects/videos
cd ~/Documents/yuv-projects/videos
# Initialize the project here.Final render lands at ~/Documents/yuv-projects/videos/<slug>/renders/<name>_FINAL.mp4. Tell the user where the video lives at the end of the render.
---
Workflow (12 steps)
1. Probe the source — ffprobe for dimensions, fps, duration, audio. 2. Scaffold — cd ~/Documents/yuv-projects/videos && npx hyperframes init <slug> --video <path> --non-interactive. Rename the copied video to source.mp4. 3. Extract audio — ffmpeg -i source.mp4 -vn -ac 1 -ar 16000 audio.wav. 4. Transcribe — copy references/transcribe.py into the project. Default model large-v3 (best Hebrew). CUDA usually fails on Windows (missing cuDNN); the script falls back to CPU int8. Force language="he" for Hebrew, language="en" for English; otherwise auto-detect. 5. Apply known corrections — copy references/corrections-hebrew.md content into a corrections.json at the project root (keys = wrong token, values = correct token). 6. 🛑 STOP — start the review server and let the user approve in a webapp. First apply known corrections: copy references/make_review.py into the project and run python make_review.py. It applies corrections.json to transcript.json.
Then spawn the review server as a background task (it blocks until the user clicks "Approve & Render" in the browser):
python "$HOME/.claude/skills/video-edit/references/serve_review.py" .
# On Windows: python "C:\Users\<you>\.claude\skills\video-edit\references\serve_review.py" .The server prints a line like REVIEW_URL=http://localhost:PORT/. Grab that URL from the background-task output (or read stdout) and send the user:
👉 Review your transcript here: http://localhost:PORT/
When you click Approve & Render, I'll continue automatically.
The agent does not need a "continue" message — when the user clicks the button, the server writes transcript_review.txt to the project dir AND exits with code 0. The agent's background-task notification fires, and the pipeline resumes from step 8.
Fallback if no browser / no server: open the editor as a static file (start "" "$HOME/.claude/skills/video-edit/transcript-editor/index.html"), ask the user to pick the project folder, edit, save transcript_review.txt back into the project, and reply "continue". The editor supports both modes.
7. (Optional) Background removal — see step 7 below; can run in parallel with the user's review.
8. After approval, run python references/apply_review.py. It re-tokenises edited lines and redistributes word timings back into transcript.json so caption sync still works. 7. (Optional) Background removal — if any talking-head segment needs behind-subject text, extract the segment as outro.mp4 (or intro.mp4) and run npx hyperframes remove-background <clip>.mp4 -o <name>_subject.webm --quality best. CPU only on most setups (~3–8 min for a ~15s 1440p clip). 8. Re-encode source with dense keyframes — multi-worker render seeks freeze on sparse keyframes. Always run:
ffmpeg -y -i source.mp4 -c:v libx264 -preset medium -crf 18 -r 30 -g 30 -keyint_min 30 -sc_threshold 0 -pix_fmt yuv420p -movflags +faststart -c:a copy footage.mp49. Re-load the (edited) transcript and generate the body sub-composition via references/gen_body.py. The generator emits the full compositions/components/caption-body.html with editorial + matrix alternating in liquid-glass pills, anchored lower-left-of-centre (clears bottom-right webcam PiPs). 10. Wire the host `index.html` from references/host-template.html. Layer order (z-index, NOT track-index):
- z0: footage
.cam-bg - z1: liquid blob background (
compositions/liquid-blobs.html,mix-blend-mode: screen, full duration) - z2: parallax behind-subject caption (intro and/or outro, when bg-removal used)
- z3: subject cut-out
.cam-out/.cam-sub(with matchingdata-media-start) - z6: body captions
- z46: progress bar + flash + liquid morph wipe
11. Lint — npx hyperframes lint. Must be 0 errors. Common fixes: GSAP/CSS transform conflict on the wipe element (use xPercent/yPercent or remove the CSS transform); overlapping tweens on the same property (add overwrite: "auto"). 12. Render — npx hyperframes render --quality standard --fps 30 --output renders/<name>_FINAL.mp4. Standard is the right delivery target — high roughly doubles render time. Verify with 6–8 spot-check frames from across the timeline before reporting done.
Vertical (9:16) output for TikTok / Reels / Shorts
When the user asks for vertical / portrait / TikTok / Reels / 9:16 output (from a 16:9 source):
1. Clone the project to a sibling folder: cp -r project/ project-vertical/. 2. Replace its index.html with references/host-template-vertical.html (1080×1920 canvas, blurred-bg backdrop with liquid blobs, the 16:9 footage as a centered horizontal strip, captions below). 3. Replace its gen_body.py with references/gen_body_vertical.py (centered pill, larger fonts, narrower max-width), then re-run it to emit compositions/components/caption-body.html. 4. Drop the behind-subject cut-out + parallax sub-compositions (the cutout is aligned for 16:9; not worth re-aligning for v1). The vertical comp uses the blurred-source backdrop + blobs for atmosphere instead. 5. Update data-duration to the actual video duration. Update the brand-chip text in index.html (YUV.AI by default). 6. Lint + render — same commands. Output is 1080×1920. Drop straight onto TikTok / IG Reels / YT Shorts.
To deliver both 16:9 and 9:16 in one go, run two render commands (in parallel projects). The transcript_review.txt approval applies to both — same captions, two compositions.
Critical rules
- Never render the final without explicit transcript approval. The review step is the whole point.
- For Hebrew:
large-v3+language="he"+direction: rtl+ Rubik (700 + 900 for editorial dual-weight emphasis). - Caption pills always need an opaque dark backing — bare light text vanishes on white app UI.
- Centre caption pills horizontally but shift the centre x-coord left (e.g.
left: 720px) when the footage has a bottom-right webcam PiP. - The behind-subject cut-out clip MUST carry
data-media-startmatching itsdata-start(or matching the offset from the source if the clip was extracted), or the cut-out plays from frame 0 and desyncs. - The
remove-backgroundwebm keeps the original RGB and writes only the alpha mask —ffprobereportsyuv420p, which looks like "no alpha". Confirm viaTAG:ALPHA_MODE=1or composite over a solid colour. - Outro/end cards with burned-in text — do NOT caption over them; they collide.
File references
| File | Purpose |
|---|---|
transcript-editor/index.html | Interactive browser editor — video preview, RTL editing, dictionary apply, optional WebLLM AI suggestions, saves transcript_review.txt |
references/setup.md | Prerequisites + install commands for Node / Python / FFmpeg / faster-whisper |
references/transcribe.py | faster-whisper transcribe with CPU fallback + word timestamps |
references/serve_review.py | Local review server — auto-loads editor, blocks until user clicks Approve & Render, then writes transcript_review.txt and exits (signals the agent) |
references/make_review.py | Apply corrections + emit transcript_review.txt (file-mode fallback) |
references/apply_review.py | Parse edited review file, redistribute word timings, update transcript.json |
references/gen_body.py | Caption-body generator (editorial + matrix in liquid-glass pills) |
references/host-template.html | 16:9 host composition with liquid effects + transition wipe |
references/host-template-vertical.html | 9:16 host (1080×1920) — TikTok / Reels / Shorts layout: blurred bg, centered 16:9 footage strip, captions below, brand chip top-right |
references/gen_body_vertical.py | Caption-body generator tuned for vertical (centered pill, larger fonts, narrower max-width) |
references/liquid-blobs.html | Full-duration drifting blob layer |
references/caption-parallax-outro.html | Behind-subject caption template (English; clone for other languages) |
references/corrections-hebrew.md | Known Hebrew Whisper mishears |
references/transcript-review-workflow.md | The pause/approve step in detail |
<div align="center">
🎬 video-edit
An agent skill + interactive webapp that turn any video into a captioned cinematic showcase — with a human-in-the-loop transcript review the agent waits for automatically.
<p> <img alt="status" src="https://img.shields.io/badge/status-beta-ff3da6?style=for-the-badge"/> <img alt="languages" src="https://img.shields.io/badge/captions-EN%20%2B%20HE%20%2B%20any-ffd24a?style=for-the-badge"/> <img alt="ai" src="https://img.shields.io/badge/AI-faster--whisper%20%2B%20WebLLM-00ff8a?style=for-the-badge"/> <img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6?style=for-the-badge"/> </p>
<table> <tr> <td><img src="screenshots/01-parallax-behind-subject.jpg" alt="Hebrew red display text woven behind the speaker"/></td> <td><img src="screenshots/03-editorial-emphasis-glass.jpg" alt="Editorial emphasis caption in a liquid-glass pill"/></td> </tr> <tr> <td><img src="screenshots/02-matrix-decode-glass.jpg" alt="Matrix-decode caption in green"/></td> <td><img src="screenshots/05-outro-behind-text.jpg" alt="Outro behind-subject Hebrew text"/></td> </tr> </table>
</div>
---
Why this exists
Whisper hears Claude as cloud, Excalidraw as Excalibro, and Hebrew "האריה הלבן" as "הרגע הלבן." Most "AI video editors" silently bake those mistakes into a 12-minute render you can't undo without re-rendering. We do the opposite — the agent stops mid-pipeline, hands you an interactive transcript editor, and only continues when you click Approve & Render. The captions are perfect because you approved them — and the agent knows you approved them because the webapp posts the signal back over a local socket.
That single design choice — a webapp that signals an agent over HTTP, not a chat message — is what makes this feel different from anything else in the open-source space.
---
What it does
| Step | Who | What |
|---|---|---|
| 1 | Agent | Probes the source video (ffprobe), scaffolds a HyperFrames project, extracts audio. |
| 2 | Agent | Transcribes with faster-whisper large-v3 (CPU-int8 fallback baked in for Windows / CUDA-less machines). |
| 3 | Agent | Applies a curated `corrections.json` dictionary — known Hebrew & product-name mishears get fixed automatically. |
| 4 | 🛑 Agent → You | Spawns a local HTTP review server. Prints a clickable URL: http://localhost:<port>/. |
| 5 | You | Open the URL. The editor auto-loads the transcript + video. Edit inline (RTL aware, video synced, autosaving). Optionally enable in-browser WebLLM to get AI-suggested fixes per segment (Qwen 2.5-3B / Llama-3.2-3B over WebGPU). |
| 6 | You | Click `✓ APPROVE & RENDER`. |
| 7 | Agent | Server writes transcript_review.txt to the project and exits 0 → the agent's background task fires automatically. |
| 8 | Agent | Redistributes word timings, regenerates the caption sub-composition (liquid-glass pills, alternating editorial + matrix styles), optionally runs background-removal for behind-subject text, and renders the final MP4. |
No continue typed. No file moved. No npm scripts run. The user clicks one button.
---
The captions are not subtitles
Renders include the full HyperFrames caption library — selectable per project, soon selectable per segment (see Roadmap):
- Editorial Emphasis — dual-font (sans body + italic serif emphasis word) on a frosted glass pill.
- Matrix Decode — letters scramble for ~180 ms then resolve, in Matrix green with a soft glow.
- Kinetic Slam — full-screen single-word slams with alternating entrance directions.
- Parallax Layers — the killer one. Massive red display text that passes behind the
subject. We background-remove the talking-head clip, drop the text on z-index: 1, put the alpha-masked subject on z-index: 2 — text weaves around their head.
Plus a liquid blob background (drifting magenta / cyan / gold orbs, screen-blended so they glow on dark talking-heads and vanish on white app UI), liquid morph transitions at section cuts, camera punch-ins synced to caption beats, and subtle film grain + vignette during cinematic segments.
<table> <tr> <td><img src="screenshots/06-liquid-blobs.jpg" alt="Liquid blob background glowing on a dark scene"/><br/><sub>Liquid blob background — glows on dark, invisible over white</sub></td> <td><img src="screenshots/04-talking-head-outro.jpg" alt="Talking-head outro with caption pill"/><br/><sub>Liquid-glass pill caption over the talking-head outro</sub></td> </tr> </table>
---
Quick start
One-shot install (Mac/Linux)
curl -sSL https://raw.githubusercontent.com/hoodini/ai-agents-skills/master/install.sh | bashInstalls node ≥ 22, python ≥ 3.10, ffmpeg, faster-whisper, hyperframes CLI, and drops this skill into ~/.claude/skills/video-edit/. Idempotent — safe to re-run.
Manual (Windows or anywhere)
winget install OpenJS.NodeJS.LTS
winget install Python.Python.3.12
winget install Gyan.FFmpeg
pip install faster-whisper
npm install -g hyperframes
git clone https://github.com/hoodini/ai-agents-skills "$HOME/.claude/skills-src"
robocopy "$HOME/.claude/skills-src/skills/video-edit" "$HOME/.claude/skills/video-edit" /EUse it from your AI agent
Open Claude Code (or Cursor / Codex / Copilot — anything that supports the agent-skills standard). Drop a path and say it like a human:
edit this video: C:\Users\me\Videos\demo.mp4…or with Hebrew / mixed-language:
ערוך לי את הסרטון הזה עם כתוביות: ~/Videos/talk.mp4The agent matches video-edit by description, runs the pipeline, and stops to ask you to approve the transcript. That's it.
---
End-to-end: from raw video to ready-to-post (10 steps)
The complete recipe a new user follows. The agent does all the heavy lifting — you do exactly two things: (1) review the transcript, (2) optionally pick styles per segment.
1. Install once
Mac / Linux:
curl -sSL https://raw.githubusercontent.com/hoodini/ai-agents-skills/master/install.sh | bashWindows (PowerShell):
winget install OpenJS.NodeJS.LTS Python.Python.3.12 Gyan.FFmpeg
pip install faster-whisper
npm install -g hyperframes
git clone https://github.com/hoodini/ai-agents-skills "$HOME/.claude/skills-src"
robocopy "$HOME/.claude/skills-src/skills/video-edit" "$HOME/.claude/skills/video-edit" /E2. Drop your video on the agent
In Claude Code (or any agent that supports the agent-skill standard):
edit this video: C:\path\to\my-talk.mp4For vertical (TikTok / Reels / Shorts) from a 16:9 source, add:
…and make it vertical for TikTokThe agent says "got it" and starts running. No menus, no presets.
3. Wait for the review URL (1–10 min depending on length)
The agent runs ffprobe → extracts audio → transcribes (faster-whisper large-v3) → applies known mishear corrections → spawns the local review server.
When transcription finishes you'll see a message like:
👉 Review your transcript here: http://localhost:54287/
Click "Approve & Render" when done — I'll continue automatically.
4. Open the URL — the editor auto-loads your project
The transcript editor opens with:
- Your transcript on the right, each segment on its own line
- The source video on the left, playing in sync
- A 🎨 chip under every segment for picking caption style
- A
✓ APPROVE & RENDERbutton at the top right
5. Fix any mishears
Click a segment, edit the text. Whisper mishears the Hebrew word הלעיסה as על עיסה, קלוד as קלוט, etc. You fix them inline.
Optional: toggle the AI button (top bar) to enable WebLLM (~1.5 GB Qwen 2.5-3B downloads to your browser cache once). Each segment then gets a 🤖 button — click it to get a context-aware fix suggestion. Accept or dismiss inline.
6. (Optional) Assign a caption style per segment
Click the 🎨 chip below any segment. A modal opens with 15 caption styles in a 3-column grid. Hover any card to play its preview — every style has a working video preview (4 from the official HyperFrames catalog, 11 bundled locally as ~50 KB MP4s).
Pick what fits the moment. Examples:
- Editorial Emphasis — for normal talking-head segments
- Matrix Decode — for tech / hacking vibes
- Kinetic Slam — for high-energy hooks
- Neon Glow — for product reveals
- Stamp Impact — for punch-line moments
- Highlight Marker — for "here's the key point" callouts
- Soft Fade — for quiet emotional beats
- Auto (default) — alternates Editorial + Matrix automatically
Each pick saves a sidecar caption_styles.json next to your transcript. If you don't pick anything, the default Auto-rotate still ships beautiful captions.
7. Click ✓ APPROVE & RENDER
That single click: 1. POSTs your edited transcript to the local server 2. Server writes transcript_review.txt + caption_styles.json next to your video 3. Server exits cleanly 4. Your agent's background task receives the exit code and resumes automatically
You don't need to type "continue" or move any files. Walk away.
8. Agent renders the video (3–15 min depending on length)
The agent:
- Re-tokenises and redistributes word timings across your edits
- Regenerates the caption sub-composition with your per-segment style picks
- Lints the HyperFrames composition
- Renders the final MP4 (standard quality, h264 + AAC)
For both 16:9 and 9:16: the same review applies — two parallel renders.
9. Get the file
Output lives in renders/<project-name>_FINAL.mp4 (or whatever filename the agent chose). The agent opens it in your default player when done.
10. Post
- TikTok / Instagram Reels / YouTube Shorts: upload the 1080×1920 file directly
- YouTube / X / LinkedIn: upload the 1920×1080 file directly
No re-encoding needed — both renders are already yuv420p h264 + AAC, faststart-flagged for instant streaming.
What if you just want to fix captions on a project you already have?
Skip the agent entirely. Open the editor as a static file:
# Mac / Linux
open ~/.claude/skills/video-edit/transcript-editor/index.html
# Windows
start "" "%USERPROFILE%\.claude\skills\video-edit\transcript-editor\index.html"Drop your HyperFrames project folder onto the upload screen → edit → click Save. It writes transcript_review.txt back into the folder. Tell your agent "continue" — or run python apply_review.py && python gen_body.py && npm run render manually.
---
The editor
Open it directly without an agent at all:
# Static (any browser):
open ~/.claude/skills/video-edit/transcript-editor/index.html
# Or run it as a tiny review server (Approve & Render flow):
python ~/.claude/skills/video-edit/references/serve_review.py /path/to/hyperframes-projectFeatures
- One-click project folder load — File System Access API auto-finds
transcript.json
and the source/footage video; saves write transcript_review.txt straight back in place.
- Recent projects history — every project you've touched is in a card grid on the
upload screen. One click to reopen, hover to download the last review file or delete.
- Drag-and-drop a folder anywhere on the upload screen.
- Server mode (Approve & Render) — when launched via
serve_review.py, the editor
auto-opens with the project loaded and the Save button becomes ✓ APPROVE & RENDER. Clicking it posts the transcript back, writes the file, and exits the server — the parent agent sees the task complete and runs the render automatically.
- Per-segment inline editing —
direction: autoper textarea, so Hebrew and English
segments render in the right direction without configuration.
- Click-to-seek — clicking a segment seeks the video; the active segment auto-scrolls
into view as the video plays.
- Dictionary apply — paste
{"wrong": "right", ...}JSON, get whole-word substitution
across every segment with one click. Unicode word boundaries (works for Hebrew).
- Find / replace — substring across all segments.
- WebLLM AI suggestions (optional) — toggle on, ~1.5 GB model downloads into your
browser cache, each segment gets a 🤖 button that asks the local model to fix Whisper mishears given previous/next-line context. Accept or dismiss inline. Runs entirely on your machine via WebGPU. No API key. No data leaves the browser.
- Autosave to `localStorage` — refresh-safe.
- `beforeunload` guard — browser warns before navigating away with unsaved edits.
- Keyboard shortcuts — Space (play/pause), Ctrl/⌘ S (save), Ctrl/⌘ O (open another
project), Ctrl/⌘ F (find), Esc (close modal), ? (help).
What the editor isn't doing
- Sending anything to an external server.
- Uploading your video or transcript.
- Phoning home.
- Logging telemetry.
It's a single HTML file. Inspect it.
---
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ YOUR AGENT (Claude Code / Cursor / Codex / Copilot) │
│ │
│ "edit this video: <path>" │
│ │ │
│ ▼ │
│ reads SKILL.md, runs: │
│ ffprobe → ffmpeg (audio) → faster-whisper → corrections.json │
│ ↓ │
│ spawns serve_review.py as a BACKGROUND TASK │
│ │ │
│ │ blocks on threading.Event │
└────────│────────────────────────────────────────────────────────────────┘
│ prints REVIEW_URL=http://localhost:<port>
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ LOCAL HTTP SERVER (Python stdlib, no deps) │
│ │
│ GET /api/project → { transcript, videoUrl, projectName } │
│ GET /video → streamed source video (HTTP Range) │
│ GET /api/recents → ~/.hyperframes-editor/projects.json │
│ POST /approve → writes transcript_review.txt, sets Event │
└─────────────────────────────────────────────────────────────────────────┘
▲
│ fetch /api/project on load, POST /approve on click
│
┌─────────────────────────────────────────────────────────────────────────┐
│ TRANSCRIPT EDITOR (static HTML, runs in your browser) │
│ │
│ • Loads transcript + video automatically │
│ • You edit segments inline (RTL aware, video synced) │
│ • Optional: enable WebLLM (Qwen 2.5-3B) for AI suggestions │
│ • Click "✓ APPROVE & RENDER" │
└─────────────────────────────────────────────────────────────────────────┘
│ approval POST returns 200, server thread exits 0
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ AGENT RESUMES (background-task notification fires) │
│ │
│ apply_review.py → gen_body.py → npx hyperframes render │
│ ↓ │
│ Final MP4 │
└─────────────────────────────────────────────────────────────────────────┘The handshake is a thread-blocked HTTP POST, not a chat message. That's what makes the flow automatic.
---
What's in this folder
skills/video-edit/
├── SKILL.md ← agent-readable workflow (the recipe)
├── README.md ← you are here
├── references/
│ ├── setup.md ← install commands for every OS
│ ├── transcribe.py ← faster-whisper, CPU-int8 fallback
│ ├── make_review.py ← apply corrections + emit transcript_review.txt
│ ├── apply_review.py ← ingest edits, redistribute word timings
│ ├── serve_review.py ← the local HTTP review server (Approve & Render)
│ ├── gen_body.py ← liquid-glass caption-body generator
│ ├── host-template.html ← HyperFrames host composition (full layered render)
│ ├── liquid-blobs.html ← drifting blob background sub-composition
│ ├── caption-parallax-outro-en.html
│ ├── caption-parallax-outro-he.html
│ ├── corrections-hebrew.md ← curated Hebrew Whisper mishears
│ └── transcript-review-workflow.md
├── transcript-editor/
│ ├── index.html ← the webapp — open in any modern browser
│ └── README.md
└── screenshots/ ← static stills used in this README---
Examples
This skill has produced:
- A 13-second Avatar-style brand reel with 5 different caption styles (Kinetic Slam →
Editorial Emphasis → Parallax Layers → Matrix Decode → Kinetic Slam CTA) showcasing each HyperFrames caption style. Background-removed subject, behind-text effect, viewfinder HUD.
- A 2-minute-47-second tutorial featuring talking-head intro, screen-recording body, talking-head
outro, and an animated end card. Captions in liquid-glass pills shifted left to clear a bottom-right webcam PiP, with behind-subject text on both the intro and outro talking-heads.
- A 2-minute-59-second Hebrew showcase with full RTL captions, Rubik typography, behind-subject
"שבוע טוב / נתראה / תגיבו" Hebrew text on the outro talking-head, and Whisper-mishear corrections (e.g. קלוט → קלוד, המאמם → המהמם, אישות → שוט).
All three were rendered end-to-end from the same agent skill with no manual scripting.
---
Roadmap
Done — May 2026
- [x] Per-segment caption-style picker — every segment has a 🎨 chip that opens a
3-column modal with all 15 HyperFrames styles. Click to assign, saves to caption_styles.json sidecar + inline ::style=<id> markers in transcript_review.txt. Round-trips through apply_review.py → gen_body.py.
- [x] Style-preview grid — every style card has a working video preview. 4 use the
official HyperFrames catalog on heygen CDN, 11 ship as ~50 KB local clips under transcript-editor/previews/. Hover any card to play.
- [x] Render adapters for all 15 styles — 13 render natively in the body pill
(editorial-emphasis, matrix-decode, typewriter, neon-glow, split-reveal, mask-wipe, marquee-rail, stamp-impact, liquid-fill, glitch-rgb, soft-fade, bold-underline, highlight-marker). 2 stay external (kinetic-slam, parallax-layers) and get their own sub-composition slots in the host.
- [x] Vertical 9:16 support — host-template-vertical.html + gen_body_vertical.py
produce a 1080×1920 render from the same 16:9 source. TikTok / Reels / Shorts ready.
- [x] Aggressive VAD transcription — VAD min_silence=400ms + segment-length cap of 6s
brings word-timing accuracy from ±1.5s drift to ±100ms.
Still ahead
- [ ] Multi-language Whisper corrections — auto-load
corrections-<lang>.jsonbased on
detected language (currently Hebrew-only).
- [ ] Hosted demo — deploy the editor as a public static URL (Vercel) so anyone can try
the picker without installing anything.
- [ ] Background-removal preview — show the alpha-cutout in the editor so you know
which segments will use parallax-behind treatment.
- [ ] Forced alignment fallback — when Whisper word timestamps still drift on
ultra-long takes, optional whisperX integration for ±20ms accuracy.
- [ ] Save & resume mid-pick — picker state persists if you reload the editor
mid-session.
---
Contributing
This is open source and built for the AI-agent community.
Best first PRs:
- Add a Hebrew correction we missed to
references/corrections-hebrew.md. - Port the corrections approach to another language (
corrections-es.md, etc.). - Add a new HyperFrames caption-style adapter to
gen_body.py(it's a small switch
statement — see the existing editorial/matrix cases as a pattern).
- Take a polished screenshot of the editor in action and drop it into
screenshots/. - Document a render gotcha you hit.
PRs land on master directly — small repo, fast turnaround.
---
Credits
Built by Yuval Avidani and Claude Sonnet 4.7 during the development of the Practical Claude Desktop course. Stands on the shoulders of:
- [HeyGen HyperFrames](https://github.com/heygen-com/hyperframes) — the HTML-to-video
rendering engine that makes all the caption animations possible.
- [faster-whisper](https://github.com/SYSTRAN/faster-whisper) — CTranslate2-based
Whisper inference. Hebrew accuracy with large-v3 on CPU is genuinely impressive.
- [WebLLM](https://github.com/mlc-ai/web-llm) — in-browser LLM inference via WebGPU.
Qwen 2.5-3B running on your laptop fixing Hebrew Whisper output offline is still magic.
---
<div align="center">
[← All skills](../../README.md) · [Open editor in browser →](transcript-editor/)
</div>
"""Apply the user's edited transcript_review.txt back into transcript.json.
Inline ``::style=<id>`` markers (written by the transcript-editor webapp's
style picker) are also parsed and mirrored into ``caption_styles.json`` so
``gen_body.py`` can pick them up.
Parses each `[mm:ss.xx] text` line, matches it to the corresponding segment in
transcript.json by start time, re-tokenises the new text, and redistributes the
original word timings to the new tokens by sequential position + character weight.
Usage:
python apply_review.py [path/to/transcript_review.txt]
After this runs, re-run gen_body.py to regenerate captions and then render.
"""
import json, os, re, sys
def parse_time(s):
parts = re.split(r"[:.]", s.strip())
if len(parts) == 3:
m, sec, hund = parts
return int(m) * 60 + int(sec) + int(hund) / 100.0
if len(parts) == 2:
m, sec = parts
return int(m) * 60 + float(sec)
return float(s)
def redistribute_words(new_text, orig_words):
"""Distribute the timing span of orig_words to new tokens of new_text."""
new_tokens = re.findall(r"\S+", new_text)
if not new_tokens:
return []
if not orig_words:
return [{"word": tok, "start": 0.0, "end": 0.0} for tok in new_tokens]
total_start = orig_words[0]["start"]
total_end = orig_words[-1]["end"]
total_span = max(total_end - total_start, 0.01)
# Weight by char count so longer tokens get proportionally more time.
weights = [max(len(t), 1) for t in new_tokens]
total_w = sum(weights)
out = []
t = total_start
for tok, w in zip(new_tokens, weights):
dur = total_span * w / total_w
out.append(
{"word": " " + tok if out else tok, "start": round(t, 3), "end": round(t + dur, 3)}
)
t += dur
# Snap last word end exactly to the original end.
if out:
out[-1]["end"] = round(total_end, 3)
return out
def main():
rpath = sys.argv[1] if len(sys.argv) > 1 else "transcript_review.txt"
proj_dir = os.path.dirname(os.path.abspath(rpath)) or "."
tpath = os.path.join(proj_dir, "transcript.json")
with open(rpath, "r", encoding="utf-8") as f:
review = f.read()
# Parse the [mm:ss.xx] text lines (ignore # comments + blanks).
# Inline style markers may appear at end of line as ` ::style=<id>`.
pattern = re.compile(r"^\[([0-9:.]+)\]\s*(.+?)\s*$")
style_marker = re.compile(r"\s*::style=([a-z0-9_-]+)\s*$")
edited = []
for line in review.splitlines():
line = line.rstrip()
if not line or line.startswith("#"):
continue
m = pattern.match(line)
if not m:
continue
body = m.group(2)
style = None
sm = style_marker.search(body)
if sm:
style = sm.group(1)
body = body[: sm.start()].rstrip()
edited.append({"start": parse_time(m.group(1)), "text": body, "style": style})
if not edited:
print("ERROR: no [mm:ss.xx] lines found in review file", file=sys.stderr)
sys.exit(1)
with open(tpath, "r", encoding="utf-8") as f:
data = json.load(f)
# Match edited lines to segments by closest start time.
changes = 0
style_count = 0
inline_assignments = {}
for ed in edited:
# Find segment with closest start.
best = min(data, key=lambda s: abs(s["start"] - ed["start"]))
if abs(best["start"] - ed["start"]) > 1.0:
print(f"WARN: no close match for [{ed['start']:.2f}] {ed['text'][:30]}...", file=sys.stderr)
continue
if best["text"].strip() != ed["text"].strip():
changes += 1
best["text"] = ed["text"]
best["words"] = redistribute_words(ed["text"], best["words"])
if ed["style"]:
best["style"] = ed["style"]
style_count += 1
inline_assignments[str(data.index(best))] = {
"start": best["start"],
"style": ed["style"],
}
elif "style" in best:
# User reset to auto by removing the marker
del best["style"]
with open(tpath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
# If inline markers were used, mirror them into caption_styles.json so
# gen_body.py only has to read one place. Don't clobber an existing sidecar
# that already has more detail — merge instead.
styles_path = os.path.join(proj_dir, "caption_styles.json")
sidecar = {"version": 1, "assignments": {}}
if os.path.exists(styles_path):
try:
with open(styles_path, "r", encoding="utf-8") as f:
sidecar = json.load(f)
if not isinstance(sidecar.get("assignments"), dict):
sidecar["assignments"] = {}
except Exception:
sidecar = {"version": 1, "assignments": {}}
sidecar["assignments"].update(inline_assignments)
if inline_assignments or os.path.exists(styles_path):
with open(styles_path, "w", encoding="utf-8") as f:
json.dump(sidecar, f, ensure_ascii=False, indent=2)
print(f"applied {changes} edits to {tpath}")
if style_count:
print(f"applied {style_count} caption-style override(s) -> caption_styles.json")
print(f"{len(data)} segments, {sum(len(s['words']) for s in data)} words")
print("\nNext: re-run gen_body.py and render.")
if __name__ == "__main__":
main()
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Parallax Layers — Outro</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
margin: 0;
overflow: hidden;
background: transparent;
font-family: "Instrument Serif", serif;
}
#parallax-outro {
pointer-events: none;
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: transparent;
}
.po-layer {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
transform: translateZ(0);
}
.po-safe {
position: absolute;
top: 30px;
left: 0;
width: 1920px;
height: 520px;
}
.po-block {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
opacity: 0;
will-change: transform, opacity;
backface-visibility: hidden;
}
.po-line {
display: flex;
align-items: baseline;
justify-content: center;
gap: 14px;
line-height: 1.1;
white-space: nowrap;
transform: scaleY(2.8);
transform-origin: 50% 0%;
}
.po-word {
display: inline-block;
font-family: "Instrument Serif", serif;
font-size: 220px;
font-weight: 400;
line-height: 0.9;
color: #ff2f36;
-webkit-text-stroke: 5px #ff2f36;
text-shadow:
0 0 36px rgba(0, 0, 0, 0.96),
0 0 74px rgba(0, 0, 0, 0.86),
3px 8px 12px rgba(28, 0, 3, 0.95);
will-change: transform, opacity;
backface-visibility: hidden;
}
</style>
</head>
<body>
<div
id="parallax-outro"
data-composition-id="caption-parallax-outro"
data-timeline-locked
data-start="0"
data-duration="14.5"
data-fps="30"
data-width="1920"
data-height="1080"
>
<div class="po-layer" aria-hidden="true">
<div id="po-stage" class="po-safe"></div>
</div>
</div>
<script>
var DURATION = 14.5;
var BEHIND_WIDTH = 1800;
var _fitCanvas = document.createElement("canvas");
var _fitCtx = _fitCanvas.getContext("2d");
function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) {
var size = baseFontSize;
var minSize = Math.floor(baseFontSize * 0.45);
while (size > minSize) {
_fitCtx.font = fontWeight + " " + size + "px " + fontFamily;
if (_fitCtx.measureText(text).width <= maxWidth) return size;
size -= 2;
}
return minSize;
}
// Outro behind-subject beats.
var W = [
{ text: "USE", start: 1.9 },
{ text: "CASES", start: 2.5 },
{ text: "REALLY", start: 8.3 },
{ text: "SOON", start: 9.0 },
{ text: "SEE", start: 11.8 },
{ text: "YOU", start: 12.3 },
{ text: "SOON", start: 12.9 },
];
var BLOCKS = [
{ idx: [0, 1] },
{ idx: [2, 3] },
{ idx: [4, 5, 6] },
];
var stage = document.getElementById("po-stage");
BLOCKS.forEach(function (block, bi) {
var bEl = document.createElement("div");
bEl.className = "po-block";
bEl.id = "pob" + bi;
var text = block.idx
.map(function (i) {
return W[i].text;
})
.join(" ");
var size = fitFontSize(text, 230, "400", "Instrument Serif", BEHIND_WIDTH);
var line = document.createElement("div");
line.className = "po-line";
block.idx.forEach(function (i, wi) {
var span = document.createElement("span");
span.className = "po-word";
span.id = "pob" + bi + "w" + wi;
span.textContent = W[i].text;
span.style.fontSize = size + "px";
line.appendChild(span);
});
bEl.appendChild(line);
stage.appendChild(bEl);
});
var allBlocks = BLOCKS.map(function (_, i) {
return document.getElementById("pob" + i);
});
function applyScales() {
BLOCKS.forEach(function (block, bi) {
var line = allBlocks[bi].querySelector(".po-line");
if (line) {
var natW = line.scrollWidth;
if (natW > 0) {
line.style.transform = "scaleX(" + BEHIND_WIDTH / natW + ") scaleY(2.8)";
}
}
});
}
applyScales();
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
BLOCKS.forEach(function (block, bi) {
var start = W[block.idx[0]].start;
var nextStart = bi < BLOCKS.length - 1 ? W[BLOCKS[bi + 1].idx[0]].start : DURATION;
var bEl = allBlocks[bi];
allBlocks.forEach(function (other, oi) {
if (oi !== bi) tl.set(other, { opacity: 0 }, start);
});
tl.set(bEl, { opacity: 1 }, start);
block.idx.forEach(function (i, wi) {
var wordEl = document.getElementById("pob" + bi + "w" + wi);
tl.set(wordEl, { opacity: 0, y: 28 }, start);
tl.to(wordEl, { opacity: 1, y: 0, duration: 0.14, ease: "power3.out" }, W[i].start);
});
tl.set(bEl, { opacity: 0 }, nextStart);
});
tl.to({}, { duration: DURATION }, 0);
window.__timelines["caption-parallax-outro"] = tl;
document.fonts.ready.then(applyScales);
</script>
</body>
</html>
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Parallax Outro — Hebrew</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:wght@900&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
margin: 0;
overflow: hidden;
background: transparent;
font-family: "Rubik", sans-serif;
}
#parallax-outro {
pointer-events: none;
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: transparent;
}
.po-layer {
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
transform: translateZ(0);
}
.po-safe {
position: absolute;
top: 30px;
left: 0;
width: 1920px;
height: 520px;
}
.po-block {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
opacity: 0;
will-change: transform, opacity;
backface-visibility: hidden;
}
.po-line {
display: flex;
direction: rtl;
align-items: baseline;
justify-content: center;
gap: 20px;
line-height: 1.1;
white-space: nowrap;
transform: scaleY(2.6);
transform-origin: 50% 0%;
}
.po-word {
display: inline-block;
font-family: "Rubik", sans-serif;
font-weight: 900;
font-size: 220px;
line-height: 0.9;
color: #ff2f36;
-webkit-text-stroke: 5px #ff2f36;
text-shadow:
0 0 36px rgba(0, 0, 0, 0.96),
0 0 74px rgba(0, 0, 0, 0.86),
3px 8px 12px rgba(28, 0, 3, 0.95);
will-change: transform, opacity;
backface-visibility: hidden;
}
</style>
</head>
<body>
<div
id="parallax-outro"
data-composition-id="caption-parallax-outro"
data-timeline-locked
data-start="0"
data-duration="7.0"
data-fps="30"
data-width="1920"
data-height="1080"
>
<div class="po-layer" aria-hidden="true">
<div id="po-stage" class="po-safe"></div>
</div>
</div>
<script>
var DURATION = 7.0;
var BEHIND_WIDTH = 1700;
var _fitCanvas = document.createElement("canvas");
var _fitCtx = _fitCanvas.getContext("2d");
function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) {
var size = baseFontSize;
var minSize = Math.floor(baseFontSize * 0.45);
while (size > minSize) {
_fitCtx.font = fontWeight + " " + size + "px " + fontFamily;
if (_fitCtx.measureText(text).width <= maxWidth) return size;
size -= 2;
}
return minSize;
}
// Outro behind-subject beats — Hebrew sign-off keywords.
var W = [
{ text: "שבוע", start: 0.4 },
{ text: "טוב", start: 0.9 },
{ text: "נתראה", start: 3.0 },
{ text: "תגיבו!", start: 5.0 },
];
var BLOCKS = [
{ idx: [0, 1] }, // שבוע טוב
{ idx: [2] }, // נתראה
{ idx: [3] }, // תגיבו!
];
var stage = document.getElementById("po-stage");
BLOCKS.forEach(function (block, bi) {
var bEl = document.createElement("div");
bEl.className = "po-block";
bEl.id = "pob" + bi;
var text = block.idx.map(function (i) { return W[i].text; }).join(" ");
var size = fitFontSize(text, 230, "900", "Rubik", BEHIND_WIDTH);
var line = document.createElement("div");
line.className = "po-line";
block.idx.forEach(function (i, wi) {
var span = document.createElement("span");
span.className = "po-word";
span.id = "pob" + bi + "w" + wi;
span.textContent = W[i].text;
span.style.fontSize = size + "px";
line.appendChild(span);
});
bEl.appendChild(line);
stage.appendChild(bEl);
});
var allBlocks = BLOCKS.map(function (_, i) { return document.getElementById("pob" + i); });
function applyScales() {
BLOCKS.forEach(function (block, bi) {
var line = allBlocks[bi].querySelector(".po-line");
if (line) {
var natW = line.scrollWidth;
if (natW > 0) {
line.style.transform = "scaleX(" + BEHIND_WIDTH / natW + ") scaleY(2.6)";
}
}
});
}
applyScales();
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
BLOCKS.forEach(function (block, bi) {
var start = W[block.idx[0]].start;
var nextStart = bi < BLOCKS.length - 1 ? W[BLOCKS[bi + 1].idx[0]].start : DURATION;
var bEl = allBlocks[bi];
allBlocks.forEach(function (other, oi) {
if (oi !== bi) tl.set(other, { opacity: 0 }, start);
});
tl.set(bEl, { opacity: 1 }, start);
block.idx.forEach(function (i, wi) {
var wordEl = document.getElementById("pob" + bi + "w" + wi);
tl.set(wordEl, { opacity: 0, y: 28 }, start);
tl.to(wordEl, { opacity: 1, y: 0, duration: 0.14, ease: "power3.out" }, W[i].start);
});
tl.set(bEl, { opacity: 0 }, nextStart);
});
tl.to({}, { duration: DURATION }, 0);
window.__timelines["caption-parallax-outro"] = tl;
document.fonts.ready.then(applyScales);
</script>
</body>
</html>
Known Hebrew Whisper Mishears (large-v3)
Seed your generator's CORRECTIONS dict with these. Keys are the wrong token that Whisper outputs; values are the correct Hebrew. Apply by exact-match on the stripped token (preserving trailing punctuation).
CORRECTIONS_HE = {
# Claude (the AI) — comes out as "cloud" cognate
"קלוט": "קלוד",
"לקלוט": "לקלוד",
"מקלוט": "מקלוד",
# other product / loanwords
"אישות": "שוט", # cinematic SHOT
"מודי": "דפי", # landing PAGES (not "moods")
"מהמדהים": "המהמם", # "the stunning"
"המאמם": "המהמם",
"התירוף": "הטירוף", # "the madness"
"מהתחלס": "מהתחלה", # from the start
"החתונה": "תחתונה", # שורה תחתונה — bottom line
# subject names / pronouns gone wrong
"הרגע": "האריה", # context: Marcus THE LION
# routine spelling slips
"מזגיר": "מזכיר",
"אשמחים": "אשמח",
"ערב": "ערך", # value (not evening)
}Notes
ערבlegitimately means "evening" — only auto-correct if the surrounding context is
clearly about value (e.g. "אם קיבלתם ערך" at the end of a sign-off). If in doubt, leave it for the user to fix in the review step.
קלוטand variants — Whisper consistently mishears Claude. Force-replace.- Names of channels, animals, places (Hope, Marcus, Midbarium) — usually correct, but
spot-check in review.
- For English-mixed Hebrew (the speaker says "Claude Code" in English mid-sentence),
Whisper may transliterate awkwardly. Replace the transliteration with the English term in Latin letters: קלוד קוד → Claude Code for cleaner captions.
How to add new corrections
When you spot a new repeated mishear during transcript review: 1. Add it to the project's gen_body.py CORRECTIONS dict (one-off). 2. If it's a generally useful correction (brand name, common Hebrew vocab) — also append it to this file so the next project starts with it.
# Hebrew body caption generator — VERTICAL (1080x1920).
# Generates compositions/components/caption-body.html from transcript.json.
# Supports 13 pill-rendered styles + 2 external (kinetic-slam, parallax-layers).
#
# Per-segment style overrides:
# caption_styles.json {"assignments": {"<seg_idx>": {"style": "<id>"}}}
# Styles routed to EXTERNAL_STYLES are skipped here and emitted in
# caption_external.json for the agent to wire as dedicated sub-comps.
import json, os, re, io
BODY_START = 0.0
BODY_END_GUESS = 179.5
RUN_LEN = 3
MAX_WORDS = 4
# Style id (picker) -> internal short code (this generator)
STYLE_SHORT = {
"editorial-emphasis": "ed",
"matrix-decode": "mx",
"typewriter": "tw",
"neon-glow": "ng",
"split-reveal": "sr",
"mask-wipe": "mw",
"marquee-rail": "mr",
"stamp-impact": "si",
"liquid-fill": "lf",
"glitch-rgb": "gr",
"soft-fade": "sf",
"bold-underline": "bu",
"highlight-marker": "hm",
}
# 2 styles take over the whole frame, can't sit in the pill — host wires them.
EXTERNAL_STYLES = {"kinetic-slam", "parallax-layers"}
CORRECTIONS = {
"קלוט": "קלוד", "לקלוט": "לקלוד", "מקלוט": "מקלוד",
"המאמם": "המהמם", "התירוף": "הטירוף", "מהתחלס": "מהתחלה",
"מזגיר": "מזכיר", "אשמחים": "אשמח", "ערב": "ערך",
"אישות": "שוט", "מהמדהים": "המהמם", "החתונה": "תחתונה",
"הרגע": "האריה", "מודי": "דפי",
}
STOP = set(
"של את על אם כי לא מה זה יש אין גם רק אני אתה הוא היא הם הן אנחנו ואז אבל או הזה הזאת אנו כך איך למה איפה מתי שם פה כאן עוד היה היא הייתי להיות יהיה תהיה ה ש ל ב מ ו כ".split()
)
def correct(tok):
m = re.match(r"^(\S+?)([.,!?]*)$", tok)
core, tail = (m.group(1), m.group(2)) if m else (tok, "")
if core in CORRECTIONS:
core = CORRECTIONS[core]
return core + tail
def load_words():
data = json.load(open("transcript.json", encoding="utf-8"))
words = []
for si, seg in enumerate(data):
for w in seg["words"]:
t = correct(w["word"].strip())
if not t:
continue
words.append({"t": t, "s": w["start"], "e": w["end"], "seg": si})
return [w for w in words if BODY_START <= w["s"] < BODY_END_GUESS]
def load_style_overrides():
if not os.path.exists("caption_styles.json"):
return {}
try:
doc = json.load(open("caption_styles.json", encoding="utf-8"))
except Exception:
return {}
out = {}
for k, v in (doc.get("assignments") or {}).items():
try:
out[int(k)] = (v or {}).get("style")
except (ValueError, TypeError):
continue
return {k: v for k, v in out.items() if v}
STYLE_OVERRIDES = load_style_overrides()
def group_words(words):
groups, cur = [], []
for i, w in enumerate(words):
cur.append(w)
nxt = words[i + 1] if i + 1 < len(words) else None
gap = (nxt["s"] - w["e"]) if nxt else 99
seg_change = bool(nxt and nxt["seg"] != w["seg"])
ends_sentence = bool(re.search(r"[.?!]$", w["t"]))
if ends_sentence or seg_change or len(cur) >= MAX_WORDS or gap > 0.34 or nxt is None:
groups.append(cur)
cur = []
merged = []
for g in groups:
if len(g) == 1 and merged and len(merged[-1]) < 5:
merged[-1].extend(g)
else:
merged.append(g)
return merged
def emph_index(g):
best, bi = -1, 0
for i, w in enumerate(g):
core = re.sub(r"[^-\w]", "", w["t"])
score = len(core) + (3 if core not in STOP else 0)
if score > best:
best, bi = score, i
return bi
def build():
words = load_words()
if not words:
raise SystemExit("no words in body range — check transcript")
groups = group_words(words)
data, external = [], []
for gi, g in enumerate(groups):
default_short = "ed" if (gi // RUN_LEN) % 2 == 0 else "mx"
seg_idx = g[0]["seg"]
override = STYLE_OVERRIDES.get(seg_idx)
if override in EXTERNAL_STYLES:
external.append({
"seg": seg_idx, "style": override,
"start": round(g[0]["s"] - BODY_START - 0.05, 3),
"end": round(g[-1]["e"] - BODY_START, 3),
"text": " ".join(w["t"] for w in g),
})
continue
st = STYLE_SHORT.get(override, default_short)
ei = emph_index(g) if st == "ed" else -1
s = max(0.0, round(g[0]["s"] - BODY_START - 0.05, 3))
data.append({
"s": s,
"raw_e": round(g[-1]["e"] - BODY_START, 3),
"st": st,
"ovr": override or None,
"words": [{"t": w["t"], "emph": (i == ei)} for i, w in enumerate(g)],
})
for i, d in enumerate(data):
d["e"] = data[i + 1]["s"] if i + 1 < len(data) else round(d["raw_e"] + 0.6, 3)
del d["raw_e"]
duration = round(data[-1]["e"] + 0.15, 2)
return data, duration, external
DATA, DURATION, EXTERNAL_GROUPS = build()
counts = {}
for d in DATA:
counts[d["st"]] = counts.get(d["st"], 0) + 1
print(f"{len(DATA)} body groups, styles: {counts}, duration {DURATION}s")
if EXTERNAL_GROUPS:
with open("caption_external.json", "w", encoding="utf-8") as f:
json.dump({"groups": EXTERNAL_GROUPS}, f, ensure_ascii=False, indent=2)
print(f"{len(EXTERNAL_GROUPS)} external groups -> caption_external.json")
DATA_JSON = json.dumps(DATA, ensure_ascii=False, separators=(",", ":"))
TEMPLATE = r"""<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1080, height=1920" />
<title>Body Captions — Hebrew (Vertical)</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:wght@500;700;800;900&family=JetBrains+Mono:wght@700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { width: 1080px; height: 1920px; margin: 0; overflow: hidden; background: transparent; }
#caption-body { position: relative; width: 1080px; height: 1920px; overflow: hidden; background: transparent; pointer-events: none; }
#cb-stage { position: absolute; inset: 0; }
.cg {
position: absolute; left: 540px; bottom: 340px;
transform: translateX(-50%); transform-origin: 50% 100%;
opacity: 0; will-change: transform, opacity;
}
/* base pill (liquid-glass) */
.pill {
position: relative; direction: rtl;
display: flex; flex-wrap: nowrap; align-items: baseline; justify-content: center;
gap: 0 18px; max-width: 980px; white-space: nowrap;
background: linear-gradient(168deg, rgba(52,54,78,0.78) 0%, rgba(18,19,30,0.83) 54%, rgba(9,10,17,0.87) 100%);
border: 1.6px solid rgba(255,255,255,0.22);
border-radius: 30px; padding: 20px 46px;
box-shadow: 0 30px 72px rgba(0,0,0,0.55), inset 0 2px 0 rgba(255,255,255,0.42), inset 0 -18px 36px rgba(0,0,0,0.46);
overflow: hidden;
}
.pill::before { content:""; position:absolute; left:0; right:0; top:0; height:52%;
background: linear-gradient(180deg, rgba(255,255,255,0.24), rgba(255,255,255,0)); pointer-events:none; }
.pill::after { content:""; position:absolute; inset:0; border-radius:30px;
box-shadow: inset 0 0 26px rgba(255,255,255,0.07); pointer-events:none; }
/* style: matrix */
.pill.mx { border-color: rgba(0,255,120,0.42);
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 54px rgba(0,255,90,0.22),
inset 0 2px 0 rgba(170,255,205,0.42), inset 0 -18px 36px rgba(0,0,0,0.46); }
/* style: neon-glow */
.pill.ng { border-color: rgba(255,61,166,0.6);
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 60px rgba(255,61,166,0.55), 0 0 120px rgba(255,61,166,0.35),
inset 0 2px 0 rgba(255,200,230,0.55), inset 0 -18px 36px rgba(0,0,0,0.5); }
/* style: marquee-rail (wide pill that slides) */
.pill.mr { border-color: rgba(255,210,74,0.55); border-radius: 12px; }
/* style: stamp-impact */
.pill.si { border-color: rgba(255,90,140,0.55); border-radius: 0;
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 0 4px rgba(255,90,140,0.18), inset 0 2px 0 rgba(255,200,210,0.4); }
/* style: glitch-rgb */
.pill.gr { border-color: rgba(255,255,255,0.35); }
/* style: soft-fade */
.pill.sf { border-color: rgba(255,255,255,0.14);
background: linear-gradient(168deg, rgba(20,22,32,0.55), rgba(8,9,16,0.65)); }
/* style: highlight-marker — yellow stroke behind text */
.pill.hm { border-color: rgba(255,210,74,0.32); }
/* style: bold-underline */
.pill.bu { border-color: rgba(255,255,255,0.18); }
/* style: liquid-fill */
.pill.lf { border-color: rgba(255,61,166,0.45); }
/* style: split-reveal */
.pill.sr { border-color: rgba(255,255,255,0.3); }
/* style: mask-wipe */
.pill.mw { border-color: rgba(0,200,255,0.45);
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 48px rgba(0,200,255,0.25),
inset 0 2px 0 rgba(180,235,255,0.45), inset 0 -18px 36px rgba(0,0,0,0.46); }
/* style: typewriter */
.pill.tw { border-color: rgba(255,255,255,0.28); }
/* word base */
.w { display: inline-block; opacity: 0; line-height: 1.05; font-family: "Rubik", sans-serif; }
/* editorial */
.w-ed { font-weight: 700; font-size: 72px; color: #f5f0d0; }
.w-ed-emph { font-weight: 900; font-size: 92px; color: #ffffff;
text-shadow: 0 0 24px rgba(255,255,255,0.25); }
/* matrix */
.w-mx { font-weight: 800; font-size: 64px; color: #00ff41; letter-spacing: 0.01em;
text-shadow: 0 0 22px rgba(0,255,65,0.5); font-family: "JetBrains Mono", monospace; }
/* typewriter */
.w-tw { font-weight: 700; font-size: 72px; color: #f5f0d0;
font-family: "JetBrains Mono", monospace; letter-spacing: 0.04em; }
.w-tw::after { content: "_"; color: #ffd24a; margin-right: 2px;
animation: tw-caret 0.6s steps(2,end) infinite; }
@keyframes tw-caret { 0%,49%{opacity:1} 50%,100%{opacity:0} }
/* neon-glow */
.w-ng { font-weight: 900; font-size: 80px; color: #fff;
text-shadow: 0 0 6px #ff3da6, 0 0 16px #ff3da6, 0 0 38px rgba(255,61,166,0.85);
animation: ng-pulse 1.4s ease-in-out infinite alternate; }
@keyframes ng-pulse { from{filter:brightness(1)} to{filter:brightness(1.35)} }
/* split-reveal — letters split top/bottom */
.w-sr { font-weight: 900; font-size: 78px; color: #fff; }
/* mask-wipe */
.w-mw { font-weight: 800; font-size: 76px; color: #e6f6ff;
text-shadow: 0 0 18px rgba(0,200,255,0.65); }
/* marquee-rail */
.w-mr { font-weight: 800; font-size: 70px; color: #ffd24a;
text-shadow: 0 0 14px rgba(255,210,74,0.55); letter-spacing: 0.02em; }
/* stamp-impact */
.w-si { font-weight: 900; font-size: 92px; color: #fff;
text-shadow: 3px 0 0 rgba(255,61,166,0.85), -3px 0 0 rgba(0,200,255,0.8); }
/* liquid-fill */
.w-lf { font-weight: 900; font-size: 78px;
background-image: linear-gradient(180deg, #ffffff 0%, #ffd24a 45%, #ff3da6 100%);
background-size: 100% 220%; background-position: 0 0;
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; color: transparent; }
/* glitch-rgb */
.w-gr { font-weight: 900; font-size: 78px; color: #fff;
text-shadow: 2px 0 0 #ff3da6, -2px 0 0 #00e5ff; }
/* soft-fade */
.w-sf { font-weight: 600; font-size: 64px; color: #f5f0d0; letter-spacing: 0.01em; }
/* bold-underline */
.w-bu { font-weight: 900; font-size: 76px; color: #fff;
position: relative; padding-bottom: 6px; }
.w-bu::after {
content:""; position:absolute; left:0; right:100%; bottom:0; height:6px;
background: linear-gradient(90deg, #ff3da6, #ffd24a); transition: right 0.18s ease;
}
.w-bu.lit::after { right: 0; }
/* highlight-marker — yellow brush */
.w-hm { font-weight: 800; font-size: 74px; color: #181820; position: relative; z-index: 1;
padding: 0 4px; }
.w-hm::before {
content:""; position:absolute; left:0; right:100%; top: 18%; bottom: 10%;
background: #ffd24a; transform: skewX(-8deg); z-index: -1;
transition: right 0.22s ease;
}
.w-hm.lit::before { right: 0; }
.sc, .rl { display: none; }
</style>
</head>
<body>
<div
id="caption-body"
data-composition-id="caption-body"
data-timeline-locked
data-start="0"
data-duration="__DURATION__"
data-fps="30"
data-width="1080"
data-height="1920"
>
<div id="cb-stage"></div>
</div>
<script>
(function () {
window.__timelines = window.__timelines || {};
function mulberry32(a){return function(){a|=0;a=(a+0x6d2b79f5)|0;var t=Math.imul(a^(a>>>15),1|a);t=(t+Math.imul(t^(t>>>7),61|t))^t;return((t^(t>>>14))>>>0)/4294967296;};}
var GLYPH="אבגדהוזחטיכלמנסעפצקרשתאבגדהוזחטיכ";
function scr(seed,n){var r=mulberry32(seed),s="";for(var i=0;i<n;i++)s+=GLYPH[Math.floor(r()*GLYPH.length)];return s;}
function fit(text, weight, max){var c=fit._c||(fit._c=document.createElement("canvas").getContext("2d"));var size=60,min=30;while(size>min){c.font=weight+" "+size+"px Rubik";if(c.measureText(text).width<=max)break;size-=2;}return size;}
var DATA = __DATA__;
var stage = document.getElementById("cb-stage");
var tl = gsap.timeline({ paused: true });
function buildLetters(text) {
var frag = document.createDocumentFragment();
for (var i = 0; i < text.length; i++) {
var s = document.createElement("span");
s.className = "ch";
s.style.display = "inline-block";
s.style.opacity = "0";
s.textContent = text[i];
frag.appendChild(s);
}
return frag;
}
DATA.forEach(function (g, gi) {
var cg = document.createElement("div");
cg.className = "cg"; cg.id = "cg-" + gi;
var pill = document.createElement("div");
pill.className = "pill " + g.st;
var joined = g.words.map(function(w){return w.t;}).join(" ");
var weight = (g.st === "mx" || g.st === "tw") ? "800" : (g.st === "sf" ? "600" : "700");
var fs = fit(joined, weight, 900);
g.words.forEach(function (w, wi) {
var sp = document.createElement("span");
sp.id = "w-" + gi + "-" + wi;
var cls = "w";
if (g.st === "ed") {
cls += " " + (w.emph ? "w-ed-emph" : "w-ed");
sp.style.fontSize = (w.emph ? Math.round(fs * 1.26) : fs) + "px";
sp.textContent = w.t;
} else if (g.st === "mx") {
cls += " w-mx"; sp.style.fontSize = fs + "px";
var letters = w.t.replace(/[^-]/g, "");
var n = Math.max(2, Math.min(7, letters.length || w.t.length));
var rl = document.createElement("span"); rl.className = "rl"; rl.id = "rl-"+gi+"-"+wi; rl.textContent = w.t;
var s0 = document.createElement("span"); s0.className = "sc"; s0.id = "s0-"+gi+"-"+wi; s0.textContent = scr(gi*97+wi, n);
var s1 = document.createElement("span"); s1.className = "sc"; s1.id = "s1-"+gi+"-"+wi; s1.textContent = scr(gi*97+wi+5000, n);
sp.appendChild(rl); sp.appendChild(s0); sp.appendChild(s1);
} else if (g.st === "tw") {
cls += " w-tw"; sp.style.fontSize = fs + "px";
sp.appendChild(buildLetters(w.t));
} else {
cls += " w-" + g.st;
sp.style.fontSize = fs + "px";
sp.textContent = w.t;
}
sp.className = cls;
pill.appendChild(sp);
});
cg.appendChild(pill); stage.appendChild(cg);
});
DATA.forEach(function (g, gi) {
var cg = document.getElementById("cg-" + gi);
var pill = cg.firstChild;
// Group enter / exit (style-aware)
var inDur = 0.46, outDur = 0.18, outAt = g.e;
if (g.st === "sf") { inDur = 0.7; outDur = 0.45; }
if (g.st === "si") { inDur = 0.28; outDur = 0.14; }
if (g.st === "mr") { inDur = 0.55; outDur = 0.25; }
if (g.st === "mw") { inDur = 0.6; outDur = 0.2; }
if (g.st === "sr") { inDur = 0.5; outDur = 0.2; }
var enterFrom = { opacity: 0, y: 32, scaleX: 1.16, scaleY: 0.72 };
var enterTo = { opacity: 1, y: 0, scaleX: 1, scaleY: 1, duration: inDur, ease: "elastic.out(1, 0.78)" };
if (g.st === "si") {
enterFrom = { opacity: 0, scaleX: 2.4, scaleY: 2.4, rotation: -4 };
enterTo = { opacity: 1, scaleX: 1, scaleY: 1, rotation: 0, duration: inDur, ease: "expo.out" };
} else if (g.st === "mr") {
enterFrom = { opacity: 0, x: 300, scaleX: 1.05 };
enterTo = { opacity: 1, x: 0, scaleX: 1, duration: inDur, ease: "power3.out" };
} else if (g.st === "sr") {
enterFrom = { opacity: 0, scaleY: 0.02 };
enterTo = { opacity: 1, scaleY: 1, duration: inDur, ease: "expo.out" };
} else if (g.st === "mw") {
enterFrom = { opacity: 1, clipPath: "polygon(0 0, 0 0, 0 100%, 0 100%)" };
enterTo = { opacity: 1, clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)", duration: inDur, ease: "power3.inOut" };
} else if (g.st === "sf") {
enterFrom = { opacity: 0, scale: 0.96 };
enterTo = { opacity: 1, scale: 1, duration: inDur, ease: "sine.out" };
}
tl.fromTo(cg, enterFrom, enterTo, g.s);
// Per-word reveal
g.words.forEach(function (w, wi) {
var el = document.getElementById("w-" + gi + "-" + wi);
var wt = g.s + 0.05 + wi * 0.07;
if (g.st === "mx") {
var rl = document.getElementById("rl-"+gi+"-"+wi);
var s0 = document.getElementById("s0-"+gi+"-"+wi);
var s1 = document.getElementById("s1-"+gi+"-"+wi);
tl.set(el, { opacity: 1 }, wt);
tl.set(s0, { display: "inline" }, wt);
tl.set(s0, { display: "none" }, wt + 0.09);
tl.set(s1, { display: "inline" }, wt + 0.09);
tl.set(s1, { display: "none" }, wt + 0.18);
tl.set(rl, { display: "inline" }, wt + 0.18);
} else if (g.st === "tw") {
tl.set(el, { opacity: 1 }, wt);
var chs = el.querySelectorAll(".ch");
chs.forEach(function (ch, ci) {
tl.set(ch, { opacity: 1 }, wt + ci * 0.045);
});
} else if (g.st === "lf") {
tl.set(el, { opacity: 1 }, wt);
tl.fromTo(el, { backgroundPosition: "0 100%" }, { backgroundPosition: "0 0", duration: 0.32, ease: "power2.out" }, wt);
} else if (g.st === "bu" || g.st === "hm") {
tl.fromTo(el, { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.18, ease: "power2.out" }, wt);
tl.call(function (e) { e.classList.add("lit"); }, [el], wt + 0.05);
} else if (g.st === "gr") {
tl.fromTo(el, { opacity: 0, x: -10 }, { opacity: 1, x: 0, duration: 0.18, ease: "power2.out" }, wt);
tl.to(el, { x: 4, duration: 0.04, yoyo: true, repeat: 3 }, wt + 0.02);
} else if (g.st === "si") {
tl.fromTo(el, { opacity: 0, scale: 1.8 }, { opacity: 1, scale: 1, duration: 0.16, ease: "power4.out" }, wt);
} else if (g.st === "mr") {
tl.fromTo(el, { opacity: 0, x: -20 }, { opacity: 1, x: 0, duration: 0.22, ease: "power2.out" }, wt);
} else if (g.st === "sf") {
tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.35, ease: "sine.out" }, wt);
} else if (g.st === "sr") {
tl.fromTo(el, { opacity: 0, y: -18 }, { opacity: 1, y: 0, duration: 0.24, ease: "power3.out" }, wt);
} else if (g.st === "mw") {
tl.set(el, { opacity: 1 }, wt);
} else if (g.st === "ng") {
tl.fromTo(el, { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1, duration: 0.28, ease: "back.out(1.6)" }, wt);
} else {
tl.fromTo(el, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.22, ease: "power2.out" }, wt);
}
});
// Exit
if (g.st === "mw") {
tl.to(cg, { opacity: 0, clipPath: "polygon(100% 0, 100% 0, 100% 100%, 100% 100%)", duration: outDur, ease: "power3.in" }, outAt - outDur);
} else if (g.st === "mr") {
tl.to(cg, { opacity: 0, x: -300, duration: outDur, ease: "power2.in" }, outAt - outDur);
} else if (g.st === "sf") {
tl.to(cg, { opacity: 0, scale: 1.03, duration: outDur, ease: "sine.in" }, outAt - outDur);
} else if (g.st === "sr") {
tl.to(cg, { opacity: 0, scaleY: 0.02, duration: outDur, ease: "power3.in" }, outAt - outDur);
} else {
tl.to(cg, { opacity: 0, y: -20, scaleY: 0.8, duration: outDur, ease: "power2.in" }, outAt - outDur);
}
tl.set(cg, { opacity: 0, visibility: "hidden" }, outAt);
});
window.__timelines["caption-body"] = tl;
})();
</script>
</body>
</html>
"""
html = TEMPLATE.replace("__DURATION__", str(DURATION)).replace("__DATA__", DATA_JSON)
with io.open("compositions/components/caption-body.html", "w", encoding="utf-8") as f:
f.write(html)
print("wrote compositions/components/caption-body.html")
# Hebrew body caption generator — HORIZONTAL (1920x1080).
# Generates compositions/components/caption-body.html from transcript.json.
# Supports 13 pill-rendered styles + 2 external (kinetic-slam, parallax-layers).
#
# Per-segment style overrides:
# caption_styles.json {"assignments": {"<seg_idx>": {"style": "<id>"}}}
# Styles routed to EXTERNAL_STYLES are skipped here and emitted in
# caption_external.json for the agent to wire as dedicated sub-comps.
import json, os, re, io
BODY_START = 0.0
BODY_END_GUESS = 179.5
RUN_LEN = 3
MAX_WORDS = 4
# Style id (picker) -> internal short code (this generator)
STYLE_SHORT = {
"editorial-emphasis": "ed",
"matrix-decode": "mx",
"typewriter": "tw",
"neon-glow": "ng",
"split-reveal": "sr",
"mask-wipe": "mw",
"marquee-rail": "mr",
"stamp-impact": "si",
"liquid-fill": "lf",
"glitch-rgb": "gr",
"soft-fade": "sf",
"bold-underline": "bu",
"highlight-marker": "hm",
}
# 2 styles take over the whole frame, can't sit in the pill — host wires them.
EXTERNAL_STYLES = {"kinetic-slam", "parallax-layers"}
CORRECTIONS = {
"קלוט": "קלוד", "לקלוט": "לקלוד", "מקלוט": "מקלוד",
"המאמם": "המהמם", "התירוף": "הטירוף", "מהתחלס": "מהתחלה",
"מזגיר": "מזכיר", "אשמחים": "אשמח", "ערב": "ערך",
"אישות": "שוט", "מהמדהים": "המהמם", "החתונה": "תחתונה",
"הרגע": "האריה", "מודי": "דפי",
}
STOP = set(
"של את על אם כי לא מה זה יש אין גם רק אני אתה הוא היא הם הן אנחנו ואז אבל או הזה הזאת אנו כך איך למה איפה מתי שם פה כאן עוד היה היא הייתי להיות יהיה תהיה ה ש ל ב מ ו כ".split()
)
def correct(tok):
m = re.match(r"^(\S+?)([.,!?]*)$", tok)
core, tail = (m.group(1), m.group(2)) if m else (tok, "")
if core in CORRECTIONS:
core = CORRECTIONS[core]
return core + tail
def load_words():
data = json.load(open("transcript.json", encoding="utf-8"))
words = []
for si, seg in enumerate(data):
for w in seg["words"]:
t = correct(w["word"].strip())
if not t:
continue
words.append({"t": t, "s": w["start"], "e": w["end"], "seg": si})
return [w for w in words if BODY_START <= w["s"] < BODY_END_GUESS]
def load_style_overrides():
if not os.path.exists("caption_styles.json"):
return {}
try:
doc = json.load(open("caption_styles.json", encoding="utf-8"))
except Exception:
return {}
out = {}
for k, v in (doc.get("assignments") or {}).items():
try:
out[int(k)] = (v or {}).get("style")
except (ValueError, TypeError):
continue
return {k: v for k, v in out.items() if v}
STYLE_OVERRIDES = load_style_overrides()
def group_words(words):
groups, cur = [], []
for i, w in enumerate(words):
cur.append(w)
nxt = words[i + 1] if i + 1 < len(words) else None
gap = (nxt["s"] - w["e"]) if nxt else 99
seg_change = bool(nxt and nxt["seg"] != w["seg"])
ends_sentence = bool(re.search(r"[.?!]$", w["t"]))
if ends_sentence or seg_change or len(cur) >= MAX_WORDS or gap > 0.34 or nxt is None:
groups.append(cur)
cur = []
merged = []
for g in groups:
if len(g) == 1 and merged and len(merged[-1]) < 5:
merged[-1].extend(g)
else:
merged.append(g)
return merged
def emph_index(g):
best, bi = -1, 0
for i, w in enumerate(g):
core = re.sub(r"[^-\w]", "", w["t"])
score = len(core) + (3 if core not in STOP else 0)
if score > best:
best, bi = score, i
return bi
def build():
words = load_words()
if not words:
raise SystemExit("no words in body range — check transcript")
groups = group_words(words)
data, external = [], []
for gi, g in enumerate(groups):
default_short = "ed" if (gi // RUN_LEN) % 2 == 0 else "mx"
seg_idx = g[0]["seg"]
override = STYLE_OVERRIDES.get(seg_idx)
if override in EXTERNAL_STYLES:
external.append({
"seg": seg_idx, "style": override,
"start": round(g[0]["s"] - BODY_START - 0.05, 3),
"end": round(g[-1]["e"] - BODY_START, 3),
"text": " ".join(w["t"] for w in g),
})
continue
st = STYLE_SHORT.get(override, default_short)
ei = emph_index(g) if st == "ed" else -1
s = max(0.0, round(g[0]["s"] - BODY_START - 0.05, 3))
data.append({
"s": s,
"raw_e": round(g[-1]["e"] - BODY_START, 3),
"st": st,
"ovr": override or None,
"words": [{"t": w["t"], "emph": (i == ei)} for i, w in enumerate(g)],
})
for i, d in enumerate(data):
d["e"] = data[i + 1]["s"] if i + 1 < len(data) else round(d["raw_e"] + 0.6, 3)
del d["raw_e"]
duration = round(data[-1]["e"] + 0.15, 2)
return data, duration, external
DATA, DURATION, EXTERNAL_GROUPS = build()
counts = {}
for d in DATA:
counts[d["st"]] = counts.get(d["st"], 0) + 1
print(f"{len(DATA)} body groups, styles: {counts}, duration {DURATION}s")
if EXTERNAL_GROUPS:
with open("caption_external.json", "w", encoding="utf-8") as f:
json.dump({"groups": EXTERNAL_GROUPS}, f, ensure_ascii=False, indent=2)
print(f"{len(EXTERNAL_GROUPS)} external groups -> caption_external.json")
DATA_JSON = json.dumps(DATA, ensure_ascii=False, separators=(",", ":"))
TEMPLATE = r"""<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Body Captions — Hebrew (Horizontal)</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:wght@500;700;800;900&family=JetBrains+Mono:wght@700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { width: 1920px; height: 1080px; margin: 0; overflow: hidden; background: transparent; }
#caption-body { position: relative; width: 1920px; height: 1080px; overflow: hidden; background: transparent; pointer-events: none; }
#cb-stage { position: absolute; inset: 0; }
.cg {
position: absolute; left: 1180px; bottom: 96px;
transform: translateX(-50%); transform-origin: 50% 100%;
opacity: 0; will-change: transform, opacity;
}
/* base pill (liquid-glass) */
.pill {
position: relative; direction: rtl;
display: flex; flex-wrap: nowrap; align-items: baseline; justify-content: center;
gap: 0 18px; max-width: 1140px; white-space: nowrap;
background: linear-gradient(168deg, rgba(52,54,78,0.78) 0%, rgba(18,19,30,0.83) 54%, rgba(9,10,17,0.87) 100%);
border: 1.6px solid rgba(255,255,255,0.22);
border-radius: 30px; padding: 20px 46px;
box-shadow: 0 30px 72px rgba(0,0,0,0.55), inset 0 2px 0 rgba(255,255,255,0.42), inset 0 -18px 36px rgba(0,0,0,0.46);
overflow: hidden;
}
.pill::before { content:""; position:absolute; left:0; right:0; top:0; height:52%;
background: linear-gradient(180deg, rgba(255,255,255,0.24), rgba(255,255,255,0)); pointer-events:none; }
.pill::after { content:""; position:absolute; inset:0; border-radius:30px;
box-shadow: inset 0 0 26px rgba(255,255,255,0.07); pointer-events:none; }
/* style: matrix */
.pill.mx { border-color: rgba(0,255,120,0.42);
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 54px rgba(0,255,90,0.22),
inset 0 2px 0 rgba(170,255,205,0.42), inset 0 -18px 36px rgba(0,0,0,0.46); }
/* style: neon-glow */
.pill.ng { border-color: rgba(255,61,166,0.6);
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 60px rgba(255,61,166,0.55), 0 0 120px rgba(255,61,166,0.35),
inset 0 2px 0 rgba(255,200,230,0.55), inset 0 -18px 36px rgba(0,0,0,0.5); }
/* style: marquee-rail (wide pill that slides) */
.pill.mr { border-color: rgba(255,210,74,0.55); border-radius: 12px; }
/* style: stamp-impact */
.pill.si { border-color: rgba(255,90,140,0.55); border-radius: 0;
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 0 4px rgba(255,90,140,0.18), inset 0 2px 0 rgba(255,200,210,0.4); }
/* style: glitch-rgb */
.pill.gr { border-color: rgba(255,255,255,0.35); }
/* style: soft-fade */
.pill.sf { border-color: rgba(255,255,255,0.14);
background: linear-gradient(168deg, rgba(20,22,32,0.55), rgba(8,9,16,0.65)); }
/* style: highlight-marker — yellow stroke behind text */
.pill.hm { border-color: rgba(255,210,74,0.32); }
/* style: bold-underline */
.pill.bu { border-color: rgba(255,255,255,0.18); }
/* style: liquid-fill */
.pill.lf { border-color: rgba(255,61,166,0.45); }
/* style: split-reveal */
.pill.sr { border-color: rgba(255,255,255,0.3); }
/* style: mask-wipe */
.pill.mw { border-color: rgba(0,200,255,0.45);
box-shadow: 0 30px 72px rgba(0,0,0,0.55), 0 0 48px rgba(0,200,255,0.25),
inset 0 2px 0 rgba(180,235,255,0.45), inset 0 -18px 36px rgba(0,0,0,0.46); }
/* style: typewriter */
.pill.tw { border-color: rgba(255,255,255,0.28); }
/* word base */
.w { display: inline-block; opacity: 0; line-height: 1.05; font-family: "Rubik", sans-serif; }
/* editorial */
.w-ed { font-weight: 700; font-size: 60px; color: #f5f0d0; }
.w-ed-emph { font-weight: 900; font-size: 76px; color: #ffffff;
text-shadow: 0 0 24px rgba(255,255,255,0.25); }
/* matrix */
.w-mx { font-weight: 800; font-size: 56px; color: #00ff41; letter-spacing: 0.01em;
text-shadow: 0 0 22px rgba(0,255,65,0.5); font-family: "JetBrains Mono", monospace; }
/* typewriter */
.w-tw { font-weight: 700; font-size: 60px; color: #f5f0d0;
font-family: "JetBrains Mono", monospace; letter-spacing: 0.04em; }
.w-tw::after { content: "_"; color: #ffd24a; margin-right: 2px;
animation: tw-caret 0.6s steps(2,end) infinite; }
@keyframes tw-caret { 0%,49%{opacity:1} 50%,100%{opacity:0} }
/* neon-glow */
.w-ng { font-weight: 900; font-size: 68px; color: #fff;
text-shadow: 0 0 6px #ff3da6, 0 0 16px #ff3da6, 0 0 38px rgba(255,61,166,0.85);
animation: ng-pulse 1.4s ease-in-out infinite alternate; }
@keyframes ng-pulse { from{filter:brightness(1)} to{filter:brightness(1.35)} }
/* split-reveal — letters split top/bottom */
.w-sr { font-weight: 900; font-size: 66px; color: #fff; }
/* mask-wipe */
.w-mw { font-weight: 800; font-size: 64px; color: #e6f6ff;
text-shadow: 0 0 18px rgba(0,200,255,0.65); }
/* marquee-rail */
.w-mr { font-weight: 800; font-size: 60px; color: #ffd24a;
text-shadow: 0 0 14px rgba(255,210,74,0.55); letter-spacing: 0.02em; }
/* stamp-impact */
.w-si { font-weight: 900; font-size: 78px; color: #fff;
text-shadow: 3px 0 0 rgba(255,61,166,0.85), -3px 0 0 rgba(0,200,255,0.8); }
/* liquid-fill */
.w-lf { font-weight: 900; font-size: 66px;
background-image: linear-gradient(180deg, #ffffff 0%, #ffd24a 45%, #ff3da6 100%);
background-size: 100% 220%; background-position: 0 0;
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; color: transparent; }
/* glitch-rgb */
.w-gr { font-weight: 900; font-size: 66px; color: #fff;
text-shadow: 2px 0 0 #ff3da6, -2px 0 0 #00e5ff; }
/* soft-fade */
.w-sf { font-weight: 600; font-size: 54px; color: #f5f0d0; letter-spacing: 0.01em; }
/* bold-underline */
.w-bu { font-weight: 900; font-size: 64px; color: #fff;
position: relative; padding-bottom: 6px; }
.w-bu::after {
content:""; position:absolute; left:0; right:100%; bottom:0; height:6px;
background: linear-gradient(90deg, #ff3da6, #ffd24a); transition: right 0.18s ease;
}
.w-bu.lit::after { right: 0; }
/* highlight-marker — yellow brush */
.w-hm { font-weight: 800; font-size: 62px; color: #181820; position: relative; z-index: 1;
padding: 0 4px; }
.w-hm::before {
content:""; position:absolute; left:0; right:100%; top: 18%; bottom: 10%;
background: #ffd24a; transform: skewX(-8deg); z-index: -1;
transition: right 0.22s ease;
}
.w-hm.lit::before { right: 0; }
.sc, .rl { display: none; }
</style>
</head>
<body>
<div
id="caption-body"
data-composition-id="caption-body"
data-timeline-locked
data-start="0"
data-duration="__DURATION__"
data-fps="30"
data-width="1920"
data-height="1080"
>
<div id="cb-stage"></div>
</div>
<script>
(function () {
window.__timelines = window.__timelines || {};
function mulberry32(a){return function(){a|=0;a=(a+0x6d2b79f5)|0;var t=Math.imul(a^(a>>>15),1|a);t=(t+Math.imul(t^(t>>>7),61|t))^t;return((t^(t>>>14))>>>0)/4294967296;};}
var GLYPH="אבגדהוזחטיכלמנסעפצקרשתאבגדהוזחטיכ";
function scr(seed,n){var r=mulberry32(seed),s="";for(var i=0;i<n;i++)s+=GLYPH[Math.floor(r()*GLYPH.length)];return s;}
function fit(text, weight, max){var c=fit._c||(fit._c=document.createElement("canvas").getContext("2d"));var size=60,min=30;while(size>min){c.font=weight+" "+size+"px Rubik";if(c.measureText(text).width<=max)break;size-=2;}return size;}
var DATA = __DATA__;
var stage = document.getElementById("cb-stage");
var tl = gsap.timeline({ paused: true });
function buildLetters(text) {
var frag = document.createDocumentFragment();
for (var i = 0; i < text.length; i++) {
var s = document.createElement("span");
s.className = "ch";
s.style.display = "inline-block";
s.style.opacity = "0";
s.textContent = text[i];
frag.appendChild(s);
}
return frag;
}
DATA.forEach(function (g, gi) {
var cg = document.createElement("div");
cg.className = "cg"; cg.id = "cg-" + gi;
var pill = document.createElement("div");
pill.className = "pill " + g.st;
var joined = g.words.map(function(w){return w.t;}).join(" ");
var weight = (g.st === "mx" || g.st === "tw") ? "800" : (g.st === "sf" ? "600" : "700");
var fs = fit(joined, weight, 1060);
g.words.forEach(function (w, wi) {
var sp = document.createElement("span");
sp.id = "w-" + gi + "-" + wi;
var cls = "w";
if (g.st === "ed") {
cls += " " + (w.emph ? "w-ed-emph" : "w-ed");
sp.style.fontSize = (w.emph ? Math.round(fs * 1.26) : fs) + "px";
sp.textContent = w.t;
} else if (g.st === "mx") {
cls += " w-mx"; sp.style.fontSize = fs + "px";
var letters = w.t.replace(/[^-]/g, "");
var n = Math.max(2, Math.min(7, letters.length || w.t.length));
var rl = document.createElement("span"); rl.className = "rl"; rl.id = "rl-"+gi+"-"+wi; rl.textContent = w.t;
var s0 = document.createElement("span"); s0.className = "sc"; s0.id = "s0-"+gi+"-"+wi; s0.textContent = scr(gi*97+wi, n);
var s1 = document.createElement("span"); s1.className = "sc"; s1.id = "s1-"+gi+"-"+wi; s1.textContent = scr(gi*97+wi+5000, n);
sp.appendChild(rl); sp.appendChild(s0); sp.appendChild(s1);
} else if (g.st === "tw") {
cls += " w-tw"; sp.style.fontSize = fs + "px";
sp.appendChild(buildLetters(w.t));
} else {
cls += " w-" + g.st;
sp.style.fontSize = fs + "px";
sp.textContent = w.t;
}
sp.className = cls;
pill.appendChild(sp);
});
cg.appendChild(pill); stage.appendChild(cg);
});
DATA.forEach(function (g, gi) {
var cg = document.getElementById("cg-" + gi);
var pill = cg.firstChild;
// Group enter / exit (style-aware)
var inDur = 0.46, outDur = 0.18, outAt = g.e;
if (g.st === "sf") { inDur = 0.7; outDur = 0.45; }
if (g.st === "si") { inDur = 0.28; outDur = 0.14; }
if (g.st === "mr") { inDur = 0.55; outDur = 0.25; }
if (g.st === "mw") { inDur = 0.6; outDur = 0.2; }
if (g.st === "sr") { inDur = 0.5; outDur = 0.2; }
var enterFrom = { opacity: 0, y: 32, scaleX: 1.16, scaleY: 0.72 };
var enterTo = { opacity: 1, y: 0, scaleX: 1, scaleY: 1, duration: inDur, ease: "elastic.out(1, 0.78)" };
if (g.st === "si") {
enterFrom = { opacity: 0, scaleX: 2.4, scaleY: 2.4, rotation: -4 };
enterTo = { opacity: 1, scaleX: 1, scaleY: 1, rotation: 0, duration: inDur, ease: "expo.out" };
} else if (g.st === "mr") {
enterFrom = { opacity: 0, x: 300, scaleX: 1.05 };
enterTo = { opacity: 1, x: 0, scaleX: 1, duration: inDur, ease: "power3.out" };
} else if (g.st === "sr") {
enterFrom = { opacity: 0, scaleY: 0.02 };
enterTo = { opacity: 1, scaleY: 1, duration: inDur, ease: "expo.out" };
} else if (g.st === "mw") {
enterFrom = { opacity: 1, clipPath: "polygon(0 0, 0 0, 0 100%, 0 100%)" };
enterTo = { opacity: 1, clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)", duration: inDur, ease: "power3.inOut" };
} else if (g.st === "sf") {
enterFrom = { opacity: 0, scale: 0.96 };
enterTo = { opacity: 1, scale: 1, duration: inDur, ease: "sine.out" };
}
tl.fromTo(cg, enterFrom, enterTo, g.s);
// Per-word reveal
g.words.forEach(function (w, wi) {
var el = document.getElementById("w-" + gi + "-" + wi);
var wt = g.s + 0.05 + wi * 0.07;
if (g.st === "mx") {
var rl = document.getElementById("rl-"+gi+"-"+wi);
var s0 = document.getElementById("s0-"+gi+"-"+wi);
var s1 = document.getElementById("s1-"+gi+"-"+wi);
tl.set(el, { opacity: 1 }, wt);
tl.set(s0, { display: "inline" }, wt);
tl.set(s0, { display: "none" }, wt + 0.09);
tl.set(s1, { display: "inline" }, wt + 0.09);
tl.set(s1, { display: "none" }, wt + 0.18);
tl.set(rl, { display: "inline" }, wt + 0.18);
} else if (g.st === "tw") {
tl.set(el, { opacity: 1 }, wt);
var chs = el.querySelectorAll(".ch");
chs.forEach(function (ch, ci) {
tl.set(ch, { opacity: 1 }, wt + ci * 0.045);
});
} else if (g.st === "lf") {
tl.set(el, { opacity: 1 }, wt);
tl.fromTo(el, { backgroundPosition: "0 100%" }, { backgroundPosition: "0 0", duration: 0.32, ease: "power2.out" }, wt);
} else if (g.st === "bu" || g.st === "hm") {
tl.fromTo(el, { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.18, ease: "power2.out" }, wt);
tl.call(function (e) { e.classList.add("lit"); }, [el], wt + 0.05);
} else if (g.st === "gr") {
tl.fromTo(el, { opacity: 0, x: -10 }, { opacity: 1, x: 0, duration: 0.18, ease: "power2.out" }, wt);
tl.to(el, { x: 4, duration: 0.04, yoyo: true, repeat: 3 }, wt + 0.02);
} else if (g.st === "si") {
tl.fromTo(el, { opacity: 0, scale: 1.8 }, { opacity: 1, scale: 1, duration: 0.16, ease: "power4.out" }, wt);
} else if (g.st === "mr") {
tl.fromTo(el, { opacity: 0, x: -20 }, { opacity: 1, x: 0, duration: 0.22, ease: "power2.out" }, wt);
} else if (g.st === "sf") {
tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.35, ease: "sine.out" }, wt);
} else if (g.st === "sr") {
tl.fromTo(el, { opacity: 0, y: -18 }, { opacity: 1, y: 0, duration: 0.24, ease: "power3.out" }, wt);
} else if (g.st === "mw") {
tl.set(el, { opacity: 1 }, wt);
} else if (g.st === "ng") {
tl.fromTo(el, { opacity: 0, scale: 0.85 }, { opacity: 1, scale: 1, duration: 0.28, ease: "back.out(1.6)" }, wt);
} else {
tl.fromTo(el, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.22, ease: "power2.out" }, wt);
}
});
// Exit
if (g.st === "mw") {
tl.to(cg, { opacity: 0, clipPath: "polygon(100% 0, 100% 0, 100% 100%, 100% 100%)", duration: outDur, ease: "power3.in" }, outAt - outDur);
} else if (g.st === "mr") {
tl.to(cg, { opacity: 0, x: -300, duration: outDur, ease: "power2.in" }, outAt - outDur);
} else if (g.st === "sf") {
tl.to(cg, { opacity: 0, scale: 1.03, duration: outDur, ease: "sine.in" }, outAt - outDur);
} else if (g.st === "sr") {
tl.to(cg, { opacity: 0, scaleY: 0.02, duration: outDur, ease: "power3.in" }, outAt - outDur);
} else {
tl.to(cg, { opacity: 0, y: -20, scaleY: 0.8, duration: outDur, ease: "power2.in" }, outAt - outDur);
}
tl.set(cg, { opacity: 0, visibility: "hidden" }, outAt);
});
window.__timelines["caption-body"] = tl;
})();
</script>
</body>
</html>
"""
html = TEMPLATE.replace("__DURATION__", str(DURATION)).replace("__DATA__", DATA_JSON)
with io.open("compositions/components/caption-body.html", "w", encoding="utf-8") as f:
f.write(html)
print("wrote compositions/components/caption-body.html")
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1080, height=1920" />
<title>Hebrew Captioned · Vertical (TikTok / Reels)</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Inter:wght@400;600;700&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1080px; height: 1920px;
overflow: hidden; background: #05050a;
}
#root {
position: relative; width: 1080px; height: 1920px;
overflow: hidden;
background:
radial-gradient(ellipse 60% 50% at 30% 20%, rgba(255, 61, 166, 0.12), transparent 70%),
radial-gradient(ellipse 60% 50% at 70% 80%, rgba(139, 92, 246, 0.14), transparent 70%),
linear-gradient(180deg, #07070d 0%, #04040a 50%, #07070d 100%);
}
.bg-video { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
.bg-video video {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover;
filter: blur(60px) brightness(0.55) saturate(1.25);
opacity: 0.6;
}
.vignette {
position: absolute; inset: 0; z-index: 2;
background: radial-gradient(ellipse 90% 70% at 50% 50%, rgba(0,0,0,0) 55%, rgba(0,0,0,0.55) 100%);
pointer-events: none;
}
.cam-strip {
position: absolute;
left: 0; top: 656px;
width: 1080px; height: 608px;
z-index: 3; background: #000; overflow: hidden;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.7),
0 -40px 100px rgba(0, 0, 0, 0.7),
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.cam-strip video {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover;
}
.cap { position: absolute; inset: 0; }
.fx { position: absolute; inset: 0; pointer-events: none; }
#prog {
position: absolute; left: 0; right: 0; bottom: 0;
height: 8px; background: rgba(255, 255, 255, 0.08);
}
#prog-fill {
height: 100%; width: 100%;
background: linear-gradient(90deg, #ff3da6 0%, #ffd700 100%);
transform: scaleX(0); transform-origin: left center;
}
.brand {
position: absolute; top: 40px; right: 40px; z-index: 50;
font-family: "Anton", sans-serif;
font-size: 28px; letter-spacing: 0.06em;
color: #ff3da6; text-transform: uppercase;
padding: 8px 16px;
background: rgba(10, 10, 20, 0.55);
border: 1px solid rgba(255, 61, 166, 0.4);
backdrop-filter: blur(6px);
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="178.93"
data-fps="30"
data-width="1080"
data-height="1920"
>
<div class="bg-video">
<video
id="footage-bg"
class="clip"
src="footage.mp4"
muted
playsinline
data-start="0"
data-duration="178.93"
data-track-index="0"
></video>
</div>
<div
class="clip"
id="cap-blobs"
data-composition-id="liquid-blobs"
data-composition-src="compositions/liquid-blobs.html"
data-start="0"
data-duration="178.93"
data-track-index="10"
data-width="1080"
data-height="1920"
style="z-index: 1; mix-blend-mode: screen; opacity: 0.5"
></div>
<div class="vignette"></div>
<div class="cam-strip">
<video
id="footage"
class="clip"
src="footage.mp4"
muted
playsinline
data-start="0"
data-duration="178.93"
data-track-index="1"
></video>
</div>
<div
class="clip cap"
id="cap-body"
data-composition-id="caption-body"
data-composition-src="compositions/components/caption-body.html"
data-start="0"
data-duration="178.93"
data-track-index="3"
data-width="1080"
data-height="1920"
style="z-index: 6"
></div>
<div class="clip fx" id="fx-main" data-start="0" data-duration="178.93" data-track-index="6" style="z-index: 46">
<div id="prog"><div id="prog-fill"></div></div>
</div>
<div class="brand">YUV.AI</div>
<audio
id="a-roll-audio"
class="clip"
src="footage.mp4"
data-start="0"
data-duration="178.93"
data-track-index="7"
data-volume="1"
></audio>
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.from(".bg-video, .cam-strip", { opacity: 0, duration: 0.4, ease: "power2.out" }, 0);
tl.fromTo("#prog-fill", { scaleX: 0 }, { scaleX: 1, duration: 178.93, ease: "none" }, 0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Claude Desktop — Captioned Tutorial</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #05050a;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #05050a;
}
/* footage + subject cut-outs */
.cam {
position: absolute;
inset: 0;
will-change: transform;
}
.cam video,
.cam-out video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.cam-bg {
z-index: 0;
}
.cam-sub {
z-index: 3;
opacity: 0;
}
.cam-out {
position: absolute;
inset: 0;
z-index: 3;
opacity: 0;
}
.cap {
position: absolute;
inset: 0;
}
/* FX */
.fx {
position: absolute;
inset: 0;
pointer-events: none;
}
.grain {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='320'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='320' height='320' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 320px 320px;
opacity: 0.2;
mix-blend-mode: overlay;
}
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse 80% 78% at 50% 44%,
rgba(0, 0, 0, 0) 56%,
rgba(5, 5, 12, 0.62) 100%
);
}
.bracket {
position: absolute;
width: 66px;
height: 66px;
filter: drop-shadow(0 0 7px rgba(255, 61, 166, 0.55));
}
.br-tl {
top: 46px;
left: 46px;
border-top: 3px solid #ff3da6;
border-left: 3px solid #ff3da6;
}
.br-tr {
top: 46px;
right: 46px;
border-top: 3px solid #ff3da6;
border-right: 3px solid #ff3da6;
}
#prog {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.1);
}
#prog-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #ff3da6 0%, #ffd700 100%);
transform: scaleX(0);
transform-origin: left center;
}
#flash {
position: absolute;
inset: 0;
background: #ffffff;
opacity: 0;
}
/* liquid morph wipe */
#lwipe {
position: absolute;
inset: 0;
opacity: 0;
overflow: hidden;
}
#lwipe-g {
position: absolute;
left: 960px;
top: 540px;
width: 0;
height: 0;
filter: blur(40px);
}
.lw {
position: absolute;
width: 540px;
height: 540px;
margin-left: -270px;
margin-top: -270px;
border-radius: 50%;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="167.27"
data-fps="30"
data-width="1920"
data-height="1080"
>
<!-- z0: footage -->
<div class="cam cam-bg">
<video
id="footage"
class="clip"
src="footage.mp4"
muted
playsinline
data-start="0"
data-duration="167.27"
data-track-index="0"
></video>
</div>
<!-- z1: liquid blob background (screen-blended; glows on dark scenes, vanishes on the white UI) -->
<div
class="clip"
id="cap-blobs"
data-composition-id="liquid-blobs"
data-composition-src="compositions/liquid-blobs.html"
data-start="0"
data-duration="167.27"
data-track-index="10"
data-width="1920"
data-height="1080"
style="z-index: 1; mix-blend-mode: screen; opacity: 0.6"
></div>
<!-- z2: parallax captions woven BEHIND the subject -->
<div
class="clip cap"
id="cap-parallax"
data-composition-id="caption-parallax-layers"
data-composition-src="compositions/components/caption-parallax-layers.html"
data-start="5.3"
data-duration="2.40"
data-track-index="1"
data-width="1920"
data-height="1080"
style="z-index: 2"
></div>
<div
class="clip cap"
id="cap-parallax-outro"
data-composition-id="caption-parallax-outro"
data-composition-src="compositions/components/caption-parallax-outro.html"
data-start="146"
data-duration="14.5"
data-track-index="9"
data-width="1920"
data-height="1080"
style="z-index: 2"
></div>
<!-- z3: transparent subject cut-outs -->
<div class="cam cam-sub">
<video
id="cutout"
class="clip"
src="intro_subject.webm"
muted
playsinline
data-start="5.1"
data-media-start="5.1"
data-duration="2.8"
data-track-index="2"
></video>
</div>
<div class="cam-out">
<video
id="cutout-out"
class="clip"
src="outro_subject.webm"
muted
playsinline
data-start="144"
data-media-start="1.0"
data-duration="16.5"
data-track-index="8"
></video>
</div>
<!-- z6: caption styles -->
<div
class="clip cap"
id="cap-kinetic"
data-composition-id="caption-kinetic-slam"
data-composition-src="compositions/components/caption-kinetic-slam.html"
data-start="0"
data-duration="9.0"
data-track-index="3"
data-width="1920"
data-height="1080"
style="z-index: 6"
></div>
<div
class="clip cap"
id="cap-body"
data-composition-id="caption-body"
data-composition-src="compositions/components/caption-body.html"
data-start="9.0"
data-duration="151.84"
data-track-index="3"
data-width="1920"
data-height="1080"
style="z-index: 6"
></div>
<!-- z40: intro-only chrome -->
<div class="clip fx" id="fx-intro" data-start="0" data-duration="9.0" data-track-index="5" style="z-index: 40">
<div class="grain"></div>
<div class="vignette"></div>
<div class="bracket br-tl"></div>
<div class="bracket br-tr"></div>
</div>
<!-- z46: full-duration chrome + liquid transitions -->
<div class="clip fx" id="fx-main" data-start="0" data-duration="167.27" data-track-index="6" style="z-index: 46">
<div id="prog"><div id="prog-fill"></div></div>
<div id="flash"></div>
<div id="lwipe">
<div id="lwipe-g">
<div class="lw" style="left: -340px; top: -180px; background: radial-gradient(circle, #ff3da6 0%, #ff3da6 58%, rgba(255,61,166,0) 82%)"></div>
<div class="lw" style="left: 320px; top: -240px; background: radial-gradient(circle, #8b5cf6 0%, #8b5cf6 58%, rgba(139,92,246,0) 82%)"></div>
<div class="lw" style="left: -260px; top: 260px; background: radial-gradient(circle, #ff5d8f 0%, #ff5d8f 58%, rgba(255,93,143,0) 82%)"></div>
<div class="lw" style="left: 360px; top: 220px; background: radial-gradient(circle, #a855f7 0%, #a855f7 58%, rgba(168,85,247,0) 82%)"></div>
<div class="lw" style="left: 40px; top: -30px; background: radial-gradient(circle, #d12fb0 0%, #d12fb0 58%, rgba(209,47,176,0) 82%)"></div>
<div class="lw" style="left: 70px; top: 320px; background: radial-gradient(circle, #ff3da6 0%, #ff3da6 58%, rgba(255,61,166,0) 82%)"></div>
</div>
</div>
</div>
<audio
id="a-roll-audio"
class="clip"
src="footage.mp4"
data-start="0"
data-duration="167.27"
data-track-index="7"
data-volume="1"
></audio>
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
/* opening: fade up + push-in settle */
tl.from(".cam-bg", { opacity: 0, duration: 0.35, ease: "power2.out" }, 0);
tl.fromTo(".cam", { scale: 1.06 }, { scale: 1.0, duration: 0.8, ease: "power3.out" }, 0);
/* intro camera punches (footage + cut-out move together) */
tl.to(".cam", { scale: 1.045, duration: 0.12, ease: "power2.out" }, 1.3);
tl.to(".cam", { scale: 1.0, duration: 0.5, ease: "power2.inOut" }, 1.42);
tl.to(".cam", { scale: 1.038, duration: 0.12, ease: "power2.out" }, 2.85);
tl.to(".cam", { scale: 1.0, duration: 0.5, ease: "power2.inOut" }, 2.97);
/* intro subject cut-out crossfade */
tl.fromTo(".cam-sub", { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power2.out" }, 5.15);
tl.to(".cam-sub", { opacity: 0, duration: 0.3, ease: "power2.in" }, 7.55);
/* outro subject cut-out crossfade */
tl.fromTo(".cam-out", { opacity: 0 }, { opacity: 1, duration: 0.4, ease: "power2.out" }, 144.3);
tl.to(".cam-out", { opacity: 0, duration: 0.4, ease: "power2.in" }, 159.9);
/* small beat flashes (intro slams) */
function flash(t, peak) {
tl.set("#flash", { opacity: peak }, t);
tl.to("#flash", { opacity: 0, duration: 0.18, ease: "expo.out" }, t);
}
flash(1.3, 0.4);
flash(2.85, 0.4);
/* liquid morph wipes at the section cuts */
function liquidWipe(t) {
tl.set("#lwipe", { opacity: 1 }, t - 0.34);
tl.fromTo(
"#lwipe-g",
{ scale: 0 },
{ scale: 4.6, duration: 0.34, ease: "power2.in", overwrite: "auto" },
t - 0.34,
);
tl.to("#lwipe-g", { scale: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" }, t);
tl.set("#lwipe", { opacity: 0 }, t + 0.42);
}
liquidWipe(8.6); // talking head -> screen recording
liquidWipe(143.8); // screen recording -> outro talking head
liquidWipe(160.7); // outro talking head -> end graphic
/* intro viewfinder brackets */
tl.from(
".bracket",
{ opacity: 0, scale: 0.4, duration: 0.5, ease: "back.out(2)", stagger: 0.1 },
0.2,
);
/* progress bar */
tl.fromTo("#prog-fill", { scaleX: 0 }, { scaleX: 1, duration: 167.27, ease: "none" }, 0);
window.__timelines["main"] = tl;
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Liquid Blobs</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
margin: 0;
overflow: hidden;
background: transparent;
}
#liquid-blobs {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: transparent;
}
.blob-field {
position: absolute;
inset: -260px;
filter: blur(38px);
}
.blob {
position: absolute;
border-radius: 50%;
will-change: transform;
}
</style>
</head>
<body>
<div
id="liquid-blobs"
data-composition-id="liquid-blobs"
data-timeline-locked
data-start="0"
data-duration="167.27"
data-fps="30"
data-width="1920"
data-height="1080"
>
<div class="blob-field">
<div class="blob" id="b0" style="left: 380px; top: 460px; width: 620px; height: 620px; background: radial-gradient(circle, #ff3da6 0%, rgba(255,61,166,0) 70%);"></div>
<div class="blob" id="b1" style="left: 1480px; top: 320px; width: 700px; height: 700px; background: radial-gradient(circle, #8b5cf6 0%, rgba(139,92,246,0) 70%);"></div>
<div class="blob" id="b2" style="left: 940px; top: 740px; width: 560px; height: 560px; background: radial-gradient(circle, #38bdf8 0%, rgba(56,189,248,0) 70%);"></div>
<div class="blob" id="b3" style="left: 440px; top: 900px; width: 540px; height: 540px; background: radial-gradient(circle, #ffc24a 0%, rgba(255,194,74,0) 70%);"></div>
<div class="blob" id="b4" style="left: 1560px; top: 860px; width: 640px; height: 640px; background: radial-gradient(circle, #ff5d8f 0%, rgba(255,93,143,0) 70%);"></div>
<div class="blob" id="b5" style="left: 1040px; top: 140px; width: 580px; height: 580px; background: radial-gradient(circle, #a855f7 0%, rgba(168,85,247,0) 70%);"></div>
</div>
</div>
<script>
(function () {
window.__timelines = window.__timelines || {};
var DUR = 167.27;
var tl = gsap.timeline({ paused: true });
var BLOBS = [
{ id: "#b0", dx: 360, dy: 250, dur: 33, sc: 1.2 },
{ id: "#b1", dx: -300, dy: 290, dur: 38, sc: 0.84 },
{ id: "#b2", dx: 290, dy: -310, dur: 29, sc: 1.22 },
{ id: "#b3", dx: 330, dy: -210, dur: 35, sc: 1.15 },
{ id: "#b4", dx: -350, dy: -270, dur: 31, sc: 0.82 },
{ id: "#b5", dx: -270, dy: 330, dur: 27, sc: 1.18 },
];
BLOBS.forEach(function (b) {
tl.to(
b.id,
{
x: b.dx,
y: b.dy,
duration: b.dur,
repeat: Math.ceil(DUR / b.dur),
yoyo: true,
ease: "sine.inOut",
},
0,
);
var sdur = b.dur * 0.64;
tl.to(
b.id,
{
scale: b.sc,
duration: sdur,
repeat: Math.ceil(DUR / sdur),
yoyo: true,
ease: "sine.inOut",
},
0,
);
});
window.__timelines["liquid-blobs"] = tl;
})();
</script>
</body>
</html>
"""Generate transcript_review.txt — the human-editable transcript.
Reads transcript.json (word-level whisper output) and emits a flat text file
that the user can edit. Each line is one whisper segment with a [mm:ss] prefix.
Usage:
python make_review.py [path/to/transcript.json]
If a `corrections.json` exists in the same directory, it's applied first
(format: {"wrong": "right", ...} keyed on stripped tokens).
"""
import json, os, re, sys
def fmt_time(t):
m, s = divmod(int(t), 60)
return f"{m:02d}:{s:02d}.{int((t - int(t)) * 100):02d}"
def load_corrections(transcript_path):
cdir = os.path.dirname(os.path.abspath(transcript_path)) or "."
cpath = os.path.join(cdir, "corrections.json")
if os.path.exists(cpath):
with open(cpath, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def correct(token, table):
"""Correct a token while preserving leading space and trailing punctuation."""
lead = ""
if token.startswith(" "):
lead = " "
token = token[1:]
m = re.match(r"^(\S+?)([.,!?;:]*)$", token)
core, tail = (m.group(1), m.group(2)) if m else (token, "")
if core in table:
core = table[core]
return lead + core + tail
def main():
tpath = sys.argv[1] if len(sys.argv) > 1 else "transcript.json"
out_path = os.path.join(os.path.dirname(os.path.abspath(tpath)) or ".", "transcript_review.txt")
with open(tpath, "r", encoding="utf-8") as f:
data = json.load(f)
corrections = load_corrections(tpath)
applied = {}
# Apply corrections to every word and rebuild segment text.
for seg in data:
new_words = []
for w in seg["words"]:
original = w["word"]
corrected = correct(original, corrections)
if corrected.strip() != original.strip() and original.strip() in corrections:
k = original.strip()
applied[k] = applied.get(k, 0) + 1
new_words.append({**w, "word": corrected})
seg["words"] = new_words
seg["text"] = "".join(w["word"] for w in new_words).strip()
# Persist the corrected baseline back to transcript.json (so apply_review.py
# can diff against the same starting state the user saw).
with open(tpath, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=1)
# Emit the review file.
lines = []
lines.append("# Transcript Review")
lines.append("# Edit the TEXT of any line — fix mishears, tighten phrasing.")
lines.append("# KEEP the [mm:ss.xx] prefix unchanged — that anchors the word timings.")
lines.append("# Save the file and tell the agent to continue.")
lines.append("")
for seg in data:
lines.append(f"[{fmt_time(seg['start'])}] {seg['text']}")
lines.append("")
lines.append("# === CORRECTIONS APPLIED ===")
if applied:
for k, n in sorted(applied.items(), key=lambda kv: -kv[1]):
lines.append(f"# {k} → {corrections[k]} (×{n})")
else:
lines.append("# (no corrections needed)")
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
print(f"wrote {out_path}")
print(f"{len(data)} segments, {sum(len(s['words']) for s in data)} words")
if __name__ == "__main__":
main()
Setup — Prerequisites for the video-edit skill
This skill orchestrates several external tools. A fresh machine needs the following one-time installs. The skill itself just calls these CLIs; nothing else is bundled.
Required
| Tool | Version | Purpose |
|---|---|---|
| Node.js | ≥ 22 | npx hyperframes … (scaffold, lint, render, background-remove) |
| Python | ≥ 3.10 | Whisper transcription + caption generator + review scripts |
| FFmpeg | any recent | Audio extraction, frame extraction, footage re-encoding |
| faster-whisper | latest | Word-level transcription (Python package) |
Install commands
Windows (PowerShell, with winget)
winget install OpenJS.NodeJS.LTS
winget install Python.Python.3.12
winget install Gyan.FFmpeg
pip install faster-whispermacOS (with Homebrew)
brew install node@22 python@3.12 ffmpeg
pip3 install faster-whisperLinux (Debian / Ubuntu)
sudo apt update
sudo apt install -y python3 python3-pip ffmpeg
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt install -y nodejs
pip3 install faster-whisperVerify
node --version # v22+ expected
python --version # 3.10+ expected
ffmpeg -version # any recent build
python -c "import faster_whisper; print(faster_whisper.__version__)"
npx hyperframes doctor # checks Chrome / FFmpeg / memory for rendersOptional but recommended
GPU acceleration for Whisper
faster-whisper can run on CUDA, but on Windows it usually crashes mid-decode because cuDNN is not on the PATH. The bundled transcribe.py is hard-coded to CPU int8 for that reason — fast enough on a modern CPU (~5 min for a 3-minute clip with the large-v3 model). If you have cuDNN properly installed on Linux, swap device="cpu" for device="cuda" and compute_type="int8" for compute_type="float16".
GPU for background removal (npx hyperframes remove-background)
CoreML (macOS), CUDA (Linux with proper drivers) or DirectML (Windows) accelerate the u2net mask model. Without GPU it falls back to CPU — a 10-second 1440p clip takes ~3-8 minutes.
npx hyperframes remove-background --info # lists available execution providersPre-cache Whisper model
The first transcribe downloads the large-v3 model (~3 GB) into ~/.cache/huggingface/hub/. Subsequent runs are instant to start.
Where files live
The skill expects to operate inside a HyperFrames project directory created by npx hyperframes init. Reference assets in this skill (references/) are copied into that project as part of step 4–6 in the main SKILL.md workflow.
"""Hebrew transcription with TIGHT segmentation for accurate caption sync.
Default Whisper segments are 20-30s monolithic blocks — within them
word-level timestamps drift by 1-2s. We force shorter segments via:
1. Silero VAD with min_silence_duration_ms=400 (default 2000) — splits
at every natural pause.
2. condition_on_previous_text=False — stops Whisper from concatenating
short segments back into long ones.
3. Post-process: any segment > 6s gets split at the nearest
mid-segment word boundary using its own word timestamps.
CPU forced because local CUDA install lacks cuDNN.
"""
import json, sys
from faster_whisper import WhisperModel
m = WhisperModel("large-v3", device="cpu", compute_type="int8", cpu_threads=8)
segs, info = m.transcribe(
"audio.wav",
language="he",
word_timestamps=True,
vad_filter=True,
vad_parameters={
"min_silence_duration_ms": 400,
"speech_pad_ms": 200,
},
condition_on_previous_text=False,
)
print("language:", info.language, file=sys.stderr)
raw = []
for seg in segs:
words = [
{"word": w.word, "start": round(w.start, 3), "end": round(w.end, 3)}
for w in (seg.words or [])
]
raw.append(
{
"text": seg.text.strip(),
"start": round(seg.start, 3),
"end": round(seg.end, 3),
"words": words,
}
)
# Post-process: split any segment > 6s into smaller pieces at the
# longest internal word-gap. Iterative until no segment exceeds the cap.
MAX_SEG = 6.0
out = []
queue = raw[:]
while queue:
s = queue.pop(0)
if s["end"] - s["start"] <= MAX_SEG or len(s["words"]) < 4:
out.append(s)
continue
# Find the largest internal gap (silence between consecutive words).
words = s["words"]
best_i, best_gap = -1, 0.0
for i in range(1, len(words) - 1):
gap = words[i]["start"] - words[i - 1]["end"]
# Bias toward the middle to avoid runt fragments.
mid_bias = 1.0 - abs(i - len(words) / 2) / (len(words) / 2)
score = gap * (0.6 + 0.4 * mid_bias)
if score > best_gap:
best_gap, best_i = score, i
if best_i < 0:
out.append(s)
continue
left_words = words[:best_i]
right_words = words[best_i:]
left = {
"text": "".join(w["word"] for w in left_words).strip(),
"start": left_words[0]["start"],
"end": left_words[-1]["end"],
"words": left_words,
}
right = {
"text": "".join(w["word"] for w in right_words).strip(),
"start": right_words[0]["start"],
"end": right_words[-1]["end"],
"words": right_words,
}
# Re-enqueue right side in case it also needs splitting
queue.insert(0, right)
out.append(left)
with open("transcript.json", "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=1)
print(
f"{len(out)} segments (was {len(raw)} raw), "
f"{sum(len(s['words']) for s in out)} words",
file=sys.stderr,
)
print(
f"Max segment length: {max(s['end']-s['start'] for s in out):.2f}s "
f"(cap was {MAX_SEG}s)",
file=sys.stderr,
)