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 @@
+
English | [中文](./README_CN.md)
@@ -385,6 +386,18 @@ Looking to collaborate, integrate PPT Master into your workflow, or just have qu
---
+## Star History
+
+
+
+
+
+
+
+
+
+---
+
## 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]: