@leejungkiin/awkit 1.6.6 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/bin/awk.js +186 -8
  2. package/package.json +5 -3
  3. package/schemas/onboarding-screen.schema.json +108 -0
  4. package/scripts/__pycache__/openrouter_image_gen.cpython-311.pyc +0 -0
  5. package/scripts/automation-gate.js +8 -7
  6. package/scripts/cockpit-quota.js +93 -0
  7. package/scripts/exec-rtk.js +50 -0
  8. package/scripts/openrouter_image_gen.py +772 -0
  9. package/scripts/video-analyzer.js +172 -0
  10. package/skills/CATALOG.md +3 -2
  11. package/skills/TRIGGER_INDEX.md +1 -1
  12. package/skills/ai-sprite-maker/SKILL.md +27 -6
  13. package/skills/ai-sprite-maker/scripts/__pycache__/remove_chroma_key.cpython-311.pyc +0 -0
  14. package/skills/ai-sprite-maker/scripts/remove_chroma_key.py +440 -0
  15. package/skills/awf-caveman/SKILL.md +65 -0
  16. package/skills/expo-build-optimizer/SKILL.md +33 -0
  17. package/skills/ios-app-store-audit/SKILL.md +48 -0
  18. package/skills/ios-expert-coder/SKILL.md +45 -0
  19. package/skills/marketing-spec-writer/SKILL.md +51 -0
  20. package/skills/marketing-spec-writer/templates/MARKETING_SPEC.md +53 -0
  21. package/skills/mascot-designer/SKILL.md +66 -0
  22. package/skills/mascot-designer/examples/witny-case-study.md +35 -0
  23. package/skills/orchestrator/SKILL.md +20 -0
  24. package/skills/review/SKILL.md +87 -0
  25. package/skills/short-maker/scripts/google-flow-cli/README.md +227 -115
  26. package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +32 -3
  27. package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +4 -2
  28. package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +33 -6
  29. package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +1 -1
  30. package/skills/storyboard-to-scene-pack/SKILL.md +102 -0
  31. package/skills/storyboard-to-scene-pack/agents/openai.yaml +4 -0
  32. package/skills/storyboard-to-scene-pack/assets/preview-template/index.html +101 -0
  33. package/skills/storyboard-to-scene-pack/references/continuity-checklist.md +32 -0
  34. package/skills/storyboard-to-scene-pack/references/scene-prompt-template.md +19 -0
  35. package/skills/storyboard-to-scene-pack/references/storyboard-sheet-template.md +14 -0
  36. package/skills/verification-gate/SKILL.md +4 -0
  37. package/templates/help.html +21 -0
  38. package/templates/project-identity/android.json +24 -0
  39. package/templates/project-identity/backend-nestjs.json +24 -0
  40. package/templates/project-identity/expo.json +24 -0
  41. package/templates/project-identity/ios.json +24 -0
  42. package/templates/project-identity/web-nextjs.json +24 -0
  43. package/templates/specs/design-template.md +71 -161
  44. package/templates/specs/requirements-template.md +133 -65
  45. package/workflows/ui/create-spec-architect.md +80 -50
  46. package/workflows/ui/image-gen.md +118 -0
  47. package/skills/code-review/SKILL.md +0 -115
