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.
This commit is contained in:
KeyInfo Bot committed 2026-09-08 00:02:07 +08:00
1 parent a0f54aea5d
commit 1e95a722a7
17 files changed
+274 -68

No files matched your search

+10 -10
View File
@@ -24,8 +24,8 @@
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main", "ref": "main",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"commit": "afc3980e024cb31c74ad32e91ed4ac55051f16e9", "commit": "304fb3b4825c4132c273732b14d2d5e86b54f8e3",
"syncedAt": "2026-09-06T15:59:59Z" "syncedAt": "2026-09-07T16:00:01Z"
}, },
{ {
"id": "ui-ux-pro-max", "id": "ui-ux-pro-max",
@@ -42,8 +42,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"commit": "5184b3d11ac6a1acb7d44b9bfaa31698157cff97", "commit": "15581d14007fd01fb3f132016741962f34936ca2",
"syncedAt": "2026-09-05T16:00:00Z" "syncedAt": "2026-09-07T16:00:01Z"
}, },
{ {
"id": "taste-skill", "id": "taste-skill",
@@ -96,8 +96,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "d3d81fe3cf4cc642de225159586308bbe98eeb4d", "commit": "44d32733ff4180a32ab0c6a2fd062954168d8721",
"syncedAt": "2026-09-05T16:00:00Z" "syncedAt": "2026-09-07T16:00:01Z"
}, },
{ {
"id": "grill-me", "id": "grill-me",
@@ -123,8 +123,8 @@
"repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git", "repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "5f9951aa6e249f86ebc84b1a12ba8e9e94ba3786", "commit": "0b133bff5e797a67c09234928d603008cce14b4d",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-07T16:00:01Z"
}, },
{ {
"id": "next-skills", "id": "next-skills",
@@ -132,8 +132,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "4fed8eaf197aaa60fd85371352482199a4c2107f", "commit": "e9c16094f2d3c950c8ad6218e464c3f4f9c1ac67",
"syncedAt": "2026-09-06T15:59:59Z" "syncedAt": "2026-09-07T16:00:01Z"
}, },
{ {
"id": "shuorenhua", "id": "shuorenhua",
@@ -2,8 +2,8 @@
"sourceId": "caveman", "sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"commit": "5184b3d11ac6a1acb7d44b9bfaa31698157cff97", "commit": "15581d14007fd01fb3f132016741962f34936ca2",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"sourcePath": "plugins/caveman", "sourcePath": "plugins/caveman",
"syncedAt": "2026-09-05T16:00:00Z" "syncedAt": "2026-09-07T16:00:01Z"
} }
@@ -5,8 +5,12 @@ import json
import re import re
from pathlib import Path from pathlib import Path
# Extensions that are natural language and compressible # Extensions that are natural language and compressible. `.mdc` is a Cursor
COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} # 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 # Extensions that are code/config and should be skipped
SKIP_EXTENSIONS = { SKIP_EXTENSIONS = {
@@ -72,6 +72,13 @@ def extract_code_blocks(text):
fence must use same char and be at least as long as opening). Supports 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 nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick
content). 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 = [] blocks = []
lines = text.split("\n") lines = text.split("\n")
@@ -82,6 +89,7 @@ def extract_code_blocks(text):
if not m: if not m:
i += 1 i += 1
continue continue
start = i
fence_char = m.group(2)[0] fence_char = m.group(2)[0]
fence_len = len(m.group(2)) fence_len = len(m.group(2))
open_line = lines[i] open_line = lines[i]
@@ -103,10 +111,11 @@ def extract_code_blocks(text):
block_lines.append(lines[i]) block_lines.append(lines[i])
i += 1 i += 1
if closed: if closed:
blocks.append("\n".join(block_lines)) blocks.append((start, "\n".join(block_lines)))
# Unclosed fences are silently skipped — they indicate malformed markdown # Unclosed fences are silently skipped — they indicate malformed markdown
# and including them would cause false-positive validation failures. # 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): 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 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 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. 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 = [] blocks = []
lines = text.split("\n") lines = text.split("\n")
@@ -150,6 +163,7 @@ def extract_indented_code_blocks(text):
elif indent == 0: elif indent == 0:
in_list = False in_list = False
if not in_list and previous_blank and indent >= 4: if not in_list and previous_blank and indent >= 4:
start = i
run = [] run = []
while i < n and i not in fenced: while i < n and i not in fenced:
current = lines[i] current = lines[i]
@@ -170,7 +184,7 @@ def extract_indented_code_blocks(text):
run.append(current) run.append(current)
i += 1 i += 1
if run: if run:
blocks.append("\n".join(run)) blocks.append((start, "\n".join(run)))
previous_blank = False previous_blank = False
continue continue
previous_blank = False previous_blank = False
@@ -2,8 +2,8 @@
"sourceId": "etunel-role-collaboration", "sourceId": "etunel-role-collaboration",
"repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git", "repo": "https://git.playones.com/chenmingxuan/EP-Hub-Skill.git",
"ref": "main", "ref": "main",
"commit": "5f9951aa6e249f86ebc84b1a12ba8e9e94ba3786", "commit": "0b133bff5e797a67c09234928d603008cce14b4d",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "etunel-role-collaboration", "sourcePath": "etunel-role-collaboration",
"syncedAt": "2026-09-04T16:00:00Z" "syncedAt": "2026-09-07T16:00:01Z"
} }
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-09-06T16:01:58Z" "syncedAt": "2026-09-07T16:02:06Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "4fed8eaf197aaa60fd85371352482199a4c2107f", "commit": "e9c16094f2d3c950c8ad6218e464c3f4f9c1ac67",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-09-06T15:59:59Z" "syncedAt": "2026-09-07T16:00:01Z"
} }
@@ -1,6 +1,6 @@
{ {
"name": "oh-my-codex", "name": "oh-my-codex",
"version": "0.21.3", "version": "0.21.4",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。", "description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": { "author": {
"name": "Yeachan Heo", "name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex", "sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git", "repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main", "ref": "main",
"commit": "afc3980e024cb31c74ad32e91ed4ac55051f16e9", "commit": "304fb3b4825c4132c273732b14d2d5e86b54f8e3",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex", "sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-09-06T15:59:59Z" "syncedAt": "2026-09-07T16:00:01Z"
} }
@@ -8,6 +8,7 @@
<p align="center"> <p align="center">
<a href="https://trendshift.io/repositories/25760?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-25760" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25760" alt="hugohe3%2Fppt-master | Trendshift" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/25760?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-25760" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25760" alt="hugohe3%2Fppt-master | Trendshift" width="250" height="55"/></a>
<a href="https://www.star-history.com/hugohe3/ppt-master"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=hugohe3/ppt-master&type=trending&theme=dark" /><img alt="GitHub Trending Repository of the Day" src="https://api.star-history.com/badge?repo=hugohe3/ppt-master&type=trending" width="198" height="55" /></picture></a>
</p> </p>
English | [中文](./README_CN.md) English | [中文](./README_CN.md)
@@ -385,6 +386,18 @@ Looking to collaborate, integrate PPT Master into your workflow, or just have qu
--- ---
## Star History
<a href="https://star-history.com/#hugohe3/ppt-master&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hugohe3/ppt-master&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hugohe3/ppt-master&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hugohe3/ppt-master&type=Date" />
</picture>
</a>
---
## Sponsors & Support ## 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. 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.
@@ -2,8 +2,8 @@
"sourceId": "ppt-master", "sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git", "repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main", "ref": "main",
"commit": "d3d81fe3cf4cc642de225159586308bbe98eeb4d", "commit": "44d32733ff4180a32ab0c6a2fd062954168d8721",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/ppt-master", "sourcePath": "skills/ppt-master",
"syncedAt": "2026-09-05T16:00:00Z" "syncedAt": "2026-09-07T16:00:01Z"
} }
@@ -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`: **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. - **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`. - **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. - **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. - **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.
@@ -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 crop, mask, rotation, mirror, opacity, shadow, scrim, outline, and overlap remain
native SVG/PPT treatments. This tool does not perform semantic background native SVG/PPT treatments. This tool does not perform semantic background
removal: use `slice_images.py --alpha --bg <key> --strict-alpha` for flat-color removal: use `slice_images.py --alpha --bg <key> --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 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 [`image-generator.md`](../../references/image-generator.md) §4.4 only for
registered subject/base layers. registered subject/base layers.
@@ -68,6 +68,11 @@ _SHEET_DIAGNOSTIC_BUCKET_SIZE = 4
# larger tolerance absorbs; farther pixels are content crossing the gutter. # larger tolerance absorbs; farther pixels are content crossing the gutter.
_KEY_DRIFT_MAX_TOLERANCE = 48 _KEY_DRIFT_MAX_TOLERANCE = 48
_KEY_DRIFT_MARGIN = 4 _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. # At most this many trim pixels on a touched edge count as isolated drift.
_EDGE_DRIFT_MAX_PIXELS = 8 _EDGE_DRIFT_MAX_PIXELS = 8
# Corner sample inset (px) and minimum cell fill for the backing-panel notice. # 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 = [ other_channels = [
channel for index, channel in enumerate(channels) if index != key_index channel for index, channel in enumerate(channels) if index != key_index
] ]
key_excess = ImageChops.subtract( key_blend = _key_blend_mask(
channels[key_index], channels[key_index],
ImageChops.lighter(other_channels[0], other_channels[1]), 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) 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( def _decontaminate_channel(
@@ -800,7 +835,8 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument( parser.add_argument(
"--bg", default=None, "--bg", default=None,
help="Background hex color for --trim/--alpha; an exact pure red/green/blue " 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( parser.add_argument(
"--tolerance", type=int, default=18, "--tolerance", type=int, default=18,
@@ -29,6 +29,31 @@ SCRIPT = SCRIPTS_DIR / "slice_images.py"
class SliceImagesDiagnosticsTests(unittest.TestCase): 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: def test_sheet_orientation_is_applied_before_slicing(self) -> None:
for orientation, expected_size in ((6, (40, 80)), (None, (80, 40))): for orientation, expected_size in ((6, (40, 80)), (None, (80, 40))):
with self.subTest(orientation=orientation), tempfile.TemporaryDirectory() as tmp: with self.subTest(orientation=orientation), tempfile.TemporaryDirectory() as tmp:
@@ -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.",
],
)
@@ -112,72 +112,88 @@ def _parse_srt(path: Path) -> list[SubtitleCue]:
return cues return cues
def _display_length(text: str) -> int: def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
return sum(not character.isspace() for character in text) """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]: def _display_length(text: str, start: int, end: int) -> int:
"""Split an overlong clause without dropping characters.""" return sum(not character.isspace() for character in text[start:end])
output: list[str] = []
remaining = text.strip()
while _display_length(remaining) > max_chars: 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 visible = 0
split_at = 0 split_at = start
whitespace_split = 0 whitespace_split = start
for index, character in enumerate(remaining, 1): for index in range(start, end):
if character.isspace(): if text[index].isspace():
whitespace_split = index whitespace_split = index + 1
continue continue
visible += 1 visible += 1
if visible > max_chars: if visible > max_chars:
break break
split_at = index split_at = index + 1
if whitespace_split and whitespace_split <= split_at: if whitespace_split > start and whitespace_split <= split_at:
split_at = whitespace_split split_at = whitespace_split
if split_at <= 0: if split_at <= start:
raise ValueError("Unable to split an overlong subtitle clause") raise ValueError("Unable to split an overlong subtitle clause")
output.append(remaining[:split_at].strip()) part = _trim_span(text, start, split_at)
remaining = remaining[split_at:].strip() if part[0] < part[1]:
if remaining: parts.append(part)
output.append(remaining) start = _trim_span(text, split_at, end)[0]
return output 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]: def _split_sentence(text: str, max_chars: int) -> list[str]:
"""Keep one sentence unless its display length requires clause splitting.""" """Keep one sentence unless its display length requires clause splitting."""
sentence = re.sub(r"\s+", " ", text).strip() sentence = re.sub(r"\s+", " ", text).strip()
if _display_length(sentence) <= max_chars: if _display_length(sentence, 0, len(sentence)) <= max_chars:
return [sentence] return [sentence]
clauses: list[str] = [] clauses: list[tuple[int, int]] = []
start = 0 start = 0
for index, character in enumerate(sentence): for index, character in enumerate(sentence):
if character not in _CLAUSE_END: if character not in _CLAUSE_END:
continue continue
clause = sentence[start:index + 1].strip() clause = _trim_span(sentence, start, index + 1)
if clause: if clause[0] < clause[1]:
clauses.append(clause) clauses.append(clause)
start = index + 1 start = index + 1
tail = sentence[start:].strip() tail = _trim_span(sentence, start, len(sentence))
if tail: if tail[0] < tail[1]:
clauses.append(tail) clauses.append(tail)
atoms = [ atoms = [
part part
for clause in clauses 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: for atom in atoms:
if not output: if not merged:
output.append(atom) merged.append(atom)
continue continue
candidate = f"{output[-1]}{atom}" candidate = (merged[-1][0], atom[1])
if _display_length(candidate) <= max_chars: if _display_length(sentence, *candidate) <= max_chars:
output[-1] = candidate merged[-1] = candidate
else: else:
output.append(atom) merged.append(atom)
return output return [sentence[span_start:span_end] for span_start, span_end in merged]
def _page_subtitle_paths(subtitle_dir: Path) -> list[Path]: def _page_subtitle_paths(subtitle_dir: Path) -> list[Path]: