From 1e95a722a78786278a631ba07058dce6d24af3cb Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Tue, 8 Sep 2026 00:02:07 +0800 Subject: [PATCH] Sync third-party and MCP marketplace plugins Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed. --- config/external-sources.lock.json | 20 ++-- .../plugins/caveman/THIRD_PARTY_SOURCE.json | 4 +- .../skills/caveman-compress/scripts/detect.py | 8 +- .../caveman-compress/scripts/validate.py | 20 +++- .../THIRD_PARTY_SOURCE.json | 4 +- .../plugins/mcp-playwright/MCP_SOURCE.json | 2 +- .../next-skills/THIRD_PARTY_SOURCE.json | 4 +- .../oh-my-codex/.codex-plugin/plugin.json | 2 +- .../oh-my-codex/THIRD_PARTY_SOURCE.json | 4 +- plugins/codex/plugins/ppt-master/README.md | 13 +++ .../ppt-master/THIRD_PARTY_SOURCE.json | 4 +- .../ppt-master/references/image-generator.md | 2 +- .../skills/ppt-master/scripts/docs/image.md | 4 +- .../skills/ppt-master/scripts/slice_images.py | 44 ++++++++- .../scripts/tests/test_slice_images.py | 25 +++++ .../scripts/tests/test_video_subtitles.py | 96 +++++++++++++++++++ .../ppt-master/scripts/video_subtitles.py | 86 ++++++++++------- 17 files changed, 274 insertions(+), 68 deletions(-) create mode 100644 plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index 221f00c9..047f45ce 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -24,8 +24,8 @@ "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", "adapter": "codex-plugin", - "commit": "afc3980e024cb31c74ad32e91ed4ac55051f16e9", - "syncedAt": "2026-09-06T15:59:59Z" + "commit": "304fb3b4825c4132c273732b14d2d5e86b54f8e3", + "syncedAt": "2026-09-07T16:00:01Z" }, { "id": "ui-ux-pro-max", @@ -42,8 +42,8 @@ "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", "adapter": "codex-plugin", - "commit": "5184b3d11ac6a1acb7d44b9bfaa31698157cff97", - "syncedAt": "2026-09-05T16:00:00Z" + "commit": "15581d14007fd01fb3f132016741962f34936ca2", + "syncedAt": "2026-09-07T16:00:01Z" }, { "id": "taste-skill", @@ -96,8 +96,8 @@ "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", "adapter": "claude-skill", - "commit": "d3d81fe3cf4cc642de225159586308bbe98eeb4d", - "syncedAt": "2026-09-05T16:00:00Z" + "commit": "44d32733ff4180a32ab0c6a2fd062954168d8721", + "syncedAt": "2026-09-07T16:00:01Z" }, { "id": "grill-me", @@ -123,8 +123,8 @@ "repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git", "ref": "main", "adapter": "claude-skill", - "commit": "5f9951aa6e249f86ebc84b1a12ba8e9e94ba3786", - "syncedAt": "2026-09-04T16:00:00Z" + "commit": "0b133bff5e797a67c09234928d603008cce14b4d", + "syncedAt": "2026-09-07T16:00:01Z" }, { "id": "next-skills", @@ -132,8 +132,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "4fed8eaf197aaa60fd85371352482199a4c2107f", - "syncedAt": "2026-09-06T15:59:59Z" + "commit": "e9c16094f2d3c950c8ad6218e464c3f4f9c1ac67", + "syncedAt": "2026-09-07T16:00:01Z" }, { "id": "shuorenhua", diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json index fb4e8650..6cfffcad 100644 --- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "caveman", "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", - "commit": "5184b3d11ac6a1acb7d44b9bfaa31698157cff97", + "commit": "15581d14007fd01fb3f132016741962f34936ca2", "adapter": "codex-plugin", "sourcePath": "plugins/caveman", - "syncedAt": "2026-09-05T16:00:00Z" + "syncedAt": "2026-09-07T16:00:01Z" } diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py index f51e6b70..b7da1fa8 100644 --- a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/detect.py @@ -5,8 +5,12 @@ import json import re from pathlib import Path -# Extensions that are natural language and compressible -COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} +# Extensions that are natural language and compressible. `.mdc` is a Cursor +# rule: prose with YAML frontmatter, the same shape as a SKILL.md, and the +# format caveman's own `--with-init` writes to `.cursor/rules/caveman.mdc`. +# Content sniffing below runs only for EXTENSIONLESS files, so an extension +# that is on neither list falls through to `unknown` and is never compressed. +COMPRESSIBLE_EXTENSIONS = {".md", ".mdc", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} # Extensions that are code/config and should be skipped SKIP_EXTENSIONS = { diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py index 29d156a2..ddab5fcc 100644 --- a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py @@ -72,6 +72,13 @@ def extract_code_blocks(text): fence must use same char and be at least as long as opening). Supports nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick content). + + Merges fenced and indented blocks by their DOCUMENT POSITION, not by + type. validate_code_blocks compares this list positionally (c1 != c2), + so a type-grouped return (all fenced, then all indented) makes two + blocks that swapped RELATIVE order in the document produce the same + list on both sides: the comparison passes while the invariant it + exists to check (block order preserved) has actually been violated. """ blocks = [] lines = text.split("\n") @@ -82,6 +89,7 @@ def extract_code_blocks(text): if not m: i += 1 continue + start = i fence_char = m.group(2)[0] fence_len = len(m.group(2)) open_line = lines[i] @@ -103,10 +111,11 @@ def extract_code_blocks(text): block_lines.append(lines[i]) i += 1 if closed: - blocks.append("\n".join(block_lines)) + blocks.append((start, "\n".join(block_lines))) # Unclosed fences are silently skipped — they indicate malformed markdown # and including them would cause false-positive validation failures. - return blocks + extract_indented_code_blocks(text) + ordered = sorted(blocks + extract_indented_code_blocks(text), key=lambda pair: pair[0]) + return [block_text for _, block_text in ordered] def extract_indented_code_blocks(text): @@ -123,6 +132,10 @@ def extract_indented_code_blocks(text): prose the compressor SHOULD rewrite. A run is only treated as code when the document is not inside a list and the run is preceded by a blank line — so this adds detections, it never turns existing passes into false failures. + + Returns (start_line, text) pairs rather than bare text: extract_code_blocks + needs each block's document position to merge it with fenced blocks in + order (see that function's docstring). """ blocks = [] lines = text.split("\n") @@ -150,6 +163,7 @@ def extract_indented_code_blocks(text): elif indent == 0: in_list = False if not in_list and previous_blank and indent >= 4: + start = i run = [] while i < n and i not in fenced: current = lines[i] @@ -170,7 +184,7 @@ def extract_indented_code_blocks(text): run.append(current) i += 1 if run: - blocks.append("\n".join(run)) + blocks.append((start, "\n".join(run))) previous_blank = False continue previous_blank = False diff --git a/plugins/codex/plugins/etunel-role-collaboration/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/etunel-role-collaboration/THIRD_PARTY_SOURCE.json index caa70584..8df366ba 100644 --- a/plugins/codex/plugins/etunel-role-collaboration/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/etunel-role-collaboration/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "etunel-role-collaboration", "repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git", "ref": "main", - "commit": "5f9951aa6e249f86ebc84b1a12ba8e9e94ba3786", + "commit": "0b133bff5e797a67c09234928d603008cce14b4d", "adapter": "claude-skill", "sourcePath": "etunel-role-collaboration", - "syncedAt": "2026-09-04T16:00:00Z" + "syncedAt": "2026-09-07T16:00:01Z" } diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index a93f408c..f30c42cc 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-09-06T16:01:58Z" + "syncedAt": "2026-09-07T16:02:06Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index a9fccde8..3d74a90a 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "4fed8eaf197aaa60fd85371352482199a4c2107f", + "commit": "e9c16094f2d3c950c8ad6218e464c3f4f9c1ac67", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-09-06T15:59:59Z" + "syncedAt": "2026-09-07T16:00:01Z" } diff --git a/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json b/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json index 9dd9aa02..63c98b1c 100644 --- a/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/oh-my-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "oh-my-codex", - "version": "0.21.3", + "version": "0.21.4", "description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。", "author": { "name": "Yeachan Heo", diff --git a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json index a02ff462..7a3e2d26 100644 --- a/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/oh-my-codex/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "oh-my-codex", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "ref": "main", - "commit": "afc3980e024cb31c74ad32e91ed4ac55051f16e9", + "commit": "304fb3b4825c4132c273732b14d2d5e86b54f8e3", "adapter": "codex-plugin", "sourcePath": "plugins/oh-my-codex", - "syncedAt": "2026-09-06T15:59:59Z" + "syncedAt": "2026-09-07T16:00:01Z" } diff --git a/plugins/codex/plugins/ppt-master/README.md b/plugins/codex/plugins/ppt-master/README.md index bd4f111a..2b5071a1 100644 --- a/plugins/codex/plugins/ppt-master/README.md +++ b/plugins/codex/plugins/ppt-master/README.md @@ -8,6 +8,7 @@