@@ -0,0 +1,172 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { GoogleGenAI } = require('@google/genai');
5
+
6
+ const HOME = process.env.HOME || process.env.USERPROFILE;
7
+ const CREDENTIALS_PATH = path.join(HOME, '.gemini', 'antigravity', '.credentials.json');
8
+
9
+ const C = {
10
+ reset: '\x1b[0m',
11
+ red: '\x1b[31m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ cyan: '\x1b[36m',
15
+ gray: '\x1b[90m',
16
+ bold: '\x1b[1m',
17
+ };
18
+
19
+ function log(msg) { console.log(msg); }
20
+ function ok(msg) { log(`${C.green}✅ ${msg}${C.reset}`); }
21
+ function err(msg) { log(`${C.red}❌ ${msg}${C.reset}`); }
22
+ function info(msg) { log(`${C.cyan}ℹ️ ${msg}${C.reset}`); }
23
+
24
+ async function wait(ms) {
25
+ return new Promise(resolve => setTimeout(resolve, ms));
26
+ }
27
+
28
+ async function cmdVideo(args) {
29
+ if (args.length === 0) {
30
+ err('Usage: awkit video <file.mp4> [--pro|--flash] [--debug|--uiux|--clone] [custom prompt]');
31
+ return;
32
+ }
33
+
34
+ const models = {
35
+ '--pro': 'gemini-3.1-pro-preview',
36
+ '--flash': 'gemini-3-flash-preview'
37
+ };
38
+
39
+ const presets = {
40
+ '--debug': 'Hãy phân tích video này theo hướng Debug/QA. Bạn hãy tìm hiểu các trạng thái treo, crash, lỗi hiển thị, giật lag animation hoặc các dòng log đỏ lỗi nếu có. Liệt kê lại timestamp và phân tích chi tiết tại sao bị lỗi.',
41
+ '--uiux': 'Hãy phân tích video này theo hướng UI/UX Designer. Đánh giá về margin, bố cục, animation curve, màu sắc và độ mượt mà. Đề xuất các cải thiện.',
42
+ '--clone': 'Bóc băng ứng dụng (Clone): Hãy phân tích thao tác người dùng ở ứng dụng này, liệt kê tất cả các Screen, luồng thao tác (User Flow), và phác thảo các Component/API cần thiết để xây dựng lại tính năng tựng tự.'
43
+ };
44
+
45
+ let selectedModel = 'gemini-3-flash-preview'; // Default to flash
46
+ let presetPrompt = '';
47
+ const fileArgs = [];
48
+ for (const a of args) {
49
+ if (models[a]) selectedModel = models[a];
50
+ else if (presets[a]) presetPrompt = presets[a];
51
+ else fileArgs.push(a);
52
+ }
53
+
54
+ if (fileArgs.length === 0) {
55
+ err('Bạn chưa cung cấp đường dẫn video.');
56
+ return;
57
+ }
58
+
59
+ const videoPath = path.resolve(process.cwd(), fileArgs[0]);
60
+ if (!fs.existsSync(videoPath)) {
61
+ err(`Video file not found: ${videoPath}`);
62
+ return;
63
+ }
64
+
65
+ let defaultPrompt = "Hãy phân tích chi tiết video này, mô tả kịch bản, các thao tác UI/UX, dòng chảy chức năng, và liệt kê các thành phần giao diện cần thiết. Đặc biệt chú ý bất kỳ bugs/lỗi nào nếu có.";
66
+ const userPrompt = presetPrompt || fileArgs.slice(1).join(' ') || defaultPrompt;
67
+
68
+ let apiKey = process.env.GEMINI_API_KEY;
69
+ if (!apiKey) {
70
+ if (fs.existsSync(CREDENTIALS_PATH)) {
71
+ try {
72
+ const creds = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
73
+ if (creds.gemini_api_key) {
74
+ apiKey = creds.gemini_api_key;
75
+ }
76
+ } catch (e) {
77
+ // ignore
78
+ }
79
+ }
80
+ }
81
+
82
+ if (!apiKey) {
83
+ const readline = require('readline').createInterface({
84
+ input: process.stdin,
85
+ output: process.stdout
86
+ });
87
+ apiKey = await new Promise(resolve => {
88
+ readline.question(`${C.cyan}Chưa có Gemini API Key. Vui lòng dán API Key của bạn vào đây: ${C.reset}`, (ans) => {
89
+ readline.close();
90
+ resolve(ans.trim());
91
+ });
92
+ });
93
+
94
+ if (apiKey) {
95
+ let creds = {};
96
+ if (fs.existsSync(CREDENTIALS_PATH)) {
97
+ try { creds = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8')); } catch (e) { }
98
+ }
99
+ creds.gemini_api_key = apiKey;
100
+ fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true });
101
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), 'utf8');
102
+ ok(`Đã lưu API Key vào ${CREDENTIALS_PATH} thành công!`);
103
+ } else {
104
+ err(`Hủy lệnh. Bạn cần API key để sửa dụng tính năng này.`);
105
+ return;
106
+ }
107
+ }
108
+
109
+ info(`Initializing Gemini AI...`);
110
+ const ai = new GoogleGenAI({ apiKey });
111
+
112
+ try {
113
+ let mimeType = 'video/mp4';
114
+ if (videoPath.toLowerCase().endsWith('.mov')) mimeType = 'video/quicktime';
115
+ else if (videoPath.toLowerCase().endsWith('.webm')) mimeType = 'video/webm';
116
+
117
+ info(`Uploading video to Gemini: ${path.basename(videoPath)}`);
118
+
119
+ const myfile = await ai.files.upload({
120
+ file: videoPath,
121
+ config: { mimeType }
122
+ });
123
+
124
+ ok(`Upload complete: ${myfile.uri}`);
125
+ info(`Checking processing status...`);
126
+
127
+ // Wait for video processing
128
+ let fileStatus = await ai.files.get({ name: myfile.name });
129
+ while (fileStatus.state === 'PROCESSING') {
130
+ await wait(2000); // Check every 2s
131
+ fileStatus = await ai.files.get({ name: myfile.name });
132
+ }
133
+
134
+ if (fileStatus.state === 'FAILED') {
135
+ err(`Video processing failed on Google server.`);
136
+ return;
137
+ }
138
+
139
+ info(`Video is ready. Generating analysis with model ${selectedModel}...`);
140
+
141
+ const response = await ai.models.generateContent({
142
+ model: selectedModel,
143
+ contents: [
144
+ {
145
+ role: "user",
146
+ parts: [
147
+ { fileData: { fileUri: myfile.uri, mimeType: fileStatus.mimeType || mimeType } },
148
+ { text: userPrompt }
149
+ ]
150
+ }
151
+ ]
152
+ });
153
+
154
+ const ts = Math.floor(Date.now() / 1000);
155
+ const outFileName = `video_analysis_${ts}.md`;
156
+
157
+ const output = `# Video Analysis: ${path.basename(videoPath)}\n**Date:** ${new Date().toLocaleString()}\n**Prompt:** ${userPrompt}\n\n---\n\n${response.text}`;
158
+
159
+ fs.writeFileSync(path.join(process.cwd(), outFileName), output, 'utf8');
160
+ ok(`Analysis saved successfully to ${outFileName}`);
161
+
162
+ } catch (error) {
163
+ err(`Analysis failed: ${error.message}`);
164
+ if (error.response) {
165
+ console.error(error.response);
166
+ }
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ cmdVideo
172
+ };
package/skills/CATALOG.md CHANGED
@@ -25,7 +25,8 @@
25
25
  | 5 | `brainstorm-agent` | `manual` | `/brainstorm`, keywords | 5 | 1.0.0 | ✅ Active |
