[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6DB3JVTWWVYKHJG0393ZVW3":3,"skill-same:01M6DB3JVTWWVYKHJG0393ZVW3":75},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":44,"linkOnly":45,"markdownContent":46,"markdownBody":47,"frontMatter":48,"refName":49,"commitSha":50,"skillDirectory":51,"forksCount":14,"repositoryHtmlUrl":52,"repositoryHomepageUrl":6,"ownerHtmlUrl":53,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":54,"install":55,"resources":68},"01M6DB3JVTWWVYKHJG0393ZVW3","caption-burn",null,"Use when BURNING captions \u002F subtitles \u002F karaoke INTO a video as hardcoded pixels (hardsub) via libass through ffmpeg, OR soft-muxing them as a selectable, removable subtitle track. Covers ASS document generation sized to the export canvas, libass styling (font \u002F size \u002F colour \u002F outline \u002F box), the OpusClip word-by-word KARAOKE preset (per-word alternating yellow\u002Fgreen + scale-pop), hook-title \u002F hook-card overlays, the CaptionOverride tuning patch, safe-area placement for 9:16, and the Netflix CPS\u002FCPL\u002Fmin-gap polish pass. Burn = lossy re-encode, permanent, styling survives anywhere; soft-mux = lossless stream-copy, toggleable, but loses ASS styling in MP4 (mov_text). NOT for moving\u002Fanimated watermark removal (use video-watermark-removal), NOT for transcription\u002Fcue generation (this consumes an existing cue list), NOT for repositioning an already-burned track (burned pixels are permanent — re-burn from source). Source of truth: media_studio\u002Ffeatures\u002Fcaption.py, caption_karaoke.py, caption_override.py, caption_polish.py, tracks.py.","Prekzursil\u002Fagent-skills-toolchain","Prekzursil","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F54636077?v=4","01M6DACZ1PCQHD99SCR1YXG28B","Andrei Mihai Visalon","01M6DACZ670WZF535V3WYYSS4Z",0,"MIT","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fcaption-burn\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000002","文档处理","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43],"caption burn","burn","patch","gap","cue","as","ffmpeg","generation","green","document","canvas","use","text","Word","mp4","Captions","Media","Video","subtitles","preset","2026-11-10 10:12:30",false,"---\nname: caption-burn\ndescription: \"Use when BURNING captions \u002F subtitles \u002F karaoke INTO a video as hardcoded pixels (hardsub) via libass through ffmpeg, OR soft-muxing them as a selectable, removable subtitle track. Covers ASS document generation sized to the export canvas, libass styling (font \u002F size \u002F colour \u002F outline \u002F box), the OpusClip word-by-word KARAOKE preset (per-word alternating yellow\u002Fgreen + scale-pop), hook-title \u002F hook-card overlays, the CaptionOverride tuning patch, safe-area placement for 9:16, and the Netflix CPS\u002FCPL\u002Fmin-gap polish pass. Burn = lossy re-encode, permanent, styling survives anywhere; soft-mux = lossless stream-copy, toggleable, but loses ASS styling in MP4 (mov_text). NOT for moving\u002Fanimated watermark removal (use video-watermark-removal), NOT for transcription\u002Fcue generation (this consumes an existing cue list), NOT for repositioning an already-burned track (burned pixels are permanent — re-burn from source). Source of truth: media_studio\u002Ffeatures\u002Fcaption.py, caption_karaoke.py, caption_override.py, caption_polish.py, tracks.py.\"\n---\n\n# Caption Burn (hardsub) & Soft-mux — libass \u002F ffmpeg\n\nRender an existing **cue list** onto a video, either **burned in** (hardcoded pixels, permanent, re-encode) or **soft-muxed** (a selectable, removable subtitle stream, stream-copy). This is the libass\u002FASS path; the styled\u002Fkaraoke output path is libass burn (mov_text cannot carry the styling).\n\nSource modules read (cite these, do not invent commands):\n- `media_studio\u002Ffeatures\u002Fcaption.py` — `CaptionEngine`, `build_ass`, escaping, `build_burn_argv`\u002F`build_softmux_argv`, hook title\u002Fcard, karaoke routing.\n- `media_studio\u002Ffeatures\u002Fcaption_karaoke.py` — OpusClip word-by-word karaoke ASS preset.\n- `media_studio\u002Ffeatures\u002Fcaption_override.py` — `CaptionOverride` -> `ResolvedCaptionStyle`, the ASS colour byte-swap.\n- `media_studio\u002Ffeatures\u002Fcaption_polish.py` — Netflix CPS\u002FCPL\u002Fmin-gap + punct\u002Fcasing\u002Femphasis\u002Fprofanity polish.\n- `media_studio\u002Ffeatures\u002Ftracks.py` — `tracks.burn` \u002F `soft_mux` \u002F `strip` RPC + the simpler V1 ASS builder.\n- `media_studio\u002Fffmpeg.py` — the shared `run()` (argv-only, stderr-drain) and `ffmpeg_path()`.\n\n## When to use \u002F when NOT\n- USE for: painting transcript cues into a clip (standard captions, styled captions, OpusClip karaoke), adding a hook-title\u002Fhook-card headline, or muxing a removable subtitle track.\n- DO NOT use for: removing a logo\u002Fwatermark (`video-watermark-removal`); generating the cues themselves (this skill consumes `{index,start,end,text}` cues already produced upstream); moving a caption that is already burned in (pixels are baked — change the position\u002Fstyle and re-burn from the original source); soft-muxing styled ASS into an MP4 and expecting the styling to survive (MP4 only carries `mov_text`, which drops ASS overrides — burn instead).\n\n## Burn vs soft-mux — the tradeoff (decide first)\n| | Burn (hardsub) | Soft-mux |\n|---|---|---|\n| ffmpeg | `-vf subtitles=...` (libass rasterises) | `-map 0 -map 1 -c copy -c:s ...` |\n| Video | **RE-ENCODED** (lossy, slow) | **stream-copied** (lossless, fast) |\n| Removable later | No — `tracks.strip` refuses a `kind==\"hard\"` track | Yes — `tracks.strip` negative-maps the sub stream |\n| Styling fidelity | Full ASS (colour, karaoke, pop, box) | Native ASS only in MKV; **MP4\u002FMOV downgrade to `mov_text`** = plain text, no styling |\n| Player support | Universal (it's just pixels) | Needs a player\u002Ftrack-selector |\n| Use when | Social\u002Fvertical export, karaoke, any styled look, MP4 delivery | Archival, multi-language toggle, keep original bytes |\n\nRule of thumb from the code: anything that needs the OpusClip look, karaoke, hook card, or a styled override and ships as MP4 -> **burn**. A toggleable subtitle in an MKV -> **soft-mux**.\n\n## Recipe (the real flow, from `CaptionEngine.render`)\n```\ncues {index,start,end,text[,words,emphasis,emoji]}\n  -> (optional) caption_polish.polish_cues   # Netflix CPS\u002FCPL split + punct\u002Fcasing\u002Femphasis\u002Fprofanity\n  -> build_ass(...) OR build_karaoke_ass(...) # ASS document sized PlayResX x PlayResY\n       - re-base every cue time by source_start  (clip offset in the ORIGINAL video)\n       - escape cue text against ASS override injection\n       - resolve CaptionOverride onto Style: Default (font\u002Fsize\u002Fcolour\u002Foutline\u002Fbox\u002Fband\u002Fuppercase)\n  -> write to a temp .ass sidecar (UTF-8, newline=\"\")  # argv-passed, never piped through a shell\n  -> ffmpeg:  burn  = build_burn_argv     (subtitles= filter, libass; -c:a copy)\n              softmux= build_softmux_argv  (map both, -c:v\u002F-c:a copy, -c:s mov_text|ass)\n  -> CaptionError on non-zero exit; temp .ass unlinked in finally\n```\n\n### Exact ffmpeg argv — BURN (`caption.build_burn_argv`)\n```\nffmpeg -hide_banner -nostdin -y -i \u003Cclip> -vf subtitles='\u003Cescaped_ass_path>' \\\n       -c:a copy -progress pipe:1 -nostats \u003Cout>\n```\nNote: there is **no `-c:v`** — ffmpeg picks the container default encoder (libx264 for `.mp4`) at its **default CRF (~23)**. If you need quality\u002Fbitrate control you must extend the argv; the burn builder does not expose it.\n\n### Exact ffmpeg argv — SOFT-MUX (`caption.build_softmux_argv`)\n```\nffmpeg -hide_banner -nostdin -y -i \u003Cclip> -i \u003Cass> \\\n       -map 0:v -map 0:a? -map 1:0 \\\n       -c:v copy -c:a copy -c:s \u003Cscodec> -progress pipe:1 -nostats \u003Cout>\n```\n`scodec` = `mov_text` for `.mp4\u002F.m4v\u002F.mov`, else `ass` (MKV keeps native styling). `tracks.build_soft_mux_argv` is the variant that maps `-map 0 -map 1 -c copy` and tags `-metadata:s:s:0 language=\u003Clang>`.\n\n### STRIP a soft sub (`tracks.build_strip_argv`)\n```\nffmpeg ... -i \u003Cin> -map 0 -map -0:s:\u003Cindex> -c copy -progress pipe:1 -nostats \u003Cout>\n```\nRe-mux that negatively-maps one subtitle stream. A burned (`kind==\"hard\"`) track raises `HardSubtitleError` — there is no stream to remove.\n\n## ASS document structure (`build_ass`)\n- `[Script Info]`: `PlayResX\u002FPlayResY = width\u002Fheight` (default **1080x1920**), `WrapStyle: 0`, `ScaledBorderAndShadow: yes`. libass lays out for the EXACT export canvas — get this wrong and font size\u002Fmargins scale wrong.\n- `[V4+ Styles]` `Style: Default`: white fill, black outline + drop shadow. Base font `Arial`, `font_size = max(12, round(height*0.045))` then `* size_scale`. Bottom-centred (`Alignment 2`) with L\u002FR margin 40 and `MarginV = max(10, round(height*0.06))`.\n- Body cue events; optional `HookTitle` (top-anchored headline) or `HookCard` (white opaque box, first ~5 s) emitted FIRST so it draws above the captions.\n- Output uses **LF** line endings (determinism; libass accepts LF).\n\n## libass styling fields you actually touch (`CaptionOverride` -> `ResolvedCaptionStyle`)\nThe additive tuning patch (`apply_override`) resolves ~8 primitives on top of the base, each degrading independently to the template default (never raises):\n- `fontFamily` — must be in `CURATED_CAPTION_FONTS` (Inter, Roboto, Open Sans, Noto Sans, Lato, Nunito, Montserrat, Poppins, Oswald, **Anton**, **Bebas Neue**, Archivo Black, DejaVu Sans) or it is dropped back to `Arial`. `DejaVu Sans` is libass's always-present fallback.\n- `sizeScale` — clamped to **0.6 .. 1.8**, multiplies the canvas-derived base size.\n- `textColor` \u002F `spokenColor` -> `PrimaryColour`; `activeColor` -> `SecondaryColour`. Bad hex dropped.\n- `box: true` -> `BorderStyle=3` (opaque box using `BackColour`); `outline: true\u002Ffalse` -> `BorderStyle=1`, width 3\u002F0. Box wins over outline.\n- `positionBand` -> top=8 \u002F center=5 \u002F bottom=2 (centre is libass-centred, MarginV ignored).\n- `uppercase: true` -> all-caps.\n\n### ASS colour format — the silent-wrong-colour trap (`hex_to_ass_color`)\nASS colours are **`&HAABBGGRR`** — alpha + **B,G,R reversed vs CSS `#RRGGBB`**, AND the alpha byte is **inverted** (`00` = opaque, `FF` = transparent). So `#FF0000` (red) -> `&H000000FF&`, `#123456` -> `&H00563412&`. Getting the byte order wrong renders the wrong colour with **no crash**. Tests pin the exact `&H...` string per input hex.\n\n## Karaoke preset (`caption_karaoke.build_karaoke_ass`, style id `opusclip-karaoke`)\nThe flat override fields cannot express the per-word colour + scale-pop, so this is a SEPARATE ASS builder routed from `CaptionEngine.render(..., karaoke=True)`. The standard `build_ass` stays byte-identical to V1.\n- One `Dialogue` event **per spoken word** over that word's `[start,end]`, showing the whole 1-4 word line with the active word wrapped in an inline block:\n  `{\\1c\u003Ccolour>\\t(0,120,\\fscx115\\fscy115)}WORD{\\r}` — alternating active colour + a grow-pop over the first `KARAOKE_POP_MS` (120 ms), then `\\r` reset back to the white-fill Style default.\n- Active colour alternates **yellow `&H0000FFFF&` -> green `&H0000FF00&`** by absolute word order (skipped words still advance the index so the alternation stays stable).\n- Look: font **Anton**, bold, `BorderStyle=1` (outline+shadow, NOT a box), outline width 4, shadow 2, all-caps, `font_size = max(12, round(height*0.05))`, 1-4 words\u002Fline.\n- Safe area (9:16): default **bottom** band clears the bottom ~18% (`SAFE_AREA_BOTTOM_FRACTION`) -> lower-mid, off the platform UI; top clears ~10%; side margin 6% of width.\n- **Word timing**: prefers the cue's aligned `words` list (karaoke-grade). With NO word timing it whitespace-splits the cue text and distributes the `[start,end]` window EVENLY — a documented degrade, not a silent failure.\n\n## Caption polish (`caption_polish.polish_cues`) — run BEFORE building the ASS\nPure Netflix timing gate (always applied) + three optional model seams (skipped when their backend is `None`):\n- CPS cap: children 13 \u002F English 20 \u002F other 17, user `maxCps` clamped 10..30. CPL \u003C= 42, \u003C= 2 lines, min gap 2 frames (at 30 fps).\n- `enforce_cps_cpl` splits\u002Fretimes a too-long cue into proportional slices; `enforce_min_gap` pulls each `end` back to keep the gap. Honest about limits: proportional splitting CANNOT make a genuinely too-fast cue readable (that needs more on-screen time upstream) — CPL is always enforced, CPS is the capacity floor.\n- Punct+casing = sherpa-onnx CT-Transformer, HF repo `lorneluo\u002Fsherpa-onnx-online-punct-en-2024-08-06` @ `aad05f2d2818122135b0667806628019cb923e84` (~30 MB, EN-only, Apache-2.0; the original csukuangfj repo 404s — re-pointed). KeyBERT (all-MiniLM) for emphasis keywords; alt-profanity-check for masking. All lazy-imported; absent model => stage skipped.\n\n## Pitfalls that actually bite\n1. **ASS override injection.** Cue\u002Fhook text is transcript\u002Fmodel-derived. `{...}` is an ASS override block; a line like `{\\an8}` could reposition or blank text. `escape_ass_text` escapes `\\` FIRST (else the backslashes it adds for braces get doubled), then `{`->`\\{`, `}`->`\\}`, real newlines -> `\\N`. NEVER hand-assemble Dialogue text without this.\n2. **Re-base cue times or captions never show.** Cues are timed against the ORIGINAL video; a clip cut from t=120 s must subtract `source_start` so its first caption is at clip-local t=0, not t=120 (which would never display). Pass the candidate's `sourceStart`. Cues whose end \u003C= start after re-base are dropped.\n3. **ASS colour byte-swap** (see above) — `&HAABBGGRR`, BGR reversed, inverted alpha. Silent wrong colour, no crash.\n4. **Windows path escaping inside the `subtitles=` filter.** Inside `-vf`, `\\`, `:` (drive letter `C:`), `'` and `[]` are filtergraph-special. `_escape_filter_path` wraps in single quotes and escapes `\\`->`\\\\`, `:`->`\\:`, `'`->`\\'`. A raw `C:\\a b\\subs.ass` otherwise breaks the filtergraph parse. The .ass is always passed as an argv path, never a shell string.\n5. **Burn RE-ENCODES; there is no `-c:v` in the burn argv** -> ffmpeg uses the container default encoder at default CRF (~23). Expect generation loss and slow encode. Soft-mux is lossless `-c copy`. If burning to MP4 needs a target quality, extend the argv yourself.\n6. **MP4 soft-mux silently downgrades to `mov_text`** — ASS styling\u002Fkaraoke\u002Fbox are LOST (plain text only). To keep styling in an MP4 you must BURN. Soft-mux into MKV (`-c:s ass`) preserves it.\n7. **Font must be installed for libass to use it.** `Anton`\u002F`Bebas Neue` (karaoke\u002Fcondensed looks) and the curated list are the burn-in fontconfig allowlist; if the face is missing on the host, libass falls back (to `DejaVu Sans`) and the burn looks wrong — and the override layer drops any non-curated font to `Arial` before that. Verify the curated fonts are present in the burn environment.\n8. **`uppercase` must transform per-slice BEFORE escaping, never the assembled string.** Upper-casing the assembled line would corrupt an inserted `{\\b1}` into `{\\B1}` (a broken override). `render_cue_text` upper-cases each raw slice independently so emphasis\u002Fspan offsets stay aligned.\n9. **stderr pipe deadlock (the \"stuck clip\" trap).** ffmpeg chats continuously on stderr even with `-nostats` (banner, stream info, libass FONT logs). `ffmpeg.run` captures `stderr=PIPE` and **drains it on a daemon thread**; if you build your own subprocess and never read stderr, the ~64 KB pipe fills and the encode BLOCKS forever. Always drain stderr (or let `ffmpeg.run` do it). `-progress pipe:1` feeds the progress parser on stdout.\n10. **argv list only — never `shell=True`.** `ffmpeg.run` raises `TypeError` on a shell string. Paths with spaces are safe because every element is a separate argv item.\n11. **A burned track cannot be stripped.** `tracks.strip` raises `HardSubtitleError` for `kind==\"hard\"`. Burn is irreversible; only soft-muxed streams are removable.\n12. **Hook title\u002Fcard timing.** Hook title spans the whole clip (`total_sec`, else last cue \u002F 60 s floor). Hook card is time-boxed to ~first 5 s. Emit them as the FIRST events so they layer above body captions.\n\n## GPU \u002F model notes\n- Burn\u002Fsoft-mux\u002Fstrip are **pure ffmpeg\u002Flibass — CPU, no GPU, no HF model.** Burn is the slow step (full video re-encode); soft-mux\u002Fstrip are near-instant stream-copies.\n- The only model dependency is the OPTIONAL `caption_polish` pass: sherpa-onnx punct model (~30 MB, pinned HF revision above), KeyBERT all-MiniLM, alt-profanity-check. All lazy; absent => that stage is skipped, the timing gate still runs. None require a GPU.\n\n## Worked example — burn OpusClip karaoke onto a 9:16 clip\nA clip cut from t=120 s of the source, with word-aligned cues, exported at 1080x1920:\n```python\nfrom media_studio.features.caption import CaptionEngine\n\nengine = CaptionEngine(settings={\"ffmpegPath\": \"ffmpeg\"})  # runner defaults to ffmpeg.run\nout = engine.render(\n    clip_path=\"clip.mp4\",\n    cues=cues,                # [{index,start,end,text,words:[{text,start,end}]}, ...]\n    out_path=\"clip-karaoke.mp4\",\n    burn=True,                # hardsub (MP4 -> styling MUST be burned, not mov_text)\n    width=1080, height=1920,\n    source_start=120.0,       # re-base: clip-local t=0 == source t=120s\n    karaoke=True,             # -> caption_karaoke.build_karaoke_ass (Anton, yellow\u002Fgreen pop)\n    total_sec=58.0,           # source duration for progress -> pct\n    on_progress=lambda pct, msg: print(f\"{pct:.0f}% {msg}\"),\n)\n```\nUnder the hood this builds the karaoke ASS (per-word events, alternating accent + scale-pop, lower-mid safe area), writes a temp `.ass`, and runs:\n```\nffmpeg -hide_banner -nostdin -y -i clip.mp4 \\\n       -vf subtitles='clip-karaoke.ass' -c:a copy -progress pipe:1 -nostats clip-karaoke.mp4\n```\nA non-zero exit raises `CaptionError`; the temp `.ass` is unlinked regardless.\n\nTo instead keep a removable English track in an MKV (lossless, styling preserved):\n```python\nout = engine.render(\"clip.mkv\", cues, \"clip-sub.mkv\", burn=False,\n                    width=1080, height=1920, source_start=120.0)\n# -> ffmpeg ... -i clip.mkv -i \u003Cass> -map 0:v -map 0:a? -map 1:0 \\\n#       -c:v copy -c:a copy -c:s ass -progress pipe:1 -nostats clip-sub.mkv\n```\n(For MP4 this would emit `-c:s mov_text` and the karaoke styling would be lost — burn for MP4.)\n","# Caption Burn (hardsub) & Soft-mux — libass \u002F ffmpeg\n\nRender an existing **cue list** onto a video, either **burned in** (hardcoded pixels, permanent, re-encode) or **soft-muxed** (a selectable, removable subtitle stream, stream-copy). This is the libass\u002FASS path; the styled\u002Fkaraoke output path is libass burn (mov_text cannot carry the styling).\n\nSource modules read (cite these, do not invent commands):\n- `media_studio\u002Ffeatures\u002Fcaption.py` — `CaptionEngine`, `build_ass`, escaping, `build_burn_argv`\u002F`build_softmux_argv`, hook title\u002Fcard, karaoke routing.\n- `media_studio\u002Ffeatures\u002Fcaption_karaoke.py` — OpusClip word-by-word karaoke ASS preset.\n- `media_studio\u002Ffeatures\u002Fcaption_override.py` — `CaptionOverride` -> `ResolvedCaptionStyle`, the ASS colour byte-swap.\n- `media_studio\u002Ffeatures\u002Fcaption_polish.py` — Netflix CPS\u002FCPL\u002Fmin-gap + punct\u002Fcasing\u002Femphasis\u002Fprofanity polish.\n- `media_studio\u002Ffeatures\u002Ftracks.py` — `tracks.burn` \u002F `soft_mux` \u002F `strip` RPC + the simpler V1 ASS builder.\n- `media_studio\u002Fffmpeg.py` — the shared `run()` (argv-only, stderr-drain) and `ffmpeg_path()`.\n\n## When to use \u002F when NOT\n- USE for: painting transcript cues into a clip (standard captions, styled captions, OpusClip karaoke), adding a hook-title\u002Fhook-card headline, or muxing a removable subtitle track.\n- DO NOT use for: removing a logo\u002Fwatermark (`video-watermark-removal`); generating the cues themselves (this skill consumes `{index,start,end,text}` cues already produced upstream); moving a caption that is already burned in (pixels are baked — change the position\u002Fstyle and re-burn from the original source); soft-muxing styled ASS into an MP4 and expecting the styling to survive (MP4 only carries `mov_text`, which drops ASS overrides — burn instead).\n\n## Burn vs soft-mux — the tradeoff (decide first)\n| | Burn (hardsub) | Soft-mux |\n|---|---|---|\n| ffmpeg | `-vf subtitles=...` (libass rasterises) | `-map 0 -map 1 -c copy -c:s ...` |\n| Video | **RE-ENCODED** (lossy, slow) | **stream-copied** (lossless, fast) |\n| Removable later | No — `tracks.strip` refuses a `kind==\"hard\"` track | Yes — `tracks.strip` negative-maps the sub stream |\n| Styling fidelity | Full ASS (colour, karaoke, pop, box) | Native ASS only in MKV; **MP4\u002FMOV downgrade to `mov_text`** = plain text, no styling |\n| Player support | Universal (it's just pixels) | Needs a player\u002Ftrack-selector |\n| Use when | Social\u002Fvertical export, karaoke, any styled look, MP4 delivery | Archival, multi-language toggle, keep original bytes |\n\nRule of thumb from the code: anything that needs the OpusClip look, karaoke, hook card, or a styled override and ships as MP4 -> **burn**. A toggleable subtitle in an MKV -> **soft-mux**.\n\n## Recipe (the real flow, from `CaptionEngine.render`)\n```\ncues {index,start,end,text[,words,emphasis,emoji]}\n  -> (optional) caption_polish.polish_cues   # Netflix CPS\u002FCPL split + punct\u002Fcasing\u002Femphasis\u002Fprofanity\n  -> build_ass(...) OR build_karaoke_ass(...) # ASS document sized PlayResX x PlayResY\n       - re-base every cue time by source_start  (clip offset in the ORIGINAL video)\n       - escape cue text against ASS override injection\n       - resolve CaptionOverride onto Style: Default (font\u002Fsize\u002Fcolour\u002Foutline\u002Fbox\u002Fband\u002Fuppercase)\n  -> write to a temp .ass sidecar (UTF-8, newline=\"\")  # argv-passed, never piped through a shell\n  -> ffmpeg:  burn  = build_burn_argv     (subtitles= filter, libass; -c:a copy)\n              softmux= build_softmux_argv  (map both, -c:v\u002F-c:a copy, -c:s mov_text|ass)\n  -> CaptionError on non-zero exit; temp .ass unlinked in finally\n```\n\n### Exact ffmpeg argv — BURN (`caption.build_burn_argv`)\n```\nffmpeg -hide_banner -nostdin -y -i \u003Cclip> -vf subtitles='\u003Cescaped_ass_path>' \\\n       -c:a copy -progress pipe:1 -nostats \u003Cout>\n```\nNote: there is **no `-c:v`** — ffmpeg picks the container default encoder (libx264 for `.mp4`) at its **default CRF (~23)**. If you need quality\u002Fbitrate control you must extend the argv; the burn builder does not expose it.\n\n### Exact ffmpeg argv — SOFT-MUX (`caption.build_softmux_argv`)\n```\nffmpeg -hide_banner -nostdin -y -i \u003Cclip> -i \u003Cass> \\\n       -map 0:v -map 0:a? -map 1:0 \\\n       -c:v copy -c:a copy -c:s \u003Cscodec> -progress pipe:1 -nostats \u003Cout>\n```\n`scodec` = `mov_text` for `.mp4\u002F.m4v\u002F.mov`, else `ass` (MKV keeps native styling). `tracks.build_soft_mux_argv` is the variant that maps `-map 0 -map 1 -c copy` and tags `-metadata:s:s:0 language=\u003Clang>`.\n\n### STRIP a soft sub (`tracks.build_strip_argv`)\n```\nffmpeg ... -i \u003Cin> -map 0 -map -0:s:\u003Cindex> -c copy -progress pipe:1 -nostats \u003Cout>\n```\nRe-mux that negatively-maps one subtitle stream. A burned (`kind==\"hard\"`) track raises `HardSubtitleError` — there is no stream to remove.\n\n## ASS document structure (`build_ass`)\n- `[Script Info]`: `PlayResX\u002FPlayResY = width\u002Fheight` (default **1080x1920**), `WrapStyle: 0`, `ScaledBorderAndShadow: yes`. libass lays out for the EXACT export canvas — get this wrong and font size\u002Fmargins scale wrong.\n- `[V4+ Styles]` `Style: Default`: white fill, black outline + drop shadow. Base font `Arial`, `font_size = max(12, round(height*0.045))` then `* size_scale`. Bottom-centred (`Alignment 2`) with L\u002FR margin 40 and `MarginV = max(10, round(height*0.06))`.\n- Body cue events; optional `HookTitle` (top-anchored headline) or `HookCard` (white opaque box, first ~5 s) emitted FIRST so it draws above the captions.\n- Output uses **LF** line endings (determinism; libass accepts LF).\n\n## libass styling fields you actually touch (`CaptionOverride` -> `ResolvedCaptionStyle`)\nThe additive tuning patch (`apply_override`) resolves ~8 primitives on top of the base, each degrading independently to the template default (never raises):\n- `fontFamily` — must be in `CURATED_CAPTION_FONTS` (Inter, Roboto, Open Sans, Noto Sans, Lato, Nunito, Montserrat, Poppins, Oswald, **Anton**, **Bebas Neue**, Archivo Black, DejaVu Sans) or it is dropped back to `Arial`. `DejaVu Sans` is libass's always-present fallback.\n- `sizeScale` — clamped to **0.6 .. 1.8**, multiplies the canvas-derived base size.\n- `textColor` \u002F `spokenColor` -> `PrimaryColour`; `activeColor` -> `SecondaryColour`. Bad hex dropped.\n- `box: true` -> `BorderStyle=3` (opaque box using `BackColour`); `outline: true\u002Ffalse` -> `BorderStyle=1`, width 3\u002F0. Box wins over outline.\n- `positionBand` -> top=8 \u002F center=5 \u002F bottom=2 (centre is libass-centred, MarginV ignored).\n- `uppercase: true` -> all-caps.\n\n### ASS colour format — the silent-wrong-colour trap (`hex_to_ass_color`)\nASS colours are **`&HAABBGGRR`** — alpha + **B,G,R reversed vs CSS `#RRGGBB`**, AND the alpha byte is **inverted** (`00` = opaque, `FF` = transparent). So `#FF0000` (red) -> `&H000000FF&`, `#123456` -> `&H00563412&`. Getting the byte order wrong renders the wrong colour with **no crash**. Tests pin the exact `&H...` string per input hex.\n\n## Karaoke preset (`caption_karaoke.build_karaoke_ass`, style id `opusclip-karaoke`)\nThe flat override fields cannot express the per-word colour + scale-pop, so this is a SEPARATE ASS builder routed from `CaptionEngine.render(..., karaoke=True)`. The standard `build_ass` stays byte-identical to V1.\n- One `Dialogue` event **per spoken word** over that word's `[start,end]`, showing the whole 1-4 word line with the active word wrapped in an inline block:\n  `{\\1c\u003Ccolour>\\t(0,120,\\fscx115\\fscy115)}WORD{\\r}` — alternating active colour + a grow-pop over the first `KARAOKE_POP_MS` (120 ms), then `\\r` reset back to the white-fill Style default.\n- Active colour alternates **yellow `&H0000FFFF&` -> green `&H0000FF00&`** by absolute word order (skipped words still advance the index so the alternation stays stable).\n- Look: font **Anton**, bold, `BorderStyle=1` (outline+shadow, NOT a box), outline width 4, shadow 2, all-caps, `font_size = max(12, round(height*0.05))`, 1-4 words\u002Fline.\n- Safe area (9:16): default **bottom** band clears the bottom ~18% (`SAFE_AREA_BOTTOM_FRACTION`) -> lower-mid, off the platform UI; top clears ~10%; side margin 6% of width.\n- **Word timing**: prefers the cue's aligned `words` list (karaoke-grade). With NO word timing it whitespace-splits the cue text and distributes the `[start,end]` window EVENLY — a documented degrade, not a silent failure.\n\n## Caption polish (`caption_polish.polish_cues`) — run BEFORE building the ASS\nPure Netflix timing gate (always applied) + three optional model seams (skipped when their backend is `None`):\n- CPS cap: children 13 \u002F English 20 \u002F other 17, user `maxCps` clamped 10..30. CPL \u003C= 42, \u003C= 2 lines, min gap 2 frames (at 30 fps).\n- `enforce_cps_cpl` splits\u002Fretimes a too-long cue into proportional slices; `enforce_min_gap` pulls each `end` back to keep the gap. Honest about limits: proportional splitting CANNOT make a genuinely too-fast cue readable (that needs more on-screen time upstream) — CPL is always enforced, CPS is the capacity floor.\n- Punct+casing = sherpa-onnx CT-Transformer, HF repo `lorneluo\u002Fsherpa-onnx-online-punct-en-2024-08-06` @ `aad05f2d2818122135b0667806628019cb923e84` (~30 MB, EN-only, Apache-2.0; the original csukuangfj repo 404s — re-pointed). KeyBERT (all-MiniLM) for emphasis keywords; alt-profanity-check for masking. All lazy-imported; absent model => stage skipped.\n\n## Pitfalls that actually bite\n1. **ASS override injection.** Cue\u002Fhook text is transcript\u002Fmodel-derived. `{...}` is an ASS override block; a line like `{\\an8}` could reposition or blank text. `escape_ass_text` escapes `\\` FIRST (else the backslashes it adds for braces get doubled), then `{`->`\\{`, `}`->`\\}`, real newlines -> `\\N`. NEVER hand-assemble Dialogue text without this.\n2. **Re-base cue times or captions never show.** Cues are timed against the ORIGINAL video; a clip cut from t=120 s must subtract `source_start` so its first caption is at clip-local t=0, not t=120 (which would never display). Pass the candidate's `sourceStart`. Cues whose end \u003C= start after re-base are dropped.\n3. **ASS colour byte-swap** (see above) — `&HAABBGGRR`, BGR reversed, inverted alpha. Silent wrong colour, no crash.\n4. **Windows path escaping inside the `subtitles=` filter.** Inside `-vf`, `\\`, `:` (drive letter `C:`), `'` and `[]` are filtergraph-special. `_escape_filter_path` wraps in single quotes and escapes `\\`->`\\\\`, `:`->`\\:`, `'`->`\\'`. A raw `C:\\a b\\subs.ass` otherwise breaks the filtergraph parse. The .ass is always passed as an argv path, never a shell string.\n5. **Burn RE-ENCODES; there is no `-c:v` in the burn argv** -> ffmpeg uses the container default encoder at default CRF (~23). Expect generation loss and slow encode. Soft-mux is lossless `-c copy`. If burning to MP4 needs a target quality, extend the argv yourself.\n6. **MP4 soft-mux silently downgrades to `mov_text`** — ASS styling\u002Fkaraoke\u002Fbox are LOST (plain text only). To keep styling in an MP4 you must BURN. Soft-mux into MKV (`-c:s ass`) preserves it.\n7. **Font must be installed for libass to use it.** `Anton`\u002F`Bebas Neue` (karaoke\u002Fcondensed looks) and the curated list are the burn-in fontconfig allowlist; if the face is missing on the host, libass falls back (to `DejaVu Sans`) and the burn looks wrong — and the override layer drops any non-curated font to `Arial` before that. Verify the curated fonts are present in the burn environment.\n8. **`uppercase` must transform per-slice BEFORE escaping, never the assembled string.** Upper-casing the assembled line would corrupt an inserted `{\\b1}` into `{\\B1}` (a broken override). `render_cue_text` upper-cases each raw slice independently so emphasis\u002Fspan offsets stay aligned.\n9. **stderr pipe deadlock (the \"stuck clip\" trap).** ffmpeg chats continuously on stderr even with `-nostats` (banner, stream info, libass FONT logs). `ffmpeg.run` captures `stderr=PIPE` and **drains it on a daemon thread**; if you build your own subprocess and never read stderr, the ~64 KB pipe fills and the encode BLOCKS forever. Always drain stderr (or let `ffmpeg.run` do it). `-progress pipe:1` feeds the progress parser on stdout.\n10. **argv list only — never `shell=True`.** `ffmpeg.run` raises `TypeError` on a shell string. Paths with spaces are safe because every element is a separate argv item.\n11. **A burned track cannot be stripped.** `tracks.strip` raises `HardSubtitleError` for `kind==\"hard\"`. Burn is irreversible; only soft-muxed streams are removable.\n12. **Hook title\u002Fcard timing.** Hook title spans the whole clip (`total_sec`, else last cue \u002F 60 s floor). Hook card is time-boxed to ~first 5 s. Emit them as the FIRST events so they layer above body captions.\n\n## GPU \u002F model notes\n- Burn\u002Fsoft-mux\u002Fstrip are **pure ffmpeg\u002Flibass — CPU, no GPU, no HF model.** Burn is the slow step (full video re-encode); soft-mux\u002Fstrip are near-instant stream-copies.\n- The only model dependency is the OPTIONAL `caption_polish` pass: sherpa-onnx punct model (~30 MB, pinned HF revision above), KeyBERT all-MiniLM, alt-profanity-check. All lazy; absent => that stage is skipped, the timing gate still runs. None require a GPU.\n\n## Worked example — burn OpusClip karaoke onto a 9:16 clip\nA clip cut from t=120 s of the source, with word-aligned cues, exported at 1080x1920:\n```python\nfrom media_studio.features.caption import CaptionEngine\n\nengine = CaptionEngine(settings={\"ffmpegPath\": \"ffmpeg\"})  # runner defaults to ffmpeg.run\nout = engine.render(\n    clip_path=\"clip.mp4\",\n    cues=cues,                # [{index,start,end,text,words:[{text,start,end}]}, ...]\n    out_path=\"clip-karaoke.mp4\",\n    burn=True,                # hardsub (MP4 -> styling MUST be burned, not mov_text)\n    width=1080, height=1920,\n    source_start=120.0,       # re-base: clip-local t=0 == source t=120s\n    karaoke=True,             # -> caption_karaoke.build_karaoke_ass (Anton, yellow\u002Fgreen pop)\n    total_sec=58.0,           # source duration for progress -> pct\n    on_progress=lambda pct, msg: print(f\"{pct:.0f}% {msg}\"),\n)\n```\nUnder the hood this builds the karaoke ASS (per-word events, alternating accent + scale-pop, lower-mid safe area), writes a temp `.ass`, and runs:\n```\nffmpeg -hide_banner -nostdin -y -i clip.mp4 \\\n       -vf subtitles='clip-karaoke.ass' -c:a copy -progress pipe:1 -nostats clip-karaoke.mp4\n```\nA non-zero exit raises `CaptionError`; the temp `.ass` is unlinked regardless.\n\nTo instead keep a removable English track in an MKV (lossless, styling preserved):\n```python\nout = engine.render(\"clip.mkv\", cues, \"clip-sub.mkv\", burn=False,\n                    width=1080, height=1920, source_start=120.0)\n# -> ffmpeg ... -i clip.mkv -i \u003Cass> -map 0:v -map 0:a? -map 1:0 \\\n#       -c:v copy -c:a copy -c:s ass -progress pipe:1 -nostats clip-sub.mkv\n```\n(For MP4 this would emit `-c:s mov_text` and the karaoke styling would be lost — burn for MP4.)\n",{"name":5,"description":7},"main","a298c929b6cbd662e1fce54302ce520d5ceea1a5",".agents\u002Fskills\u002Fcaption-burn","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain","https:\u002F\u002Fgithub.com\u002FPrekzursil","2026-11-02 14:30:57",{"source":8,"skillName":5,"commands":56,"prompt":66,"zipAvailable":67},[57,60,63],{"tool":58,"command":59},"npx","npx skills add Prekzursil\u002Fagent-skills-toolchain --skill caption-burn",{"tool":61,"command":62},"bunx","bunx skills add Prekzursil\u002Fagent-skills-toolchain --skill caption-burn",{"tool":64,"command":65},"pnpm","pnpm dlx skills add Prekzursil\u002Fagent-skills-toolchain --skill caption-burn","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fcaption-burn\u002FSKILL.md",true,[69],{"path":70,"resourceType":71,"resourceTypeDesc":72,"sizeBytes":73,"sourceHtmlUrl":74},"resources\u002Fimplementation-playbook.md","REFERENCE","参考文档",17935,"https:\u002F\u002Fgithub.com\u002Fagency-black\u002FSkills\u002Fblob\u002Fmain\u002FCopia de async-python-patterns\u002Fresources\u002Fimplementation-playbook.md",[76,97,126,153,167,183],{"skillNo":77,"name":78,"nameZh":6,"description":79,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":80,"categories":81,"keywords":82,"publishedAt":44},"01M6DF7D7478R55P46REYH4QAC","clarify-requirements","Resolve genuine pre-execution ambiguity by scanning repo context, generating 1-6 grounded options, and asking 1-3 focused questions via AskUserQuestion. NOT for mid-task use (injection risk); NOT when the goal is already unambiguous; use to disambiguate vague prompts before any file edits.","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Fworkspace-hooks\u002Fskills\u002Fclarify-requirements\u002FSKILL.md",[],[83,84,85,86,87,88,89,90,91,35,92,93,94,95,96],"clarify requirements","clarify","requirements","risk","options","task","prompts","goal","scanning","context","repo","injection","execution","file",{"skillNo":98,"name":99,"nameZh":6,"description":100,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":101,"categories":102,"keywords":106,"publishedAt":44},"01M6DF79CA4BHJS66J8EZDBPWJ","openmm-md","Run FREE, open-source, Windows-native GPU molecular dynamics via the openmm-md MCP (OpenMM + CUDA on the local NVIDIA GPU). Use for protein \u002F protein-ligand MD when you want a free stack or are on Windows without a Schrodinger license. Covers the full workflow (prepare, solvate, minimize, equilibrate, production, analyze, mmgbsa) AND an honest decision guide vs Schrodinger Desmond, including how to run Desmond legitimately on Linux or a cloud GPU with a real license. This is a deliberately LESSER free alternative and does NOT replace the schrodinger MCP tools. Do NOT use it to pick a free tool when a validated FEP+, WaterMap, IFD-MD, or Prime MM-GBSA result is actually required.","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Fmcp\u002Fopenmm-md-mcp\u002Fskill\u002Fopenmm-md\u002FSKILL.md",[103],{"categoryNo":104,"categoryName":105,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[107,108,109,110,111,112,113,114,115,116,117,118,119,120,35,121,122,123,124,125],"openmm md","how","decision","pick","linux","analyze","tool","stack","open","NVIDIA","gpu","license","workflow","windows","free","molecular dynamics","local","do","MCP",{"skillNo":127,"name":128,"nameZh":6,"description":129,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":130,"categories":131,"keywords":138,"publishedAt":44},"01M6DF76C1MG85VEWP8VZCBNMN","visual-review","Take screenshots of web pages and UI using Playwright for visual review and iteration","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fvisual-review\u002FSKILL.md",[132,135],{"categoryNo":133,"categoryName":134,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000003","演示文稿",{"categoryNo":136,"categoryName":137,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量",[139,140,141,142,143,144,145,146,147,148,108,149,150,151,152],"review","visual","visual review","pages","web","false","iteration","take","check","slides","ui","screenshot","playwright","screenshots",{"skillNo":154,"name":155,"nameZh":6,"description":156,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":157,"categories":158,"keywords":159,"publishedAt":44},"01M6DF73AQVCBZKEN0YXEX9FXP","status","Diagnostic status report — shows metaswarm installation state, project setup, and potential issues","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fstatus\u002FSKILL.md",[],[155,160,161,162,163,164,165,166],"setup","report","project","installation","project setup","issues","state",{"skillNo":168,"name":169,"nameZh":6,"description":170,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":171,"categories":172,"keywords":174,"publishedAt":44},"01M6DF6X6C2XB0P6GX76P7TTYV","start","Use when starting work on any task, when the user mentions metaswarm, or when the user wants to begin tracked development work","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fstart\u002FSKILL.md",[173],{"categoryNo":104,"categoryName":105,"taxonomyType":21,"taxonomyTypeDesc":22},[169,175,88,176,177,178,35,179,180,181,182],"start issue","issue","ready","work on issue","user","work","work on","development",{"skillNo":184,"name":160,"nameZh":6,"description":185,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":186,"categories":187,"keywords":188,"publishedAt":44},"01M6DF6QMJ9AJKFKHHKRT005PQ","Interactive project setup — detects your project, configures metaswarm, writes project-local files","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fsetup\u002FSKILL.md",[],[160,162,123,189,164,190],"files","interactive"]