@leejungkiin/awkit 1.6.5 → 1.6.8
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.
- package/README.md +121 -130
- package/bin/awk.js +111 -8
- package/package.json +5 -3
- package/schemas/onboarding-screen.schema.json +108 -0
- package/scripts/__pycache__/openrouter_image_gen.cpython-311.pyc +0 -0
- package/scripts/cockpit-quota.js +93 -0
- package/scripts/openrouter_image_gen.py +772 -0
- package/scripts/video-analyzer.js +172 -0
- package/skills/CATALOG.md +2 -1
- package/skills/ai-sprite-maker/SKILL.md +27 -6
- package/skills/ai-sprite-maker/scripts/__pycache__/remove_chroma_key.cpython-311.pyc +0 -0
- package/skills/ai-sprite-maker/scripts/remove_chroma_key.py +440 -0
- package/skills/awf-caveman/SKILL.md +65 -0
- package/skills/expo-build-optimizer/SKILL.md +33 -0
- package/skills/ios-app-store-audit/SKILL.md +48 -0
- package/skills/ios-expert-coder/SKILL.md +45 -0
- package/skills/mascot-designer/SKILL.md +66 -0
- package/skills/mascot-designer/examples/witny-case-study.md +35 -0
- package/skills/orchestrator/SKILL.md +20 -0
- package/skills/short-maker/scripts/google-flow-cli/README.md +227 -115
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +32 -3
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +4 -2
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +33 -6
- package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +1 -1
- package/skills/verification-gate/SKILL.md +4 -0
- package/templates/help.html +21 -0
- package/templates/project-identity/android.json +24 -0
- package/templates/project-identity/backend-nestjs.json +24 -0
- package/templates/project-identity/expo.json +24 -0
- package/templates/project-identity/ios.json +24 -0
- package/templates/project-identity/web-nextjs.json +24 -0
- package/templates/specs/design-template.md +71 -161
- package/templates/specs/requirements-template.md +133 -65
- package/workflows/ui/create-spec-architect.md +80 -50
- package/workflows/ui/image-gen.md +118 -0
|
@@ -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 | `
|
|
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 |
|
|
@@ -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.
|
|
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
|
-
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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`).
|
|
Binary file
|
|
@@ -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()
|