26
26
  | 6 | `awf-error-translator` | `auto` | Error detected | 6 | — | ✅ Active |
27
27
  | 7 | `awf-adaptive-language` | `auto` | Always | 7 | — | ✅ Active |
28
- | 8 | `ios-engineer` | `manual` | iOS tasks | | | ✅ Active |
28
+ | 8 | `awf-caveman` | `auto` | `.project-identity` check | 7.5 | 1.0.0 | ✅ Active |
29
+ | 9 | `ios-engineer` | `manual` | iOS tasks | — | — | ✅ Active |
29
30
  | 9 | `smali-to-kotlin` | `manual` | `/reverse-android` | 8 | — | ✅ Active |
30
31
  | 10 | `smali-to-swift` | `manual` | `/reverse-ios` | 9 | — | ✅ Active |
31
32
  | 11 | `awf-context-help` | `auto` | `/help`, stuck | — | — | ✅ Active |
@@ -47,7 +48,7 @@
47
48
  |---|-------|------|---------|----------|--------|
48
49
  | 14 | `verification-gate` | `auto` | Task completion, commit, deploy | 1 | ✅ Active |
49
50
  | 15 | `systematic-debugging` | `auto` | `/debug`, error detected, test failures | 2 | ✅ Active |
50
- | 16 | `code-review` | `auto` | Task completion, before merge | 3 | ✅ Active |
51
+ | 16 | `review` | `auto` | Task completion, before merge | 3 | ✅ Active |
51
52
  | 17 | `writing-skills` | `manual` | Creating/modifying skills | — | ✅ Active |
52
53
 
53
54
 
@@ -11,7 +11,7 @@
11
11
  | awf-session-restore | Session start, init chain | awf-session-restore/SKILL.md | 🔴 |
12
12
  | nm-memory-sync | Session start/end, debug, errors | nm-memory-sync/SKILL.md | 🟡 |
13
13
  | verification-gate | Task completion, commit, deploy, success claims | verification-gate/SKILL.md | 🟡 |
14
- | code-review | Task completion, before merge | code-review/SKILL.md | 🟡 |
14
+ | review | Task completion, before merge | review/SKILL.md | 🟡 |
15
15
  | spec-gate | Gate 2 architecture design | spec-gate/SKILL.md | 🟡 |
16
16
  | brainstorm-agent | `/brainstorm`, ý tưởng, ideation | brainstorm-agent/SKILL.md | 🟢 |