hugohe3%2Fppt-master | Trendshift + GitHub Trending Repository of the Day

English | [中文](./README_CN.md) @@ -385,6 +386,18 @@ Looking to collaborate, integrate PPT Master into your workflow, or just have qu --- +## Star History + + + + + + Star History Chart + + + +--- + ## Sponsors & Support PPT Master is currently built and maintained primarily by me. Every new template, bug fix, and documentation update takes ongoing resources — currently shared by the sponsors and individual supporters below. diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json index f3cf883c..b45d6177 100644 --- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "ppt-master", "repo": "https://github.com/hugohe3/ppt-master.git", "ref": "main", - "commit": "d3d81fe3cf4cc642de225159586308bbe98eeb4d", + "commit": "44d32733ff4180a32ab0c6a2fd062954168d8721", "adapter": "claude-skill", "sourcePath": "skills/ppt-master", - "syncedAt": "2026-09-05T16:00:00Z" + "syncedAt": "2026-09-07T16:00:01Z" } diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md index 824d497f..9790a7f2 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/references/image-generator.md @@ -126,7 +126,7 @@ A sheet generates compatible transparent **illustration**, **illustrated-icon**, **Sheet prompt convention** — one `page_role: local` item with `image_size` from final placement; spot sheets `text_policy: none`, lettering sheets `embedded`: - **Grid**: derive `aspect_ratio` and `--grid` from the target shape, not a universal square grid. State an invisible logical **R×C grid** and the cell shape (compact square object, tall portrait, wide vignette, wide lettering mark); center each element in about 65% of its cell and keep at least 10% key-only margin on every side — tips, steam, particles, and effects included — so `slice_images.py --strict-alpha` never meets content on a cell edge. Never draw cells, panels, dividers, borders, frames, or alternate gutter colors; never shrink every subject into a square sticker. -- **Key**: one flat chroma key across the sheet — pure `#00FF00`, `#0000FF`, or `#FF0000`, chosen so its color dominates no element or effect — stated as exact HEX before the palette and the subjects (deck colors appear only inside elements), unchanged in every gutter, free of reflections or spill; grain, halftone, and vignette stay inside elements. The key is technical, not deck palette. Every pixel that is not an element is the key itself — no white card, panel, paper, mat, or frame under an element and no second tint of the key; state the key rule first in the prompt and repeat it last, because a model told only "no borders" still paints each element on a white card. +- **Key**: one flat chroma key across the sheet — pure `#00FF00`, `#0000FF`, or `#FF0000`, chosen so its hue is absent from every element and effect (a pure key despills and recovers soft alpha; an opaque element of the key's hue keeps its color, but thin dark strokes of that hue still fringe) — stated as exact HEX before the palette and the subjects (deck colors appear only inside elements), unchanged in every gutter, free of reflections or spill; grain, halftone, and vignette stay inside elements. The key is technical, not deck palette. Every pixel that is not an element is the key itself — no white card, panel, paper, mat, or frame under an element and no second tint of the key; state the key rule first in the prompt and repeat it last, because a model told only "no borders" still paints each element on a white card. - **Identity**: shared `deck_rendering` + `color_scheme`. - **Illustration / illustrated-icon sheet**: name each element and its page or reuse job; for an icon, the compact cue that must survive at placement size; the §5.3 `none` cue. - **Lettering sheet**: exactly one named stable string per cell as the only text, quoted literally; the group's letterform character and treatment, then role, placement/background relationship, relative weight, and energy under §5.3's controlled-authorship default; artistry glyph-bound (silhouette, stroke structure, material, texture, depth, contour-bound light). No topic motifs, scene fragments, icons, detached ribbons, or particles unless the approved treatment is a lettering-plus-illustration lockup; key-only padding, no scene, unrelated copy, labels, watermark, or mockup surface. A single mark on a wide sheet (3:2 or wider) tends to come back with a faded ghost copy of itself above or below the real mark — give a lone mark a narrow band (about 4:1) that leaves no room for a second line, and inspect every lettering slice for a ghost band, not only for the characters. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md index 1b902529..e03b19e8 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/image.md @@ -160,7 +160,9 @@ Create a non-destructive PNG derivative from one bitmap already prepared under crop, mask, rotation, mirror, opacity, shadow, scrim, outline, and overlap remain native SVG/PPT treatments. This tool does not perform semantic background removal: use `slice_images.py --alpha --bg --strict-alpha` for flat-color -keys (a pure red/green/blue key also recovers soft alpha and removes spill), an +keys (a pure red/green/blue key also recovers soft alpha and removes spill from +key-dominant blends, leaving an opaque foreground of the key's hue untouched; thin +dark strokes of that hue can still fringe, so choose the key by hue absence), an already prepared RGBA asset or the active host image editor for a standalone cutout, and [`image-generator.md`](../../references/image-generator.md) §4.4 only for registered subject/base layers. diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py index 4d269f0d..1ae504b6 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/slice_images.py @@ -68,6 +68,11 @@ _SHEET_DIAGNOSTIC_BUCKET_SIZE = 4 # larger tolerance absorbs; farther pixels are content crossing the gutter. _KEY_DRIFT_MAX_TOLERANCE = 48 _KEY_DRIFT_MARGIN = 4 +# A key-dominant pixel whose strongest non-key channel reaches this fraction of +# the key channel is opaque foreground of a key-like hue (malachite green on a +# green key), not a semi-transparent blend with the key; color-to-alpha recovery +# and despill skip it so the element keeps its own color. +_KEY_PURITY_OPAQUE_RATIO = 0.6 # At most this many trim pixels on a touched edge count as isolated drift. _EDGE_DRIFT_MAX_PIXELS = 8 # Corner sample inset (px) and minimum cell fill for the backing-panel notice. @@ -314,13 +319,43 @@ def _chroma_alpha(rgb: Image.Image, bg: tuple[int, int, int]) -> Image.Image: other_channels = [ channel for index, channel in enumerate(channels) if index != key_index ] - key_excess = ImageChops.subtract( + key_blend = _key_blend_mask( channels[key_index], ImageChops.lighter(other_channels[0], other_channels[1]), ) - key_dominance = key_excess.point(lambda value: 255 if value > 0 else 0) opaque = Image.new("L", rgb.size, 255) - return Image.composite(raw_alpha, opaque, key_dominance) + return Image.composite(raw_alpha, opaque, key_blend) + + +def _key_blend_mask(key_channel: Image.Image, other_max: Image.Image) -> Image.Image: + """Mask the pixels that read as a blend of foreground with a pure key. + + A pixel qualifies only when the key channel dominates and the strongest + non-key channel stays below `_KEY_PURITY_OPAQUE_RATIO` of it. An opaque + foreground color that merely shares the key's hue (a malachite green under a + green key) keeps a substantial non-key channel and is left opaque, while + antialiased edges, soft shadows, and glows composited over the key fall + below the ratio and still receive color-to-alpha recovery. + """ + ratio_num = round(_KEY_PURITY_OPAQUE_RATIO * 10) + if hasattr(ImageMath, "lambda_eval"): + blend = ImageMath.lambda_eval( + lambda op: op["convert"]( + (op["key"] > op["other"]) + & (op["other"] * 10 < op["key"] * ratio_num), + "L", + ), + key=key_channel, + other=other_max, + ) + else: + blend = ImageMath.eval( # type: ignore[attr-defined] + "convert((key > other) & (other * 10 < key * ratio), 'L')", + key=key_channel, + other=other_max, + ratio=ratio_num, + ) + return blend.point(lambda value: 255 if value else 0) def _decontaminate_channel( @@ -800,7 +835,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--bg", default=None, help="Background hex color for --trim/--alpha; an exact pure red/green/blue " - "key enables despill and soft-alpha recovery (default: auto-sample)", + "key enables despill and soft-alpha recovery, skipping opaque " + "foreground pixels of a key-like hue (default: auto-sample)", ) parser.add_argument( "--tolerance", type=int, default=18, diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_slice_images.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_slice_images.py index 1ca4ec12..afac0dc1 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_slice_images.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_slice_images.py @@ -29,6 +29,31 @@ SCRIPT = SCRIPTS_DIR / "slice_images.py" class SliceImagesDiagnosticsTests(unittest.TestCase): + def test_pure_key_despill_keeps_opaque_key_hue_foreground_and_recovers_shadow(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sheet_path = root / "sheet.png" + key = (0, 255, 0) + malachite = (63, 143, 108) + image = Image.new("RGB", (240, 100), key) + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 100, 80), fill=malachite) + # A black shadow composited over the key at 50% alpha. + draw.rectangle((140, 20, 220, 80), fill=(0, 128, 0)) + image.save(sheet_path) + + written = slice_sheet( + sheet_path, 1, 1, root / "out", + names=["element"], alpha=True, bg=key, tolerance=18, + ) + element = Image.open(written[0]).convert("RGBA") + + self.assertEqual(element.getpixel((60, 50)), malachite + (255,)) + shadow_r, shadow_g, shadow_b, shadow_a = element.getpixel((180, 50)) + self.assertLessEqual(max(shadow_r, shadow_g, shadow_b), 8) + self.assertTrue(120 <= shadow_a <= 136, shadow_a) + self.assertEqual(element.getpixel((120, 50))[3], 0) + def test_sheet_orientation_is_applied_before_slicing(self) -> None: for orientation, expected_size in ((6, (40, 80)), (None, (80, 40))): with self.subTest(orientation=orientation), tempfile.TemporaryDirectory() as tmp: diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py new file mode 100644 index 00000000..09a40421 --- /dev/null +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_video_subtitles.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Unit tests for video_subtitles.py cue splitting and clause merging.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +import video_subtitles # noqa: E402 + + +PAGE_SRT = """1 +00:00:00,000 --> 00:00:04,000 +We plan, we build, we ship. + +2 +00:00:04,000 --> 00:00:08,000 +Design first, then measure, then iterate. +""" + + +class ClauseMergeTests(unittest.TestCase): + def test_merged_clauses_keep_the_space_that_separated_them(self) -> None: + self.assertEqual( + video_subtitles._split_sentence("We plan, we build, we ship.", 20), + ["We plan, we build,", "we ship."], + ) + self.assertEqual( + video_subtitles._split_sentence( + "Alpha, beta, gamma, delta and epsilon.", 20 + ), + ["Alpha, beta, gamma,", "delta and epsilon."], + ) + + def test_cjk_clauses_merge_without_inventing_a_space(self) -> None: + self.assertEqual( + video_subtitles._split_sentence( + "第一步是调研,第二步是设计,第三步是交付。", 20 + ), + ["第一步是调研,第二步是设计,", "第三步是交付。"], + ) + + def test_short_sentence_is_returned_unsplit(self) -> None: + self.assertEqual( + video_subtitles._split_sentence("We ship, we learn.", 20), + ["We ship, we learn."], + ) + + def test_every_cue_is_a_trimmed_slice_within_the_character_budget(self) -> None: + sentences = ( + "We plan, we build, we ship.", + "Design first, then measure, then iterate on the result.", + "Alpha, beta, gamma, delta and epsilon.", + "第一步是调研,第二步是设计,第三步是交付。", + "One extraordinarily long unbreakable token: supercalifragilistic.", + ) + for max_chars in (3, 8, 20): + for sentence in sentences: + lines = video_subtitles._split_sentence(sentence, max_chars) + with self.subTest(sentence=sentence, max_chars=max_chars): + self.assertTrue(lines) + for line in lines: + self.assertIn(line, " ".join(sentence.split())) + self.assertEqual(line, line.strip()) + self.assertLessEqual( + sum(not c.isspace() for c in line), max_chars + ) + self.assertEqual( + "".join(c for line in lines for c in line if not c.isspace()), + "".join(c for c in sentence if not c.isspace()), + ) + + +class FrozenTranscriptTests(unittest.TestCase): + def test_page_local_srt_text_reaches_the_transcript_with_its_spacing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + subtitle_dir = Path(tmp) / "audio" + subtitle_dir.mkdir() + (subtitle_dir / "01-cover.srt").write_text(PAGE_SRT, encoding="utf-8") + self.assertEqual( + video_subtitles._frozen_transcript_lines(subtitle_dir, 20), + [ + "We plan, we build,", + "we ship.", + "Design first,", + "then measure,", + "then iterate.", + ], + ) diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py index 62e6b02d..84c4ff1c 100644 --- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py +++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/video_subtitles.py @@ -112,72 +112,88 @@ def _parse_srt(path: Path) -> list[SubtitleCue]: return cues -def _display_length(text: str) -> int: - return sum(not character.isspace() for character in text) +def _trim_span(text: str, start: int, end: int) -> tuple[int, int]: + """Return the span with leading and trailing whitespace removed.""" + while start < end and text[start].isspace(): + start += 1 + while end > start and text[end - 1].isspace(): + end -= 1 + return start, end -def _hard_split(text: str, max_chars: int) -> list[str]: - """Split an overlong clause without dropping characters.""" - output: list[str] = [] - remaining = text.strip() - while _display_length(remaining) > max_chars: +def _display_length(text: str, start: int, end: int) -> int: + return sum(not character.isspace() for character in text[start:end]) + + +def _hard_split_span( + text: str, + span: tuple[int, int], + max_chars: int, +) -> list[tuple[int, int]]: + """Split an overlong clause span without dropping characters.""" + start, end = _trim_span(text, *span) + parts: list[tuple[int, int]] = [] + while _display_length(text, start, end) > max_chars: visible = 0 - split_at = 0 - whitespace_split = 0 - for index, character in enumerate(remaining, 1): - if character.isspace(): - whitespace_split = index + split_at = start + whitespace_split = start + for index in range(start, end): + if text[index].isspace(): + whitespace_split = index + 1 continue visible += 1 if visible > max_chars: break - split_at = index - if whitespace_split and whitespace_split <= split_at: + split_at = index + 1 + if whitespace_split > start and whitespace_split <= split_at: split_at = whitespace_split - if split_at <= 0: + if split_at <= start: raise ValueError("Unable to split an overlong subtitle clause") - output.append(remaining[:split_at].strip()) - remaining = remaining[split_at:].strip() - if remaining: - output.append(remaining) - return output + part = _trim_span(text, start, split_at) + if part[0] < part[1]: + parts.append(part) + start = _trim_span(text, split_at, end)[0] + part = _trim_span(text, start, end) + if part[0] < part[1]: + parts.append(part) + return parts def _split_sentence(text: str, max_chars: int) -> list[str]: """Keep one sentence unless its display length requires clause splitting.""" sentence = re.sub(r"\s+", " ", text).strip() - if _display_length(sentence) <= max_chars: + if _display_length(sentence, 0, len(sentence)) <= max_chars: return [sentence] - clauses: list[str] = [] + clauses: list[tuple[int, int]] = [] start = 0 for index, character in enumerate(sentence): if character not in _CLAUSE_END: continue - clause = sentence[start:index + 1].strip() - if clause: + clause = _trim_span(sentence, start, index + 1) + if clause[0] < clause[1]: clauses.append(clause) start = index + 1 - tail = sentence[start:].strip() - if tail: + tail = _trim_span(sentence, start, len(sentence)) + if tail[0] < tail[1]: clauses.append(tail) atoms = [ part for clause in clauses - for part in _hard_split(clause, max_chars) + for part in _hard_split_span(sentence, clause, max_chars) ] - output: list[str] = [] + merged: list[tuple[int, int]] = [] for atom in atoms: - if not output: - output.append(atom) + if not merged: + merged.append(atom) continue - candidate = f"{output[-1]}{atom}" - if _display_length(candidate) <= max_chars: - output[-1] = candidate + candidate = (merged[-1][0], atom[1]) + if _display_length(sentence, *candidate) <= max_chars: + merged[-1] = candidate else: - output.append(atom) - return output + merged.append(atom) + return [sentence[span_start:span_end] for span_start, span_end in merged] def _page_subtitle_paths(subtitle_dir: Path) -> list[Path]: