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
5589889631
commit
238a435e7d
21 files changed
+398
-106
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-automation",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
|
||||
"author": {
|
||||
"name": "chenmingxuan",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"sourceId": "artemis-automation",
|
||||
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
|
||||
"ref": "v1.0.0",
|
||||
"commit": "2ec563c07943d4d651a5a2875314a2e4f3ca1126",
|
||||
"ref": "v1.0.1",
|
||||
"commit": "cec9898e4c688c0e4cc44282306a18b37df98f12",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/artemis-automation",
|
||||
"syncedAt": "2026-09-22T16:42:07Z"
|
||||
"syncedAt": "2026-09-23T16:00:00Z"
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-09-22T16:44:16Z"
|
||||
"syncedAt": "2026-09-23T16:02:05Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "next-skills",
|
||||
"repo": "https://github.com/vercel/next.js.git",
|
||||
"ref": "canary",
|
||||
"commit": "bf53f53c7da622c7ee366cef50aa9ba4bb8143db",
|
||||
"commit": "8ff7f2bd06226f1e11ab4772a3c092f8147b74d2",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-09-22T16:42:07Z"
|
||||
"syncedAt": "2026-09-23T16:00:00Z"
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "ppt-master",
|
||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||
"ref": "main",
|
||||
"commit": "fb478a93cbb8d359dea33b75687a1bfdb6f504cd",
|
||||
"commit": "481e057ecd9f5ff094c9c789b17b2d1331e278e8",
|
||||
"adapter": "claude-skill",
|
||||
"sourcePath": "skills/ppt-master",
|
||||
"syncedAt": "2026-09-22T16:08:55Z"
|
||||
"syncedAt": "2026-09-23T16:00:00Z"
|
||||
}
|
||||
@@ -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 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; on a dark deck the despilled fringe takes the key's complementary hue — white chalk keyed on pure blue leaves a yellow-green halo on slate — so plan two runs for them: slice with the pure key first, then rerun with the measured ground colour `--strict-alpha` names, which skips despill) — 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.
|
||||
|
||||
@@ -66,6 +66,7 @@ from server_common import ( # noqa: E402
|
||||
find_free_port as _find_free_port,
|
||||
lock_pid as _lock_pid,
|
||||
open_preview_browser,
|
||||
plain_request_log,
|
||||
popen_detached as _popen_detached,
|
||||
process_alive as _process_alive,
|
||||
read_lock as _read_lock,
|
||||
@@ -2807,6 +2808,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
format='[%(asctime)s] [%(levelname)s] confirm_ui: %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
)
|
||||
plain_request_log()
|
||||
|
||||
if args.port is not None:
|
||||
try:
|
||||
|
||||
@@ -128,6 +128,10 @@ Native path (no external binary required):
|
||||
|
||||
Pandoc fallback (only if you need these):
|
||||
- `.doc`, `.odt`, `.rtf`, `.tex`/`.latex`, `.rst`, `.org`, `.typ`
|
||||
- `.typ` evaluates under pandoc, so a real project file that imports its template,
|
||||
a package, or a custom function fails there; it then (or without pandoc) keeps the
|
||||
source text with `=` headings mapped to Markdown and raw blocks, markup, math, and
|
||||
code calls verbatim, recorded as a conversion-profile warning.
|
||||
|
||||
```bash
|
||||
python3 scripts/source_to_md/doc_to_md.py lecture.docx
|
||||
|
||||
@@ -163,14 +163,20 @@ crop, mask, rotation, mirror, opacity, shadow, scrim, outline, and overlap remai
|
||||
native SVG/PPT treatments. This tool does not perform semantic background
|
||||
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 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;
|
||||
key-dominant blends — including a light element's edge next to the key — taking
|
||||
the recovered key channel from the strongest other channel so shadows stay grey,
|
||||
while an opaque foreground of the key's hue stays untouched; thin dark strokes of
|
||||
that hue can still fringe, so choose the key by hue absence;
|
||||
strict alpha diagnoses off-key haze from the four 10% key-only margins, allowing
|
||||
soft shadows and glows on a clean key; a key-hued ground that is not flat —
|
||||
a gradient, lighting, or texture painted by the image model — is keyed by
|
||||
key-channel dominance against a local ground estimate instead of one colour,
|
||||
with a notice to inspect the cut, while a flat ground that merely differs from
|
||||
the stated key is still rejected with the measured colour to rerun with), an
|
||||
with a notice to inspect the cut, as is a flat ground farther from every pure
|
||||
key than drift explains (the measured `--bg` rerun, or the sheet's own ground
|
||||
without `--bg`) so shadows cast on it clear — a measured ground within drift
|
||||
of a pure key keeps pure-key recovery — while a flat ground that merely
|
||||
differs from the stated key is still rejected with the measured colour to rerun
|
||||
with), 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.
|
||||
@@ -293,7 +299,7 @@ python3 scripts/image_search.py "abstract gradient" \
|
||||
|
||||
Suitability & manual replacement (a web top hit is metadata-relevant, not guaranteed visually right):
|
||||
|
||||
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution). A downloaded camera multi-picture JPEG (MPO, common among Commons originals) is rewritten as its primary frame before validation so every later consumer sees a single-frame JPEG.
|
||||
- By default only the best match is downloaded, plus a downscaled review copy at `images/.review/<stem>.jpg` (the placed asset stays full-resolution). A downloaded camera multi-picture JPEG (MPO, common among Commons originals) is rewritten as its primary frame before validation so every later consumer sees a single-frame JPEG. An original above Pillow's decompression-warning size (about 89.5 megapixels) is first reduced by the smallest power of two that fits, keeping format, EXIF, and ICC data, so later readers neither warn nor decode hundreds of megabytes.
|
||||
- For exact subjects (landmarks, people, companies, products), use `--require-terms` or batch `required_terms` so visually plausible but wrong metadata is rejected before ranking. Example: `--require-terms Chongqing --require-terms "Jiefangbei|Liberation Monument"`. Keep proper-name / geography anchors; do not broaden to generic terms like `canyon`, `stone pillar`, or `ancient town` just to improve coverage.
|
||||
- When the current Generate agent can inspect images, use `--save-candidates`. The tool saves only the first ranked page of review-eligible provider previews (**8 by default**), writes `candidates/<stem>/review_sheet.jpg`, and leaves the target image and `image_sources.json` untouched. Standalone CLI use remains best-only unless this flag is explicit.
|
||||
- Compare the thumbnail set against the active Reference/Crop Policy. Only after one passes, run `--promote candidate_03.jpg --filename <name>.jpg`; this downloads and validates exactly that original. In batch mode, pass the same `--batch images/image_queries.json` so `Needs-Selection` becomes `Sourced`.
|
||||
|
||||
@@ -113,7 +113,8 @@ comment slot and retains the whole-spec `base_current` comparison instead.
|
||||
`annotations.jsonl` appends `saved`, `updated`, `removed`,
|
||||
and `annotation_applied` events. `storage.guard` serializes browser/CLI
|
||||
sidecar transactions and edit-log access across processes. `edits.jsonl` appends `ts`, `key`, `title`,
|
||||
`before`, `after`, `before_sha256`, and `after_sha256` for each changed block.
|
||||
`before`, `after` (the block text), and `before_sha256` / `after_sha256` (whole-spec
|
||||
hashes) for each changed block.
|
||||
|
||||
`check_spec_annotations.py` prints compact JSON with pending comments, their
|
||||
current baseline statuses, and unread direct-edit summaries (`ts`, `key`, `title`,
|
||||
|
||||
@@ -48,6 +48,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import warnings
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -487,6 +488,55 @@ def _normalize_multi_frame_jpeg(path: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _fit_pixel_budget(path: Path) -> Optional[tuple[tuple[int, int], tuple[int, int]]]:
|
||||
"""Halve in place an original above Pillow's decompression-warning size.
|
||||
|
||||
Wikimedia and similar hosts serve 100+ megapixel originals. Every later
|
||||
Pillow reader (checker, finalizer, exporter) would warn and decode hundreds
|
||||
of megabytes for a picture no slide shows at that size. The saved copy is
|
||||
reduced by the smallest power of two that fits — decoded at that scale for
|
||||
JPEG — keeping its format, EXIF, and ICC data. Returns the old and new sizes
|
||||
when the file was rewritten.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
limit = Image.MAX_IMAGE_PIXELS
|
||||
if not limit:
|
||||
return None
|
||||
try:
|
||||
with Image.open(path) as source:
|
||||
width, height = source.size
|
||||
if width * height <= limit:
|
||||
return None
|
||||
factor = 2
|
||||
while (width // factor) * (height // factor) > limit:
|
||||
factor *= 2
|
||||
fmt = source.format or "JPEG"
|
||||
save_kwargs: dict[str, object] = {"quality": 90} if fmt in ("JPEG", "WEBP") else {}
|
||||
for key in ("exif", "icc_profile"):
|
||||
value = source.info.get(key)
|
||||
if value:
|
||||
save_kwargs[key] = value
|
||||
source.draft(source.mode, (width // factor, height // factor))
|
||||
reduced = source.reduce(max(1, source.size[0] // (width // factor)))
|
||||
except (OSError, ValueError, SyntaxError):
|
||||
return None
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{path.stem}.fit.", suffix=path.suffix, dir=str(path.parent))
|
||||
os.close(fd)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
reduced.save(temp_path, fmt, **save_kwargs)
|
||||
os.replace(temp_path, path)
|
||||
except (OSError, ValueError):
|
||||
temp_path.unlink(missing_ok=True)
|
||||
return None
|
||||
finally:
|
||||
reduced.close()
|
||||
return (width, height), reduced.size
|
||||
|
||||
|
||||
def _stage_and_validate_image(
|
||||
output_path: Path,
|
||||
materialize: Callable[[Path], object],
|
||||
@@ -508,6 +558,17 @@ def _stage_and_validate_image(
|
||||
keep_temp = False
|
||||
try:
|
||||
materialize(temp_path)
|
||||
with warnings.catch_warnings():
|
||||
# The oversized original is exactly what the next call removes.
|
||||
warnings.filterwarnings("ignore", message=r"Image size \(\d+ pixels\) exceeds limit")
|
||||
fitted = _fit_pixel_budget(temp_path)
|
||||
if fitted is not None:
|
||||
(old_w, old_h), (new_w, new_h) = fitted
|
||||
print(
|
||||
f" downscaled a {old_w}x{old_h} original to {new_w}x{new_h}: "
|
||||
"above Pillow's decompression-warning size",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if _normalize_multi_frame_jpeg(temp_path):
|
||||
print(
|
||||
" normalized a multi-frame camera JPEG (MPO) to its primary frame",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
PPT Master - Local Preview Server Helpers
|
||||
|
||||
Shared per-project mutual-exclusion (lock) and liveness helpers for the local
|
||||
Flask preview servers (`svg_editor/server.py`, `confirm_ui/server.py`). Each
|
||||
Flask preview servers (`svg_editor/server.py`, `confirm_ui/server.py`, `spec_review/server.py`). Each
|
||||
server keeps its own lock filename and Flask app; this module owns browser dispatch, the
|
||||
cross-platform process-liveness check and the claim/read/release lock logic so
|
||||
the two servers cannot drift apart.
|
||||
the servers cannot drift apart.
|
||||
|
||||
Usage:
|
||||
from server_common import find_free_port, validate_port
|
||||
@@ -20,9 +20,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -120,6 +122,31 @@ def find_free_port(preferred: int, host: str = '127.0.0.1', span: int = 50) -> i
|
||||
)
|
||||
|
||||
|
||||
_ANSI_SGR = re.compile(r'\x1b\[[0-9;]*m')
|
||||
|
||||
|
||||
class _PlainRequestLog(logging.Filter):
|
||||
"""Strip werkzeug's ANSI request styling from log arguments."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if isinstance(record.args, tuple):
|
||||
record.args = tuple(
|
||||
_ANSI_SGR.sub('', arg) if isinstance(arg, str) else arg
|
||||
for arg in record.args
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def plain_request_log() -> None:
|
||||
"""Keep request lines free of terminal colour when stderr is not a terminal.
|
||||
|
||||
werkzeug styles request lines unconditionally on POSIX; a detached server
|
||||
writes them to its component ``server.log``, where the escapes are noise.
|
||||
"""
|
||||
if not sys.stderr.isatty():
|
||||
logging.getLogger('werkzeug').addFilter(_PlainRequestLog())
|
||||
|
||||
|
||||
def popen_detached(
|
||||
args: list[str],
|
||||
*,
|
||||
|
||||
@@ -73,6 +73,13 @@ _KEY_DRIFT_MARGIN = 4
|
||||
# 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
|
||||
# An element edge blended with the key can keep enough non-key channel to pass
|
||||
# as opaque (a cream or teal edge over green). Within this radius of recovered
|
||||
# ground, a pixel whose key dominance rises this far above the lowest in its
|
||||
# neighbourhood is recovered as a blend; a hard edge of a key-hued colour has
|
||||
# no such rise and stays opaque.
|
||||
_KEY_EDGE_RADIUS = 2
|
||||
_KEY_EDGE_MIN_RISE = 16
|
||||
# At most this many trim pixels on a touched edge count as isolated drift.
|
||||
_EDGE_DRIFT_MAX_PIXELS = 8
|
||||
# Semi-transparent coverage nominates a haze candidate; only a failed key-only
|
||||
@@ -308,55 +315,38 @@ def _nearest_pure_key(
|
||||
return best
|
||||
|
||||
|
||||
def _channel_alpha(channel: Image.Image, bg_value: int) -> Image.Image:
|
||||
"""Return the minimum alpha that can explain one channel over a key."""
|
||||
lut = []
|
||||
for value in range(256):
|
||||
if value > bg_value:
|
||||
denominator = 255 - bg_value
|
||||
alpha = 255 if denominator == 0 else round(
|
||||
(value - bg_value) * 255 / denominator
|
||||
)
|
||||
elif value < bg_value:
|
||||
alpha = 255 if bg_value == 0 else round(
|
||||
(bg_value - value) * 255 / bg_value
|
||||
)
|
||||
else:
|
||||
alpha = 0
|
||||
lut.append(alpha)
|
||||
return channel.point(lut)
|
||||
def _key_edge_band(dominance: Image.Image, ground: Image.Image) -> Image.Image:
|
||||
"""Mask element edge pixels blended with the key that the purity ratio keeps opaque."""
|
||||
size = 2 * _KEY_EDGE_RADIUS + 1
|
||||
rise = ImageChops.subtract(dominance, dominance.filter(ImageFilter.MinFilter(size)))
|
||||
rising = rise.point(lambda value: 255 if value >= _KEY_EDGE_MIN_RISE else 0)
|
||||
return ImageChops.multiply(rising, ground.filter(ImageFilter.MaxFilter(size)))
|
||||
|
||||
|
||||
def _chroma_alpha(rgb: Image.Image, bg: tuple[int, int, int]) -> Image.Image:
|
||||
"""Recover foreground opacity for a pure single-channel chroma key.
|
||||
def _pure_key_recovery(rgb: Image.Image, key_index: int) -> tuple[Image.Image, Image.Image]:
|
||||
"""Recover opacity and colour over a pure single-channel chroma key.
|
||||
|
||||
A non-key-dominant pixel is treated as opaque foreground. A key-dominant
|
||||
pixel uses color-to-alpha recovery, which preserves soft shadows, glows,
|
||||
and antialiased edges without making an ordinary solid foreground color
|
||||
unnecessarily translucent.
|
||||
A key blend takes the minimum opacity that explains every channel, which
|
||||
preserves soft shadows, glows, and antialiased edges without making a solid
|
||||
foreground translucent. Un-premultiplying cannot recover the key channel,
|
||||
so the foreground's key channel is taken as its strongest non-key channel:
|
||||
spill leaves the edge, and a soft shadow comes back grey instead of tinted
|
||||
with the key's complement. A non-key-dominant pixel, or an opaque colour
|
||||
that merely shares the key's hue, keeps full opacity and its own colour.
|
||||
"""
|
||||
channels = rgb.split()
|
||||
channel_alphas = [
|
||||
_channel_alpha(channel, bg_value)
|
||||
for channel, bg_value in zip(channels, bg)
|
||||
]
|
||||
raw_alpha = ImageChops.lighter(
|
||||
ImageChops.lighter(channel_alphas[0], channel_alphas[1]),
|
||||
channel_alphas[2],
|
||||
)
|
||||
key_index = _pure_chroma_channel(bg)
|
||||
if key_index is None:
|
||||
return raw_alpha
|
||||
|
||||
other_channels = [
|
||||
channel for index, channel in enumerate(channels) if index != key_index
|
||||
]
|
||||
key_blend = _key_blend_mask(
|
||||
channels[key_index],
|
||||
ImageChops.lighter(other_channels[0], other_channels[1]),
|
||||
)
|
||||
opaque = Image.new("L", rgb.size, 255)
|
||||
return Image.composite(raw_alpha, opaque, key_blend)
|
||||
key = channels[key_index]
|
||||
others = [channel for index, channel in enumerate(channels) if index != key_index]
|
||||
other = ImageChops.lighter(others[0], others[1])
|
||||
blend = _key_blend_mask(key, other)
|
||||
raw_alpha = ImageChops.lighter(other, ImageChops.invert(key))
|
||||
ground = ImageChops.multiply(blend, raw_alpha.point(lambda value: 255 if value < 128 else 0))
|
||||
recover = ImageChops.lighter(blend, _key_edge_band(ImageChops.subtract(key, other), ground))
|
||||
alpha = Image.composite(raw_alpha, Image.new("L", rgb.size, 255), recover)
|
||||
unmixed = [_decontaminate_channel(channel, alpha, 0) for channel in others]
|
||||
key_out = Image.composite(ImageChops.lighter(unmixed[0], unmixed[1]), key, recover)
|
||||
out = [key_out if index == key_index else unmixed.pop(0) for index in range(3)]
|
||||
return alpha, Image.merge("RGB", out)
|
||||
|
||||
|
||||
def _key_blend_mask(key_channel: Image.Image, other_max: Image.Image) -> Image.Image:
|
||||
@@ -416,21 +406,6 @@ def _decontaminate_channel(
|
||||
)
|
||||
|
||||
|
||||
def _decontaminate_rgb(
|
||||
rgb: Image.Image,
|
||||
alpha: Image.Image,
|
||||
bg: tuple[int, int, int],
|
||||
) -> Image.Image:
|
||||
"""Recover foreground RGB values from a composited pure chroma key."""
|
||||
return Image.merge(
|
||||
"RGB",
|
||||
tuple(
|
||||
_decontaminate_channel(channel, alpha, bg_value)
|
||||
for channel, bg_value in zip(rgb.split(), bg)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _key_hue_channel(bg: tuple[int, int, int]) -> Optional[int]:
|
||||
"""Return the channel a key-hued ground is dominated by, if any."""
|
||||
index = max(range(3), key=lambda channel: bg[channel])
|
||||
@@ -442,8 +417,14 @@ def _key_hue_channel(bg: tuple[int, int, int]) -> Optional[int]:
|
||||
return index
|
||||
|
||||
|
||||
def _sheet_ground_is_uneven(sheet: Image.Image, bg: Optional[tuple[int, int, int]]) -> bool:
|
||||
"""Tell whether the sheet's outer gutter is a key-hued field that is not flat."""
|
||||
def _sheet_keys_by_dominance(sheet: Image.Image, bg: Optional[tuple[int, int, int]]) -> bool:
|
||||
"""Tell whether the sheet's key-hued ground needs dominance keying.
|
||||
|
||||
It does when the outer gutter is not flat, or when the ground sits farther
|
||||
from every pure key than drift explains: such a ground gets no pure-key
|
||||
recovery, and one-colour distance leaves shadows cast on it as opaque
|
||||
key-tinted blotches.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
@@ -468,6 +449,8 @@ def _sheet_ground_is_uneven(sheet: Image.Image, bg: Optional[tuple[int, int, int
|
||||
)
|
||||
if keyed.mean() < _UNEVEN_RING_KEY_SHARE:
|
||||
return False
|
||||
if _nearest_pure_key(ground) is None: # type: ignore[arg-type]
|
||||
return True
|
||||
low, high = np.percentile(dominance[keyed], (1, 99))
|
||||
return bool(high - low > _UNEVEN_RING_DOMINANCE_RANGE)
|
||||
|
||||
@@ -489,8 +472,9 @@ def _dominance_masks(
|
||||
and colour is un-premultiplied against the same local ground.
|
||||
|
||||
Applies only when the cell's outer ring is a clean key-hued gutter and the
|
||||
ground is not flat, in this cell or (``uneven_sheet``) across the sheet, so
|
||||
every cell of one sheet is keyed the same way.
|
||||
ground is not flat in this cell, or when ``uneven_sheet`` says the sheet
|
||||
needs it (``_sheet_keys_by_dominance``), so every cell of one sheet is keyed
|
||||
the same way.
|
||||
"""
|
||||
key_index = _key_hue_channel(bg)
|
||||
if key_index is None:
|
||||
@@ -553,6 +537,9 @@ def _dominance_masks(
|
||||
# A colour that merely shares the key's hue keeps a substantial non-key
|
||||
# channel and stays opaque, as in the pure-key path.
|
||||
blend = (key > other) & (other < key * _KEY_PURITY_OPAQUE_RATIO)
|
||||
ground_like = Image.fromarray(np.where(blend & (opacity < 0.5), 255, 0).astype(np.uint8), "L")
|
||||
band = _key_edge_band(Image.fromarray(dominance.astype(np.uint8), "L"), ground_like)
|
||||
blend |= np.asarray(band) > 0
|
||||
opacity = np.where(blend, opacity, 1.0)
|
||||
|
||||
ground_colour = np.median(ring_pixels[keyed], axis=0).astype(np.float32)
|
||||
@@ -565,13 +552,12 @@ def _dominance_masks(
|
||||
safe = np.maximum(opacity, 1 / 255)[..., None]
|
||||
colour = (pixels - (1.0 - opacity[..., None]) * backdrop) / safe
|
||||
colour = np.clip(colour, 0, 255)
|
||||
# Spill suppression on recovered edges: a blended pixel's key channel
|
||||
# never exceeds its strongest non-key channel.
|
||||
# Un-premultiplying cannot recover the key channel of a blended pixel, so
|
||||
# it takes the strongest non-key channel, as in the pure-key path: spill
|
||||
# leaves the edge and a shadow stays grey instead of the key's complement.
|
||||
edge = blend & (opacity < 1.0)
|
||||
limit = colour[..., other_indexes].max(axis=2)
|
||||
colour[..., key_index] = np.where(
|
||||
edge, np.minimum(colour[..., key_index], limit), colour[..., key_index]
|
||||
)
|
||||
colour[..., key_index] = np.where(edge, limit, colour[..., key_index])
|
||||
|
||||
alpha_mask = Image.fromarray(np.rint(opacity * 255).astype(np.uint8), "L")
|
||||
# The gate is a percentile of the gutter's grain, so a few ground pixels sit
|
||||
@@ -615,18 +601,25 @@ def _content_masks(
|
||||
if uneven is not None:
|
||||
if notices is not None:
|
||||
notices.append(
|
||||
"the key ground is uneven (gradient, lighting, or texture); keyed by "
|
||||
"key-channel dominance against the local ground instead of one colour — "
|
||||
"the key ground is uneven (gradient, lighting, or texture) or off every "
|
||||
"pure key; keyed by key-channel dominance against the local ground "
|
||||
"instead of one colour — "
|
||||
"inspect the cut elements"
|
||||
)
|
||||
return uneven
|
||||
diff = _max_channel_difference(rgb, bg)
|
||||
trim_mask = diff.point(lambda p: 255 if p > tolerance else 0)
|
||||
tolerance_gate = _soft_mask_from_diff(diff, tolerance)
|
||||
if _pure_chroma_channel(bg) is not None:
|
||||
chroma_alpha = _chroma_alpha(rgb, bg)
|
||||
key_index = _pure_chroma_channel(bg)
|
||||
if key_index is None:
|
||||
# A measured ground within drift of a pure key is that key drifted;
|
||||
# it keeps pure-key recovery, gated by distance to the measured ground.
|
||||
drifted = _nearest_pure_key(bg)
|
||||
if drifted is not None:
|
||||
key_index = _pure_chroma_channel(drifted[0])
|
||||
if key_index is not None:
|
||||
chroma_alpha, keyed_rgb = _pure_key_recovery(rgb, key_index)
|
||||
alpha_mask = ImageChops.multiply(chroma_alpha, tolerance_gate)
|
||||
keyed_rgb = _decontaminate_rgb(rgb, chroma_alpha, bg)
|
||||
return trim_mask, alpha_mask, keyed_rgb, diff
|
||||
|
||||
alpha_mask = tolerance_gate.filter(ImageFilter.MinFilter(3))
|
||||
@@ -777,7 +770,9 @@ def _haze_finding(
|
||||
cell, border_ratio=0.10, key=bg,
|
||||
)
|
||||
dominant_distance = max(abs(dominant[index] - bg[index]) for index in range(3))
|
||||
if max(dominant_distance, spread, distance) <= tolerance:
|
||||
# Haze comes from the field itself; a glow or shadow tail entering the
|
||||
# margin over an on-key field recovers as soft alpha and is not haze.
|
||||
if max(dominant_distance, spread) <= tolerance:
|
||||
return None
|
||||
hex_bg = "#{:02X}{:02X}{:02X}".format(*bg)
|
||||
measured_bg = "#{:02X}{:02X}{:02X}".format(*dominant)
|
||||
@@ -991,7 +986,7 @@ def slice_sheet(
|
||||
with Image.open(sheet_path) as source:
|
||||
sheet = ImageOps.exif_transpose(source).convert("RGBA")
|
||||
sw, sh = sheet.size
|
||||
uneven_sheet = (trim or alpha) and _sheet_ground_is_uneven(sheet, bg)
|
||||
uneven_sheet = (trim or alpha) and _sheet_keys_by_dominance(sheet, bg)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stem = sheet_path.stem
|
||||
|
||||
+54
-1
@@ -11,6 +11,8 @@ Primary formats (pure Python, no external tools required):
|
||||
|
||||
Fallback formats (require pandoc installed):
|
||||
.doc .odt .rtf .tex .latex .rst .org .typ
|
||||
(.typ keeps its text with headings mapped when pandoc is absent or cannot
|
||||
evaluate a file that imports a template, package, or custom function)
|
||||
|
||||
All paths produce the same output convention:
|
||||
<input>.md Markdown file
|
||||
@@ -1528,6 +1530,52 @@ def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
|
||||
return markdown
|
||||
|
||||
|
||||
_TYPST_HEADING_RE = re.compile(r"^(=+)\s+(.*)$")
|
||||
_TYPST_FENCE_RE = re.compile(r"`{3,}")
|
||||
|
||||
|
||||
def _typst_text_to_markdown(source: str) -> str:
|
||||
"""Map Typst headings to Markdown and keep everything else as written.
|
||||
|
||||
Pandoc's Typst reader evaluates the document, so a real project file that
|
||||
imports its template, a package, or a custom function fails as a whole.
|
||||
This pass evaluates nothing: headings become ``#`` headings, while raw
|
||||
blocks (opened anywhere on a line, as in ``#code(```typ``), markup, and
|
||||
code calls stay verbatim.
|
||||
"""
|
||||
lines = []
|
||||
fence: str | None = None
|
||||
for line in source.splitlines():
|
||||
heading = _TYPST_HEADING_RE.match(line) if fence is None else None
|
||||
for run in _TYPST_FENCE_RE.findall(line):
|
||||
if fence is None:
|
||||
fence = run
|
||||
elif run == fence:
|
||||
fence = None
|
||||
lines.append(f"{'#' * len(heading.group(1))} {heading.group(2)}" if heading else line)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _convert_typst(input_file: Path, out_file: Path, warnings: list[str]) -> str:
|
||||
"""Convert through pandoc, falling back to the non-evaluating text pass."""
|
||||
if _check_pandoc():
|
||||
markdown = _convert_with_pandoc(input_file, out_file, ".typ")
|
||||
if markdown:
|
||||
return markdown
|
||||
reason = "pandoc could not evaluate the Typst source (imports, packages, or custom functions)"
|
||||
else:
|
||||
reason = "pandoc is not installed"
|
||||
print(f"[INFO] {reason}; keeping the Typst text with headings mapped")
|
||||
warnings.append(
|
||||
f"{reason}: kept the source text with headings mapped to Markdown; "
|
||||
"code calls, show/set rules, and math stay as written Typst"
|
||||
)
|
||||
markdown = _typst_text_to_markdown(input_file.read_text(encoding="utf-8"))
|
||||
out_file.write_text(markdown, encoding="utf-8")
|
||||
_report_result(out_file, None)
|
||||
return markdown
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Dispatcher
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -1585,13 +1633,18 @@ def convert_to_markdown(input_path: str, output_path: str | None = None) -> str:
|
||||
|
||||
_, format_desc = PANDOC_FORMATS[suffix]
|
||||
print(f"[INFO] Converting {format_desc} via pandoc: {input_file.name}")
|
||||
markdown = _convert_with_pandoc(input_file, out_file, suffix)
|
||||
warnings = []
|
||||
if suffix == ".typ":
|
||||
markdown = _convert_typst(input_file, out_file, warnings)
|
||||
else:
|
||||
markdown = _convert_with_pandoc(input_file, out_file, suffix)
|
||||
if markdown:
|
||||
profile_path = write_conversion_profile_best_effort(
|
||||
input_path=str(input_file),
|
||||
markdown_path=out_file,
|
||||
converter="doc_to_md.py",
|
||||
conversion_type=suffix.lstrip("."),
|
||||
warnings=warnings,
|
||||
)
|
||||
if profile_path:
|
||||
print(f" Wrote conversion profile -> {profile_path}")
|
||||
|
||||
@@ -49,6 +49,7 @@ from server_common import ( # noqa: E402
|
||||
find_free_port,
|
||||
lock_pid,
|
||||
open_preview_browser,
|
||||
plain_request_log,
|
||||
popen_detached,
|
||||
process_alive,
|
||||
read_lock,
|
||||
@@ -222,7 +223,7 @@ def _running(project: Path) -> dict | None:
|
||||
|
||||
def _announce(lock: dict, no_browser: bool) -> None:
|
||||
url = f"http://{PUBLIC_HOST}:{lock['port']}"
|
||||
print(json.dumps({"service": "spec_review", "url": url, **lock}, separators=(",", ":")))
|
||||
print(json.dumps({"service": "spec_review", "url": url, **lock}, separators=(",", ":")), flush=True)
|
||||
if not no_browser:
|
||||
open_preview_browser(url, logger=logger)
|
||||
|
||||
@@ -244,6 +245,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s spec_review: %(message)s")
|
||||
logging.getLogger("werkzeug").addFilter(_QuietPolls())
|
||||
plain_request_log()
|
||||
project = Path(args.project_path).resolve()
|
||||
runtime = project / "spec_review"
|
||||
lock_file = runtime / "lock.json"
|
||||
|
||||
@@ -68,6 +68,7 @@ from server_common import ( # noqa: E402
|
||||
find_free_port as _find_free_port,
|
||||
lock_pid as _lock_pid,
|
||||
open_preview_browser,
|
||||
plain_request_log,
|
||||
popen_detached as _popen_detached,
|
||||
process_alive as _process_alive,
|
||||
read_lock as _read_lock,
|
||||
@@ -1225,6 +1226,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
format='[%(asctime)s] [%(levelname)s] svg_editor: %(message)s',
|
||||
datefmt='%H:%M:%S',
|
||||
)
|
||||
plain_request_log()
|
||||
|
||||
if args.port is not None:
|
||||
try:
|
||||
|
||||
@@ -183,6 +183,33 @@ class CandidatePoolContinuationTests(unittest.TestCase):
|
||||
self.assertGreater(image.getpixel((3, 3))[0], 150)
|
||||
self.assertFalse(image_search._normalize_multi_frame_jpeg(path))
|
||||
|
||||
def test_original_above_pillow_warning_size_is_halved_quietly(self) -> None:
|
||||
import warnings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
target = Path(temp_dir) / "takin.jpg"
|
||||
|
||||
def materialize(path: Path) -> None:
|
||||
Image.new("RGB", (200, 150), (120, 90, 60)).save(path, "JPEG")
|
||||
|
||||
with patch.object(Image, "MAX_IMAGE_PIXELS", 20_000), \
|
||||
warnings.catch_warnings(record=True) as caught, \
|
||||
redirect_stderr(io.StringIO()) as stderr:
|
||||
warnings.simplefilter("always")
|
||||
staged, size = image_search._stage_and_validate_image(
|
||||
target, materialize, min_width=0, min_height=0,
|
||||
enforce_thumbnail_floor=False,
|
||||
)
|
||||
|
||||
self.assertEqual(size, (100, 75))
|
||||
self.assertIn("downscaled a 200x150 original to 100x75", stderr.getvalue())
|
||||
self.assertFalse(
|
||||
[w for w in caught if issubclass(w.category, Image.DecompressionBombWarning)]
|
||||
)
|
||||
staged.unlink()
|
||||
|
||||
def test_multi_frame_camera_jpeg_bakes_exif_orientation(self) -> None:
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@@ -290,6 +290,91 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
|
||||
key_tinted = opaque & (cut[..., 1] > cut[..., 0] + 30) & (cut[..., 1] > cut[..., 2] + 30)
|
||||
self.assertEqual(int(key_tinted.sum()), 0)
|
||||
|
||||
def test_pure_key_light_edge_and_warm_shadow_leave_no_green_or_magenta_fringe(self) -> None:
|
||||
import numpy as np
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
image = Image.new("RGB", (200, 200), (0, 255, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
# A warm shadow at 40% over the key, then a cream disc whose edge
|
||||
# rings with a light key-tinted line, as a compressed render leaves it.
|
||||
draw.ellipse((66, 66, 156, 156), fill=(16, 165, 12))
|
||||
draw.ellipse((49, 49, 151, 151), fill=(223, 255, 206))
|
||||
draw.ellipse((50, 50, 150, 150), fill=(245, 237, 218))
|
||||
image.save(sheet_path)
|
||||
|
||||
slice_sheet(
|
||||
sheet_path, 1, 1, root / "out", names=["disc"],
|
||||
alpha=True, strict_alpha=True, bg=(0, 255, 0), tolerance=40,
|
||||
)
|
||||
|
||||
cut = np.asarray(Image.open(root / "out" / "disc.png").convert("RGBA"), dtype=np.int16)
|
||||
red, green, blue, alpha = (cut[..., index] for index in range(4))
|
||||
key_tinted = (alpha >= 128) & (green > np.maximum(red, blue) + 30)
|
||||
magenta = (alpha >= 64) & (green + 30 < np.minimum(red, blue))
|
||||
self.assertEqual(int(key_tinted.sum()), 0)
|
||||
self.assertEqual(int(magenta.sum()), 0)
|
||||
self.assertEqual(tuple(cut[100, 100]), (245, 237, 218, 255))
|
||||
shadow = cut[140, 140]
|
||||
self.assertTrue(80 <= shadow[3] <= 110, shadow)
|
||||
self.assertLessEqual(int(shadow[:3].max() - shadow[:3].min()), 12)
|
||||
|
||||
def test_off_key_flat_ground_with_cast_shadow_is_keyed_by_dominance(self) -> None:
|
||||
import numpy as np
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
ground = (94, 222, 81)
|
||||
image = Image.new("RGB", (240, 240), ground)
|
||||
draw = ImageDraw.Draw(image)
|
||||
# A contact shadow cast on the painted ground: dark ground, not key.
|
||||
draw.rectangle((70, 70, 190, 190), fill=(47, 111, 40))
|
||||
draw.rectangle((60, 60, 170, 170), fill=(150, 90, 60))
|
||||
image.save(sheet_path)
|
||||
|
||||
slice_sheet(
|
||||
sheet_path, 1, 1, root / "out", names=["block"],
|
||||
trim=True, alpha=True, strict_alpha=True,
|
||||
)
|
||||
|
||||
cut = np.asarray(Image.open(root / "out" / "block.png").convert("RGBA"), dtype=np.int16)
|
||||
red, green, blue, alpha = (cut[..., index] for index in range(4))
|
||||
key_tinted = (alpha >= 128) & (green > np.maximum(red, blue) + 30)
|
||||
self.assertEqual(int(key_tinted.sum()), 0)
|
||||
self.assertEqual(tuple(cut[50, 50]), (150, 90, 60, 255))
|
||||
self.assertLess(int(alpha[-5:, -5:].max()), 200)
|
||||
|
||||
def test_glow_tail_in_margin_over_on_key_ground_is_not_haze(self) -> None:
|
||||
import numpy as np
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sheet_path = root / "sheet.png"
|
||||
size = 200
|
||||
yy, xx = np.mgrid[0:size, 0:size]
|
||||
radius = np.hypot(xx - 100, yy - 100)
|
||||
# A pale disc whose soft glow fades into the key-only margin.
|
||||
coverage = np.where(radius <= 40, 1.0, np.clip(1 - (radius - 40) / 70, 0, 1) * 0.35)
|
||||
ground = np.array([2.0, 8.0, 254.0])
|
||||
sheet = ground * (1 - coverage[..., None]) + np.array([235.0, 225.0, 200.0]) * coverage[..., None]
|
||||
Image.fromarray(sheet.astype(np.uint8), "RGB").save(sheet_path)
|
||||
|
||||
for bg in ((0, 0, 255), (2, 8, 254)):
|
||||
with self.subTest(bg=bg):
|
||||
out = root / f"out_{bg[0]}"
|
||||
slice_sheet(
|
||||
sheet_path, 1, 1, out, names=["moon"],
|
||||
alpha=True, strict_alpha=True, bg=bg, tolerance=18,
|
||||
)
|
||||
cut = np.asarray(Image.open(out / "moon.png").convert("RGBA"), dtype=np.int16)
|
||||
red, green, blue, alpha = (cut[..., index] for index in range(4))
|
||||
# A measured near-key ground still despills: the glow is not blue.
|
||||
bluish = (alpha >= 40) & (blue > np.maximum(red, green) + 30)
|
||||
self.assertEqual(int(bluish.sum()), 0)
|
||||
|
||||
def test_strict_alpha_names_painted_card_cells_instead_of_a_key_rerun(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
+27
@@ -410,5 +410,32 @@ class SourceCollisionTests(unittest.TestCase):
|
||||
self.assertFalse(source.with_suffix(".conversion_profile.json").exists())
|
||||
|
||||
|
||||
class TypstFallbackTests(unittest.TestCase):
|
||||
def test_project_file_with_imports_keeps_text_and_maps_headings(self) -> None:
|
||||
from doc_to_md import convert_to_markdown
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = Path(tmp) / "chapter.typ"
|
||||
source.write_text(
|
||||
'#import "mod.typ": *\n'
|
||||
"#show: book.page.with(title: [Intro])\n\n"
|
||||
"= Headings\n\n"
|
||||
"Write *markup* here.\n\n"
|
||||
"#code(```typ\n= Not a heading\n```)\n\n"
|
||||
"== Next\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
markdown = convert_to_markdown(str(source))
|
||||
|
||||
lines = markdown.splitlines()
|
||||
self.assertIn("# Headings", lines)
|
||||
self.assertIn("## Next", lines)
|
||||
self.assertIn("= Not a heading", lines)
|
||||
self.assertIn("Write *markup* here.", lines)
|
||||
profile = json.loads(source.with_suffix(".conversion_profile.json").read_text(encoding="utf-8"))
|
||||
self.assertTrue(any("headings mapped" in warning for warning in profile["warnings"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,8 +2,8 @@
|
||||
"sourceId": "taste-skill",
|
||||
"repo": "https://github.com/Leonxlnx/taste-skill.git",
|
||||
"ref": "main",
|
||||
"commit": "5217fb45be2c0b302f29c9cd31cbd3237501c684",
|
||||
"commit": "c184364c58658b2f131b4ae8bd3d206cabb3deee",
|
||||
"adapter": "skill-collection",
|
||||
"sourcePath": "skills",
|
||||
"syncedAt": "2026-09-22T16:08:55Z"
|
||||
"syncedAt": "2026-09-23T16:00:00Z"
|
||||
}
|
||||
Reference in new issue
Block a user