17
17
  | skill-creator | `/create-agent-skill`, tạo/sửa skill | skill-creator/SKILL.md | 🟢 |
@@ -35,19 +35,40 @@ Trước khi vẽ, hãy hỏi người dùng các thông tin cơ bản, GỢI M
35
35
  3. **Chuẩn Ảnh Tĩnh (Concept/Avatar):** Dùng cắt nền bình thường. KHÔNG ép keyword grid, KHÔNG gọi kèm hàm `--align`.
36
36
 
37
37
  ### Bước 2: Sinh ảnh (Generation)
38
- Sau khi chốt ý tưởng, AI đối chiếu với Grid Protocol bên trên để hoàn thiện prompt. Dùng tool `generate_image` với tuỳ biến sau:
38
+ Sau khi chốt ý tưởng, AI đối chiếu với Grid Protocol bên trên để hoàn thiện prompt.
39
+
40
+ #### Lựa chọn Model (Model Triage):
41
+ 1. **Standard (Internal):** Sử dụng tool `generate_image` nội bộ cho các yêu cầu thông thường.
42
+ 2. **Premium (OpenRouter GPT-5.4):** Nếu người dùng yêu cầu chất lượng cao, "studio quality", hoặc chỉ định rõ "GPT-5.4/OpenRouter", hãy sử dụng script với đầy đủ tham số Art Direction:
43
+ ```bash
44
+ python3 ~/.gemini/antigravity/scripts/openrouter_image_gen.py generate \
45
+ --prompt "{prompt}" \
46
+ --style "{style_hint}" \
47
+ --lighting "soft studio" \
48
+ --out "assets/generated/{filename}.webp"
49
+ ```
50
+ *Mẹo: Dùng `edit` subcommand nếu muốn biến đổi một phác thảo/ảnh thô có sẵn thành sprite chuẩn phong cách.*
51
+
52
+ #### Cấu hình Prompt:
39
53
  - LUÔN kết hợp chặt chẽ các keyword về chuẩn Grid Layout (nếu là ảnh động) như đã định nghĩa ở Bước 1.5.
40
54
  - **ĐỂ TRÁNH BỊ CHÈN CHỮ VÀO FRAME:** LUÔN thêm keyword: `no text, no wording, no watermark, no labels, no frame numbers, clean blank spacing`.
41
- - BẮT BUỘC chèn đoạn hậu tố nền xanh sau đây để Python nhận toạ độ cắt:
42
- `solid bright green background #00FF00, high contrast, clean distinct edges, isolated subject, no shadows cast on the background.`
55
+ - **🚫 CẤM DÙNG "Nền trong suốt"**: TUYỆT ĐỐI KHÔNG thêm từ khóa "transparent background", "nền trong suốt". Việc này sẽ sinh ra nền caro giả (fake checkerboard) không thể xoá.
56
+ - BẮT BUỘC dùng kỹ thuật "Codex Hatch Pet": Chèn đoạn hậu tố nền xanh sau đây để script Python Chroma-key nhận toạ độ cắt:
57
+ `solid bright green background #00FF00, high contrast, clean distinct edges, isolated subject, no shadows cast on the background.` (Hoặc dùng `solid white background #FFFFFF` nếu chủ thể có màu xanh lá).
43
58
  - Đặt ImageName mô tả đúng chủ thể, kết thúc bằng `_raw` (vd: `cat_walk_4x1_raw`).
44
59
 
45
60
  ### Bước 3: Hậu kỳ (Post-Processing)
46
- Ngay khi ảnh thô sinh ra thành công, AI tự động gọi script Python `process_sprites.py` để xử lý ảnh final.
61
+ Ngay khi ảnh thô sinh ra thành công, AI tự động gọi script Python để xử lý ảnh final.
62
+
63
+ - **Kỹ thuật Codex Hatch Pet (Chuyên nghiệp):** Sử dụng `remove_chroma_key.py` để gỡ nền xanh/trắng với khả năng despill (xoá ám xanh cạnh) và soft-matte.
64
+ ```bash
65
+ # Đối với nền xanh lá #00FF00
66
+ python3 ~/.gemini/antigravity/skills/ai-sprite-maker/scripts/remove_chroma_key.py --input <đường_dẫn_raw> --out <đường_dẫn_final> --key-color #00FF00 --soft-matte --spill-cleanup --edge-feather 0.5
67
+ ```
47
68
 
