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:
1 parent
a0f54aea5d
commit
1e95a722a7
17 files changed
+274
-68
No files matched your search
@@ -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&utm_medium=badge&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&utm_medium=badge&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:
|
||||||
|
|||||||
+96
@@ -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]:
|
||||||
|
|||||||
Reference in new issue
Block a user