--- name: wjs-overlaying-video description: Use when the user has one or more video clips and wants to add post-production on top — AI-generated cover as first frame, HTML/CSS captions synced to SRT, kinetic illustration overlays at hook moments, chapter chips, end-card CTA, or any other timed motion graphics. Most often used as the downstream of `/wjs-segmenting-video` — pick up where that skill stopped (raw cropped clip + per-clip SRT) and produce the upload-ready MP4. Backed by HyperFrames so everything compiles to ONE final encode — no cascade of re-encodes. Triggers — "加封面", "加字幕", "加动画", "加 CTA", "做后期", "post-production", "title card", "kinetic captions", "end card". --- # wjs-overlaying-video Post-production for a video clip: cover, captions, illustrations, CTA, custom motion graphics — all composed in ONE HyperFrames project and rendered in a SINGLE final encode. No cascade of decodes/re-encodes (each cascade pass degrades quality and burns time). ## When to use - **Downstream of `/wjs-segmenting-video`** — the segmentation skill hands you cropped clips + per-clip SRTs; this skill turns them into upload-ready MP4s with cover/captions/illustrations/CTA. - User has a finished video and wants to dress it up with motion graphics: opening hook, key-quote callout, closing slogan, chapter cards, AI-generated cover as first frame. - User wants HTML/CSS-quality captions on a video (kinetic word-by-word highlighting, custom fonts, large outlined text, seekable per cue). - User wants illustration overlays at specific hook moments — diagrams, big text emphasis, flow charts. **Don't use** for: - Splitting one long video into clips → use `/wjs-segmenting-video`. - Creating the source SRT → use `/wjs-transcribing-audio` (then `/wjs-translating-subtitles` if you need a different language). - Full HyperFrames productions where the source isn't a fixed video → use `hyperframes` directly. - 微信视频号 / 抖音 upload (no public API for those) → this skill produces the MP4; upload is manual. ## What this skill IS — and IS NOT | Is | Is not | |---|---| | Everything that goes ON TOP of a video clip: cover, caption, chapter, illustration, CTA | Cutting / cropping a video (that's `/wjs-segmenting-video` + `/wjs-reframing-video`) | | One HyperFrames composition per clip = ONE final encode | A multi-step decode/encode cascade | | `cover` is the literal first frame of the output (platforms auto-pick it as thumbnail) | A separate thumbnail file the user uploads alongside | | Captions are HTML/CSS — `-webkit-text-stroke` for white-on-anything readability | libass burn-in (deprecated) | | Illustrations: re-usable `stack` / `hammer` patterns + custom escape hatch | One bespoke HTML/CSS per illustration without re-use | | AI covers regenerated at native target aspect (1024×1792 for vertical, 1536×1024 for horizontal) | Single 1024×1536 default that letterboxes or crops on the platform | ## The pipeline ``` clip.mp4 + clip.zh-CN.burn.srt (from /wjs-segmenting-video hand-off) ↓ 1. (Optional) Generate AI cover via gpt-image-2 make_cover.py --segments S.json --out output/ --size 1024x1792 cover_NN_slug.png 2. Scaffold a HyperFrames project per clip hf_clip_NN/1080/{index.html, clip.mp4, cover.png, captions.json} 3. Compose: cover scene + body video + caption track + chapter chip + 1-2 illustrations at hook moments + CTA scene 4. npm run check (lint + validate + visual inspect) npm run render → upload-ready MP4 ``` A 2-minute vertical 1080×1920 composition renders in ~2-3 min on M-series Mac. ## Color: tone-map HLG/HDR source → SDR BEFORE compositing **Only tone-map genuinely HLG/HDR sources.** If the body clip is ALREADY Rec.709 SDR — e.g. a graded multicam render, or polysync output where an S-Log3→709 LUT was already applied — running the HLG tone-map recipe on it washes/darkens the already-correct color. `build_hf_clips.py`'s `tonemap_to_sdr` now probes `color_transfer` (`_is_hlg_hdr`): HLG/PQ → tone-map; otherwise a straight re-encode with dense keyframes (no tone-map). Either way you still get the `-g 30` dense-keyframe encode HyperFrames needs. iPhone / modern-camera footage is often **HLG HDR (bt2020 / arib-std-b67)**. If you feed that straight into HyperFrames it either renders washed-out ("发白") or, with a naive `--sdr`, too dark ("发黑"); and the HDR x265 path can hang the renderer. **Pre-convert the body clip to SDR (bt709) 30fps h264 with a locked zscale tone-map**, then composite the SDR clip. The verified recipe (`tonemap_to_sdr()` in `build_hf_clips.py`). `npl=203` matches macOS-native (qlmanage) reference brightness; `hable` keeps contrast; this preserves the ORIGINAL look (natural skin / foliage / brick), no wash, no darkening: ```python # zscale-capable ffmpeg — Homebrew's lacks zscale/tonemap. # imageio-ffmpeg ships one: .../imageio_ffmpeg/binaries/ffmpeg-macos-aarch64-v7.1 TONEMAP_VF = ("zscale=tin=arib-std-b67:min=bt2020nc:pin=bt2020:t=linear:npl=203," "format=gbrpf32le,tonemap=tonemap=hable:desat=0," "zscale=t=bt709:m=bt709:p=bt709:r=tv,format=yuv420p,fps=30") # encode: libx264 -crf 18 -color_primaries/-trc/-colorspace bt709 # -g 30 -keyint_min 30 -movflags +faststart ← see gotcha below ``` **Dense-keyframe gotcha.** HyperFrames seeks the body video frame-by-frame. A clip with sparse keyframes (long GOP) makes it freeze on stale frames — the render log warns `Video "video" has sparse keyframes`. Always encode the SDR clip with `-g 30 -keyint_min 30` (one keyframe per frame-second) so every seek lands clean. **Verify** the render log says `No HDR sources detected — rendering SDR`. If it says HDR detected, your clip wasn't tone-mapped — fix that first. ## Version stamp (every output) Stamp `「skill名字 + 版本号」` bottom-right, shown during the END/CTA scene, so every render is traceable to the pipeline version that made it. Bump `VERSION` in `build_hf_clips.py` on each pipeline change. ```css #ver-stamp { position: absolute; right: 28px; bottom: 28px; z-index: 30; font-size: 20px; color: rgba(150,150,156,0.55); letter-spacing: 0.06em; } ``` ```html
wjs-overlaying-video v1.3
``` ## Standard overlay types (the 6 building blocks) Every clip's final composition is built from some combination of these. The agent picks the right ones per clip — typically all 6 for a podcast highlight, or just 1-2 for a single annotation overlay. ### 1. `cover` — full-frame AI image as first frame The cover IS the first frame (no animation, no zoom) so platforms that auto-pick the first frame as the thumbnail get your designed cover by default. **Always verify with `ffmpeg -ss 0 -vframes 1`** — frame 0 must NOT be black or platform thumbnails will be black. **HTML:** ```html
``` **CSS:** ```css #cover { position: absolute; inset: 0; background: #0c0d10; overflow: hidden; } #cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } ``` **Generation:** use `/wjs-segmenting-video/scripts/make_cover.py` (wraps `gpt-image-2 images edit` with the midpoint frame as ref): ```bash # For 1080×1920 vertical output (视频号 / 抖音): make_cover.py --segments S.json --out output/ --size 1024x1792 [--single N] # For 1920×1080 horizontal output (YouTube / B站): make_cover.py --segments S.json --out output/ --size 1536x1024 ``` **Aspect must match output frame.** `--size 1024x1536` (2:3, the script default) gets letterboxed or cropped on 9:16 output — always pass `1024x1792` for vertical. The cover image's aspect is what the viewer sees full-frame, so mismatch is visible. Re-roll one with `--single N`; codex provider can transient-fail mid-batch. **Codex auth required**: the script calls codex CLI via `gpt-image-2-skill`. If `~/.codex/auth.json` is missing, the script errors. See `gpt-image-2-skill` for setup. **Reference frame must match the OUTPUT orientation.** make_cover reads `output/frame_NN_slug.jpg` as the photographic background it keeps. For a vertical clip that came from a horizontal two-person source, the default `frame_NN` is the horizontal two-shot — feeding that to a `1024x1792` cover crams both people into portrait awkwardly. Replace `frame_NN_slug.jpg` with a **vertical single-speaker frame** pulled from the already-cropped body clip first (`ffmpeg -ss -i clip_vert.mp4 -frames:v 1 frame_NN_slug.jpg`), then run make_cover. The cover then matches the body framing. **Baked-title cover ⇒ drop the animated `#hook` opener.** make_cover stamps the segment *title* into the cover image (white fill + heavy black stroke, placed clear of faces). That cover IS the title card. Do NOT also run the animated `#hook` opener over it (overlay type below) — you'd double-stamp the title. Pick one: either a make_cover baked-title cover (then leave `HOOK` empty), or a plain video-frame cover + animated hook. The house default the user approved is the **make_cover baked-title cover** (a clean video frame with the title burned in, no AI painting). ### 2. `caption` — 关键词高亮 captions (字幕风格 03) synced to SRT **Chosen style for 王建硕 (user-approved): 字幕风格 03「关键词高亮」+ 思源宋体 Noto Serif SC.** Serif white text with a black stroke, and punchy QUANTITATIVE keywords (倍数 / 大数量级 / 百分比) wrapped in a small **gold gradient block**. Captions are vertically centered in a fixed zone (so 1-line vs 2-line cues don't make the visual center jump up and down). There were 4 candidate styles (描边白字 / 质感底条 / 关键词高亮 / 逐字点亮); the user picked **03 关键词高亮** with **serif sc** font. Use that. The plain-stroke style (`-webkit-text-stroke: 5px #000`, no gold block, sans font) is the fallback if a clip has no quantitative keywords to highlight. **Font — load Noto Serif SC from Google Fonts in ``** (the HyperFrames compiler fetches & inlines requested Google font families automatically; verify the render log says `Fetched … Noto Serif SC`): ```html ``` **HTML:** ```html
``` **CSS (vertical 1080×1920) — 字幕风格 03:** ```css #caption { position: absolute; left: 0; right: 0; bottom: 240px; height: 240px; z-index: 10; overflow: visible; } #caption .bubble { position: absolute; top: 50%; left: 50%; display: inline-block; padding: 0 24px; font-family: "Noto Serif SC", "Songti SC", "STSong", serif; font-size: 52px; line-height: 1.32; font-weight: 700; color: #fff; max-width: 980px; text-align: center; -webkit-text-stroke: 2.5px rgba(0,0,0,0.9); paint-order: stroke fill; text-shadow: 0 2px 8px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.9); letter-spacing: 0.01em; } #caption .bubble .hot { /* gold keyword block */ color: #1a1206; -webkit-text-stroke: 0; background: linear-gradient(180deg, #f3c877, #c79655); padding: 2px 12px; border-radius: 9px; margin: 0 3px; box-shadow: 0 3px 10px -3px rgba(232,176,99,0.6); } ``` **Keyword auto-selection (sparse on purpose).** Wrap only genuinely emphatic magnitudes so the gold block stays meaningful, not noisy. Deliberately EXCLUDE generic 个/年 ("一个", "20年"). Handles thousands-commas ("1,000万"). `build_hf_clips.py` does this in `mark_keywords()`: ```python _NUM = r"[0-90-9,,一二三四五六七八九十百千两零几]+" _HOT_RE = re.compile(rf"(?:翻了?{_NUM}?[倍番]|{_NUM}\s*(?:[倍番]|万亿?|亿|%|%))") # → highlights: 一倍 五六倍 十倍 10倍 50万 800万 1,000万 50% 翻一倍 # render the cue with b.innerHTML = g.html (HTML-escape the non-keyword text) ``` **JS (one bubble per cue + GSAP fade in/out, all centered at container midpoint):** ```js // SRT cues are loaded as inline JSON. Each cue's start/end is offset // by the cover-scene duration (e.g., 1.5s) so the timing aligns with // the composition timeline (not the body's own t=0). const captionEl = document.getElementById("caption"); const groups = JSON.parse(document.getElementById("captions-data").textContent); const bubbles = groups.map((g, i) => { const b = document.createElement("span"); b.className = "bubble"; b.id = "cap-" + i; b.innerHTML = g.html || g.text; // g.html has keyword blocks b.style.opacity = "0"; captionEl.appendChild(b); return b; }); // GSAP xPercent/yPercent for centering (CSS transform would get // overwritten the moment we tween y). gsap.set(bubbles, { xPercent: -50, yPercent: -50 }); groups.forEach((g, i) => { const el = bubbles[i]; tl.fromTo(el, { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.18, ease: "power2.out" }, g.start); const exitStart = Math.max(g.start + 0.18, g.end - 0.12); tl.to(el, { opacity: 0, duration: 0.12, ease: "power2.in" }, exitStart); tl.set(el, { opacity: 0 }, g.end); }); ``` **Source SRT — slice + shift before inlining.** Prefer the **word-timed `.asr.srt`** built by `/wjs-transcribing-audio` (火山 streaming ASR → `build_srt_from_asr.py`) — its per-word timing means cues sit exactly on the spoken audio with no drift. Parse each cue, add the cover duration to every `start`/`end`, run `mark_keywords()` to produce the `html` field, and inline as JSON in a `