48
- - **Đối với ảnh tĩnh (Concept/Avatar):** Chỉ gỡ nền xanh an toàn.
69
+ - **Đối với Sprite Sheet (Căn lưới):** Sử dụng `process_sprites.py` để vừa gỡ nền vừa căn chỉnh các frame vào lưới đồng nhất.
49
70
  ```bash
50
- python ~/.gemini/antigravity/skills/ai-sprite-maker/scripts/process_sprites.py <đường_dẫn_ảnh_raw> <đường_dẫn_lưu_ảnh_final>
71
+ python3 ~/.gemini/antigravity/skills/ai-sprite-maker/scripts/process_sprites.py <đường_dẫn_raw> <đường_dẫn_final> [--align CxR]
51
72
  ```
52
73
 
53
74
  - **Đối với Dải hoạt ảnh (Sprite Sheet):** KIÊN QUYẾT TỰ ĐỘNG bổ sung tham số `--align SốCộtxSốHàng` để tự động căn lề thẳng tắp mặt đất cho mọi frame hình. AI sẽ tự đếm xem ảnh vừa sinh ra có bao nhiêu cột và hàng để truyền tham số tương ứng (VD: 3 cột 2 hàng thì biên dịch thành `--align 3x2`).
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env python3
2
+ """Remove a solid chroma-key background from an image.
3
+
4
+ This helper supports the imagegen skill's built-in-first transparent workflow:
5
+ generate an image on a flat key color, then convert that key color to alpha.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from io import BytesIO
12
+ from pathlib import Path
13
+ import re
14
+ from statistics import median
15
+ import sys
16
+ from typing import Tuple
17
+
18
+
19
+ Color = Tuple[int, int, int]
20
+ KEY_DOMINANCE_THRESHOLD = 16.0
21
+ ALPHA_NOISE_FLOOR = 8
22
+
23
+
24
+ def _die(message: str, code: int = 1) -> None:
25
+ print(f"Error: {message}", file=sys.stderr)
26
+ raise SystemExit(code)
27
+
28
+
29
+ def _dependency_hint(package: str) -> str:
30
+ return (
31
+ "Activate the repo-selected environment first, then install it with "
32
+ f"`uv pip install {package}`. If this repo uses a local virtualenv, start with "
33
+ "`source .venv/bin/activate`; otherwise use this repo's configured shared fallback "
34
+ "environment."
35
+ )
36
+
37
+
38
+ def _load_pillow():
39
+ try:
40
+ from PIL import Image, ImageFilter
41
+ except ImportError:
42
+ _die(f"Pillow is required for chroma-key removal. {_dependency_hint('pillow')}")
43
+ return Image, ImageFilter
44
+
45
+
46
+ def _parse_key_color(raw: str) -> Color:
47
+ value = raw.strip()
48
+ match = re.fullmatch(r"#?([0-9a-fA-F]{6})", value)
49
+ if not match:
50
+ _die("key color must be a hex RGB value like #00ff00.")
51
+ hex_value = match.group(1)
52
+ return (
53
+ int(hex_value[0:2], 16),
54
+ int(hex_value[2:4], 16),
55
+ int(hex_value[4:6], 16),
56
+ )
57
+
58
+
59
+ def _validate_args(args: argparse.Namespace) -> None:
60
+ if args.tolerance < 0 or args.tolerance > 255:
61
+ _die("--tolerance must be between 0 and 255.")
62
+ if args.transparent_threshold < 0 or args.transparent_threshold > 255:
63
+ _die("--transparent-threshold must be between 0 and 255.")
64
+ if args.opaque_threshold < 0 or args.opaque_threshold > 255:
65
+ _die("--opaque-threshold must be between 0 and 255.")
66
+ if args.soft_matte and args.transparent_threshold >= args.opaque_threshold:
67
+ _die("--transparent-threshold must be lower than --opaque-threshold.")
68
+ if args.edge_feather < 0 or args.edge_feather > 64:
69
+ _die("--edge-feather must be between 0 and 64.")
70
+ if args.edge_contract < 0 or args.edge_contract > 16:
71
+ _die("--edge-contract must be between 0 and 16.")
72
+
73
+ src = Path(args.input)
74
+ if not src.exists():
75
+ _die(f"Input image not found: {src}")
76
+
77
+ out = Path(args.out)
78
+ if out.exists() and not args.force:
79
+ _die(f"Output already exists: {out} (use --force to overwrite)")
80
+
81
+ if out.suffix.lower() not in {".png", ".webp"}:
82
+ _die("--out must end in .png or .webp so the alpha channel is preserved.")
83
+
84
+
85
+ def _channel_distance(a: Color, b: Color) -> int:
86
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2]))
87
+
88
+
89
+ def _clamp_channel(value: float) -> int:
90
+ return max(0, min(255, int(round(value))))
91
+
92
+
93
+ def _smoothstep(value: float) -> float:
94
+ value = max(0.0, min(1.0, value))
95
+ return value * value * (3.0 - 2.0 * value)
96
+
97
+
98
+ def _soft_alpha(distance: int, transparent_threshold: float, opaque_threshold: float) -> int:
99
+ if distance <= transparent_threshold:
100
+ return 0
101
+ if distance >= opaque_threshold:
102
+ return 255
103
+ ratio = (float(distance) - transparent_threshold) / (
104
+ opaque_threshold - transparent_threshold
105
+ )
106
+ return _clamp_channel(255.0 * _smoothstep(ratio))
107
+
108
+
109
+ def _dominance_alpha(rgb: Color, key: Color) -> int:
110
+ spill_channels = _spill_channels(key)
111
+ if not spill_channels:
112
+ return 255
113
+
114
+ channels = [float(value) for value in rgb]
115
+ non_spill = [idx for idx in range(3) if idx not in spill_channels]
116
+ key_strength = (
117
+ min(channels[idx] for idx in spill_channels)
118
+ if len(spill_channels) > 1
119
+ else channels[spill_channels[0]]
120
+ )
121
+ non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)
122
+ dominance = key_strength - non_key_strength
123
+ if dominance <= 0:
124
+ return 255
125
+
126
+ denominator = max(1.0, float(max(key)) - non_key_strength)
127
+ alpha = 1.0 - min(1.0, dominance / denominator)
128
+ return _clamp_channel(alpha * 255.0)
129
+
130
+
131
+ def _spill_channels(key: Color) -> list[int]:
132
+ key_max = max(key)
133
+ if key_max < 128:
134
+ return []
135
+ return [idx for idx, value in enumerate(key) if value >= key_max - 16 and value >= 128]
136
+
137
+
138
+ def _key_channel_dominance(rgb: Color, key: Color) -> float:
139
+ spill_channels = _spill_channels(key)
140
+ if not spill_channels:
141
+ return 0.0
142
+
143
+ channels = [float(value) for value in rgb]
144
+ non_spill = [idx for idx in range(3) if idx not in spill_channels]
145
+ key_strength = (
146
+ min(channels[idx] for idx in spill_channels)
147
+ if len(spill_channels) > 1
148
+ else channels[spill_channels[0]]
149
+ )
150
+ non_key_strength = max((channels[idx] for idx in non_spill), default=0.0)
151
+ return key_strength - non_key_strength
152
+
153
+
154
+ def _looks_key_colored(rgb: Color, key: Color, distance: int) -> bool:
155
+ if distance <= 32:
156
+ return True
157
+
158
+ spill_channels = _spill_channels(key)
159
+ if not spill_channels:
160
+ return True
161
+
162
+ return _key_channel_dominance(rgb, key) >= KEY_DOMINANCE_THRESHOLD
163
+
164
+
165
+ def _cleanup_spill(rgb: Color, key: Color, alpha: int = 255) -> Color:
166
+ if alpha >= 252:
167
+ return rgb
168
+
169
+ spill_channels = _spill_channels(key)
170
+ if not spill_channels:
171
+ return rgb
172
+
173
+ channels = [float(value) for value in rgb]
174
+ non_spill = [idx for idx in range(3) if idx not in spill_channels]
175
+ if non_spill:
176
+ anchor = max(channels[idx] for idx in non_spill)
177
+ cap = max(0.0, anchor - 1.0)
178
+ for idx in spill_channels:
179
+ if channels[idx] > cap:
180
+ channels[idx] = cap
181
+
182
+ return (
183
+ _clamp_channel(channels[0]),
184
+ _clamp_channel(channels[1]),
185
+ _clamp_channel(channels[2]),
186
+ )
187
+
188
+
189
+ def _apply_alpha_to_image(
190
+ image,
191
+ *,
192
+ key: Color,
193
+ tolerance: int,
194
+ spill_cleanup: bool,
195
+ soft_matte: bool,
196
+ transparent_threshold: float,
197
+ opaque_threshold: float,
198
+ ) -> int:
199
+ pixels = image.load()
200
+ width, height = image.size
201
+ transparent = 0
202
+
203
+ for y in range(height):
204
+ for x in range(width):
205
+ red, green, blue, alpha = pixels[x, y]
206
+ rgb = (red, green, blue)
207
+ distance = _channel_distance(rgb, key)
208
+ key_like = _looks_key_colored(rgb, key, distance)
209
+ output_alpha = (
210
+ min(
211
+ _soft_alpha(distance, transparent_threshold, opaque_threshold),
212
+ _dominance_alpha(rgb, key),
213
+ )
214
+ if soft_matte and key_like
215
+ else (0 if distance <= tolerance else 255)
216
+ )
217
+ output_alpha = int(round(output_alpha * (alpha / 255.0)))
218
+ if 0 < output_alpha <= ALPHA_NOISE_FLOOR:
219
+ output_alpha = 0
220
+
221
+ if output_alpha == 0:
222
+ pixels[x, y] = (0, 0, 0, 0)
223
+ transparent += 1
224
+ continue
225
+
226
+ if spill_cleanup and key_like:
227
+ red, green, blue = _cleanup_spill(rgb, key, output_alpha)
228
+ pixels[x, y] = (red, green, blue, output_alpha)
229
+
230
+ return transparent
231
+
232
+
233
+ def _contract_alpha(image, pixels: int):
234
+ if pixels == 0:
235
+ return image
236
+
237
+ _, ImageFilter = _load_pillow()
238
+ alpha = image.getchannel("A")
239
+ for _ in range(pixels):
240
+ alpha = alpha.filter(ImageFilter.MinFilter(3))
241
+ image.putalpha(alpha)
242
+ return image
243
+
244
+
245
+ def _apply_edge_feather(image, radius: float):
246
+ if radius == 0:
247
+ return image
248
+
249
+ _, ImageFilter = _load_pillow()
250
+ alpha = image.getchannel("A")
251
+ alpha = alpha.filter(ImageFilter.GaussianBlur(radius=radius))
252
+ image.putalpha(alpha)
253
+ return image
254
+
255
+
256
+ def _encode_image(image, output_format: str) -> bytes:
257
+ out = BytesIO()
258
+ image.save(out, format=output_format.upper())
259
+ return out.getvalue()
260
+
261
+
262
+ def _alpha_counts(image) -> tuple[int, int, int]:
263
+ pixels = image.load()
264
+ width, height = image.size
265
+ total = 0
266
+ transparent = 0
267
+ partial = 0
268
+
269
+ for y in range(height):
270
+ for x in range(width):
271
+ alpha = pixels[x, y][3]
272
+ total += 1
273
+ if alpha == 0:
274
+ transparent += 1
275
+ elif alpha < 255:
276
+ partial += 1
277
+
278
+ return total, transparent, partial
279
+
280
+
281
+ def _sample_border_key(image, mode: str) -> Color:
282
+ width, height = image.size
283
+ pixels = image.load()
284
+ samples: list[Color] = []
285
+
286
+ if mode == "corners":
287
+ patch = max(1, min(width, height, 12))
288
+ boxes = [
289
+ (0, 0, patch, patch),
290
+ (width - patch, 0, width, patch),
291
+ (0, height - patch, patch, height),
292
+ (width - patch, height - patch, width, height),
293
+ ]
294
+ for left, top, right, bottom in boxes:
295
+ for y in range(top, bottom):
296
+ for x in range(left, right):
297
+ red, green, blue = pixels[x, y][:3]
298
+ samples.append((red, green, blue))
299
+ else:
300
+ band = max(1, min(width, height, 6))
301
+ step = max(1, min(width, height) // 256)
302
+ for x in range(0, width, step):
303
+ for y in range(band):
304
+ red, green, blue = pixels[x, y][:3]
305
+ samples.append((red, green, blue))
306
+ red, green, blue = pixels[x, height - 1 - y][:3]
307
+ samples.append((red, green, blue))
308
+ for y in range(0, height, step):
309
+ for x in range(band):
310
+ red, green, blue = pixels[x, y][:3]
311
+ samples.append((red, green, blue))
312
+ red, green, blue = pixels[width - 1 - x, y][:3]
313
+ samples.append((red, green, blue))
314
+
315
+ if not samples:
316
+ _die("Could not sample background key color from image border.")
317
+
318
+ return (
319
+ int(round(median(sample[0] for sample in samples))),
320
+ int(round(median(sample[1] for sample in samples))),
321
+ int(round(median(sample[2] for sample in samples))),
322
+ )
323
+
324
+
325
+ def _remove_chroma_key(args: argparse.Namespace) -> None:
326
+ Image, _ = _load_pillow()
327
+ src = Path(args.input)
328
+ out = Path(args.out)
329
+
330
+ with Image.open(src) as image:
331
+ rgba = image.convert("RGBA")
332
+ key = (
333
+ _sample_border_key(rgba, args.auto_key)
334
+ if args.auto_key != "none"
335
+ else _parse_key_color(args.key_color)
336
+ )
337
+
338
+ transparent = _apply_alpha_to_image(
339
+ rgba,
340
+ key=key,
341
+ tolerance=args.tolerance,
342
+ spill_cleanup=args.spill_cleanup,
343
+ soft_matte=args.soft_matte,
344
+ transparent_threshold=args.transparent_threshold,
345
+ opaque_threshold=args.opaque_threshold,
346
+ )
347
+ rgba = _contract_alpha(rgba, args.edge_contract)
348
+ rgba = _apply_edge_feather(rgba, args.edge_feather)
349
+
350
+ total, transparent_after, partial_after = _alpha_counts(rgba)
351
+
352
+ out.parent.mkdir(parents=True, exist_ok=True)
353
+ output_format = "PNG" if out.suffix.lower() == ".png" else "WEBP"
354
+ out.write_bytes(_encode_image(rgba, output_format))
355
+
356
+ print(f"Wrote {out}")
357
+ print(f"Key color: #{key[0]:02x}{key[1]:02x}{key[2]:02x}")
358
+ print(f"Transparent pixels: {transparent_after}/{total}")
359
+ print(f"Partially transparent pixels: {partial_after}/{total}")
360
+ if transparent == 0:
361
+ print("Warning: no pixels matched the key color before feathering.", file=sys.stderr)
362
+
363
+
364
+ def _build_parser() -> argparse.ArgumentParser:
365
+ parser = argparse.ArgumentParser(
366
+ description="Remove a solid chroma-key background and write an image with alpha."
367
+ )
368
+ parser.add_argument("--input", required=True, help="Input image path.")
369
+ parser.add_argument("--out", required=True, help="Output .png or .webp path.")
370
+ parser.add_argument(
371
+ "--key-color",
372
+ default="#00ff00",
373
+ help="Hex RGB key color to remove, for example #00ff00.",
374
+ )
375
+ parser.add_argument(
376
+ "--tolerance",
377
+ type=int,
378
+ default=12,
379
+ help="Hard-key per-channel tolerance for matching the key color, 0-255.",
380
+ )
381
+ parser.add_argument(
382
+ "--auto-key",
383
+ choices=["none", "corners", "border"],
384
+ default="none",
385
+ help="Sample the key color from image corners or border instead of --key-color.",
386
+ )
387
+ parser.add_argument(
388
+ "--soft-matte",
389
+ action="store_true",
390
+ help="Use a smooth alpha ramp between transparent and opaque thresholds.",
391
+ )
392
+ parser.add_argument(
393
+ "--transparent-threshold",
394
+ type=float,
395
+ default=12.0,
396
+ help="Soft-matte distance at or below which pixels become fully transparent.",
397
+ )
398
+ parser.add_argument(
399
+ "--opaque-threshold",
400
+ type=float,
401
+ default=96.0,
402
+ help="Soft-matte distance at or above which pixels become fully opaque.",
403
+ )
404
+ parser.add_argument(
405
+ "--edge-feather",
406
+ type=float,
407
+ default=0.0,
408
+ help="Optional alpha blur radius for softened edges, 0-64.",
409
+ )
410
+ parser.add_argument(
411
+ "--edge-contract",
412
+ type=int,
413
+ default=0,
414
+ help="Shrink the visible alpha matte by this many pixels before feathering.",
415
+ )
416
+ parser.add_argument(
417
+ "--spill-cleanup",
418
+ dest="spill_cleanup",
419
+ action="store_true",
420
+ help="Reduce obvious key-color spill on opaque pixels.",
421
+ )
422
+ parser.add_argument(
423
+ "--despill",
424
+ dest="spill_cleanup",
425
+ action="store_true",
426
+ help="Alias for --spill-cleanup; decontaminate key-color edge spill.",
427
+ )
428
+ parser.add_argument("--force", action="store_true", help="Overwrite an existing output file.")
429
+ return parser
430
+
431
+
432
+ def main() -> None:
433
+ parser = _build_parser()
434
+ args = parser.parse_args()
435
+ _validate_args(args)
436
+ _remove_chroma_key(args)
437
+
438
+
439
+ if __name__ == "__main__":
440
+ main()