@genex-ai/cli-demo 0.47.0-dev.71 → 0.47.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.
- package/README.md +25 -2
- package/dist/index.js +1147 -98
- package/package.json +4 -2
- package/templates/skills/genex-ai-hud/SKILL.md +381 -0
- package/templates/skills/genex-ai-hud/references/masked-fill.md +145 -0
- package/templates/skills/genex-ai-hud/references/stage1-prompt-template.md +44 -0
- package/templates/skills/genex-ai-hud/references/stage2-prompt-template.md +73 -0
- package/templates/skills/genex-ai-image/SKILL.md +15 -5
- package/templates/skills/genex-ai-menu/SKILL.md +291 -0
- package/templates/skills/genex-ai-model/SKILL.md +1 -1
- package/templates/skills/genex-ai-sfx/SKILL.md +1 -1
- package/templates/skills/genex-ai-skybox/SKILL.md +1 -1
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +12 -5
- package/templates/skills/genex-explore/SKILL.md +1 -1
- package/templates/skills/genex-getting-started/SKILL.md +7 -4
- package/templates/skills/genex-threejs-game-ui/SKILL.md +149 -18
- package/templates/skills/genex-threejs-game-ui/references/style-capsules.md +89 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +27 -1
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +37 -17
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +5 -10
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import os from "os";
|
|
9
9
|
import path from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
-
var RAW_CHANNEL = "
|
|
11
|
+
var RAW_CHANNEL = "latest";
|
|
12
12
|
var CLI_CHANNEL = RAW_CHANNEL === "dev" ? "dev" : "latest";
|
|
13
13
|
var STANDS = {
|
|
14
14
|
prod: { api: "https://api.genex.games", dashboard: "https://genex.games" },
|
|
@@ -445,7 +445,7 @@ async function authorize(authBaseUrl, options) {
|
|
|
445
445
|
cleanup();
|
|
446
446
|
resolve(token);
|
|
447
447
|
};
|
|
448
|
-
const
|
|
448
|
+
const fail2 = (err) => {
|
|
449
449
|
if (settled) return;
|
|
450
450
|
settled = true;
|
|
451
451
|
cleanup();
|
|
@@ -462,7 +462,7 @@ async function authorize(authBaseUrl, options) {
|
|
|
462
462
|
}
|
|
463
463
|
});
|
|
464
464
|
});
|
|
465
|
-
server.on("error", (err) =>
|
|
465
|
+
server.on("error", (err) => fail2(err));
|
|
466
466
|
server.listen(0, "127.0.0.1", () => {
|
|
467
467
|
const { port } = server.address();
|
|
468
468
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
@@ -493,7 +493,7 @@ async function authorize(authBaseUrl, options) {
|
|
|
493
493
|
);
|
|
494
494
|
}
|
|
495
495
|
timer = setTimeout(() => {
|
|
496
|
-
|
|
496
|
+
fail2(
|
|
497
497
|
new Error(
|
|
498
498
|
"Timed out waiting for authorization. Re-run the command to try again."
|
|
499
499
|
)
|
|
@@ -868,49 +868,6 @@ async function writeGitignore(dir, log) {
|
|
|
868
868
|
await fs4.writeFile(file, next);
|
|
869
869
|
log.dim(`Updated .gitignore (${toAdd.join(", ")}).`);
|
|
870
870
|
}
|
|
871
|
-
var LFS_PATTERNS = [
|
|
872
|
-
// models
|
|
873
|
-
"*.glb",
|
|
874
|
-
"*.gltf",
|
|
875
|
-
"*.bin",
|
|
876
|
-
"*.vrm",
|
|
877
|
-
"*.fbx",
|
|
878
|
-
"*.obj",
|
|
879
|
-
// textures / images
|
|
880
|
-
"*.png",
|
|
881
|
-
"*.jpg",
|
|
882
|
-
"*.jpeg",
|
|
883
|
-
"*.webp",
|
|
884
|
-
"*.ktx2",
|
|
885
|
-
"*.basis",
|
|
886
|
-
"*.hdr",
|
|
887
|
-
"*.exr",
|
|
888
|
-
"*.tga",
|
|
889
|
-
// audio / video
|
|
890
|
-
"*.mp3",
|
|
891
|
-
"*.ogg",
|
|
892
|
-
"*.wav",
|
|
893
|
-
"*.m4a",
|
|
894
|
-
"*.mp4",
|
|
895
|
-
"*.webm"
|
|
896
|
-
];
|
|
897
|
-
async function writeGitattributes(dir, log) {
|
|
898
|
-
const file = path4.join(dir, ".gitattributes");
|
|
899
|
-
let content = "";
|
|
900
|
-
try {
|
|
901
|
-
content = await fs4.readFile(file, "utf8");
|
|
902
|
-
} catch {
|
|
903
|
-
}
|
|
904
|
-
const present = new Set(content.split("\n").map((l) => l.trim().split(/\s+/)[0]));
|
|
905
|
-
const toAdd = LFS_PATTERNS.filter((p) => !present.has(p));
|
|
906
|
-
if (toAdd.length === 0) return;
|
|
907
|
-
let next = content;
|
|
908
|
-
if (next.length > 0 && !next.endsWith("\n")) next += "\n";
|
|
909
|
-
if (!content.trim()) next += "# Binary game assets go to Git LFS (R2) \u2014 keeps the source push small\n";
|
|
910
|
-
next += toAdd.map((p) => `${p} filter=lfs diff=lfs merge=lfs -text`).join("\n") + "\n";
|
|
911
|
-
await fs4.writeFile(file, next);
|
|
912
|
-
log.dim(`Updated .gitattributes (${toAdd.length} binary globs \u2192 Git LFS).`);
|
|
913
|
-
}
|
|
914
871
|
|
|
915
872
|
// src/lib/store.ts
|
|
916
873
|
import fs6 from "fs/promises";
|
|
@@ -1555,28 +1512,6 @@ async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
|
1555
1512
|
["node_modules/", "dist/", ".git/", ".genex/", ".env", ".env.*", "!.env.example", ""].join("\n")
|
|
1556
1513
|
);
|
|
1557
1514
|
const env = { ...base, GIT_WORK_TREE: cwd, GIT_INDEX_FILE: path10.join(gitDir, "index-source") };
|
|
1558
|
-
let lfs = (await run("git", ["lfs", "version"], base)).code !== 0 ? false : true;
|
|
1559
|
-
if (!lfs) {
|
|
1560
|
-
log.step("Installing git-lfs (keeps large binary assets out of the source push)\u2026");
|
|
1561
|
-
const [bin, args] = process.platform === "darwin" ? ["brew", ["install", "git-lfs"]] : ["sudo", ["-n", "apt-get", "install", "-y", "git-lfs"]];
|
|
1562
|
-
await run(bin, args, base);
|
|
1563
|
-
lfs = (await run("git", ["lfs", "version"], base)).code === 0;
|
|
1564
|
-
if (!lfs) {
|
|
1565
|
-
log.warn(
|
|
1566
|
-
"Couldn't install git-lfs \u2014 large binary assets won't be saved to your source (the game still publishes)."
|
|
1567
|
-
);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
if (lfs) {
|
|
1571
|
-
await writeGitattributes(cwd, log);
|
|
1572
|
-
const filters = [
|
|
1573
|
-
["filter.lfs.clean", "git-lfs clean -- %f"],
|
|
1574
|
-
["filter.lfs.smudge", "git-lfs smudge -- %f"],
|
|
1575
|
-
["filter.lfs.process", "git-lfs filter-process"],
|
|
1576
|
-
["filter.lfs.required", "true"]
|
|
1577
|
-
];
|
|
1578
|
-
for (const [key, value] of filters) await run("git", ["config", key, value], base);
|
|
1579
|
-
}
|
|
1580
1515
|
await run("git", ["add", "-A"], env);
|
|
1581
1516
|
const tree = (await run("git", ["ls-files"], env)).out.trim() ? (await run("git", ["write-tree"], env)).out.trim() : "";
|
|
1582
1517
|
if (!tree || tree === EMPTY_TREE) {
|
|
@@ -1585,10 +1520,6 @@ async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
|
1585
1520
|
}
|
|
1586
1521
|
const commit = (await run("git", ["commit-tree", tree, "-m", "source"], { ...base, ...ident })).out.trim();
|
|
1587
1522
|
await run("git", ["update-ref", "refs/heads/main", commit], base);
|
|
1588
|
-
if (lfs && (await run("git", ["lfs", "push", "--all", pushUrl, "main"], base)).code !== 0) {
|
|
1589
|
-
log.error("Couldn't upload your game's binary assets to the source store.");
|
|
1590
|
-
return false;
|
|
1591
|
-
}
|
|
1592
1523
|
const push = await run(
|
|
1593
1524
|
"git",
|
|
1594
1525
|
["-c", "http.postBuffer=536870912", "push", "-q", pushUrl, "+refs/heads/main:main"],
|
|
@@ -1825,13 +1756,6 @@ function advisoryNudges(log, d) {
|
|
|
1825
1756
|
);
|
|
1826
1757
|
}
|
|
1827
1758
|
}
|
|
1828
|
-
async function firstPreviewNudge(log, cwd = process.cwd()) {
|
|
1829
|
-
const meta = await readProject(cwd);
|
|
1830
|
-
if (!meta || meta.lastDeployAt || meta.status === "published") return;
|
|
1831
|
-
log.dim(
|
|
1832
|
-
" \u279C Draft page still the placeholder? Ship the first rough build now: npx genex preview \u2014 then keep building."
|
|
1833
|
-
);
|
|
1834
|
-
}
|
|
1835
1759
|
|
|
1836
1760
|
// src/commands/publish.ts
|
|
1837
1761
|
async function runPublish(opts) {
|
|
@@ -1868,8 +1792,6 @@ async function runPublish(opts) {
|
|
|
1868
1792
|
process.exitCode = 1;
|
|
1869
1793
|
return;
|
|
1870
1794
|
}
|
|
1871
|
-
meta.lastDeployAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1872
|
-
await writeProject(meta);
|
|
1873
1795
|
} else {
|
|
1874
1796
|
log.info("Skipping build + deploy (--no-push).");
|
|
1875
1797
|
}
|
|
@@ -1953,9 +1875,8 @@ async function runPreview(opts) {
|
|
|
1953
1875
|
const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
|
|
1954
1876
|
if (freshStatus && freshStatus !== meta.status) {
|
|
1955
1877
|
meta.status = freshStatus;
|
|
1878
|
+
await writeProject(meta);
|
|
1956
1879
|
}
|
|
1957
|
-
meta.lastDeployAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1958
|
-
await writeProject(meta);
|
|
1959
1880
|
const published = meta.status === "published";
|
|
1960
1881
|
log.plain("");
|
|
1961
1882
|
if (published) {
|
|
@@ -2011,6 +1932,40 @@ async function* readSSE(body) {
|
|
|
2011
1932
|
|
|
2012
1933
|
// src/commands/generate.ts
|
|
2013
1934
|
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1935
|
+
function buildGenOptions(kind, opts) {
|
|
1936
|
+
const options = {};
|
|
1937
|
+
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
1938
|
+
if (kind === "sfx" && opts.duration) options.durationSeconds = opts.duration;
|
|
1939
|
+
if (kind === "image") {
|
|
1940
|
+
if (opts.transparent) options.transparent = true;
|
|
1941
|
+
if (opts.aspect) options.aspect = opts.aspect;
|
|
1942
|
+
if (opts.quality) options.quality = opts.quality;
|
|
1943
|
+
if (opts.candidates !== void 0 && opts.candidates > 1) options.candidates = opts.candidates;
|
|
1944
|
+
if (opts.width && opts.height) {
|
|
1945
|
+
options.width = opts.width;
|
|
1946
|
+
options.height = opts.height;
|
|
1947
|
+
}
|
|
1948
|
+
if (opts.editUrl) {
|
|
1949
|
+
options.sourceUrl = opts.editUrl;
|
|
1950
|
+
options.op = "edit";
|
|
1951
|
+
}
|
|
1952
|
+
if (opts.cleanUrl) {
|
|
1953
|
+
options.sourceUrl = opts.cleanUrl;
|
|
1954
|
+
options.op = "remove-bg";
|
|
1955
|
+
options.bgMode = opts.bgMode ?? "sheet";
|
|
1956
|
+
}
|
|
1957
|
+
if (opts.removeBackground) options.removeBackground = true;
|
|
1958
|
+
if (opts.bgMode) options.bgMode = opts.bgMode;
|
|
1959
|
+
}
|
|
1960
|
+
if (kind === "video") {
|
|
1961
|
+
if (opts.duration) options.durationSeconds = opts.duration;
|
|
1962
|
+
if (opts.loop) options.loop = true;
|
|
1963
|
+
if (opts.frameUrl) options.frameUrl = opts.frameUrl;
|
|
1964
|
+
if (opts.firstFrameUrl) options.firstFrameUrl = opts.firstFrameUrl;
|
|
1965
|
+
if (opts.lastFrameUrl) options.lastFrameUrl = opts.lastFrameUrl;
|
|
1966
|
+
}
|
|
1967
|
+
return options;
|
|
1968
|
+
}
|
|
2014
1969
|
async function runGenerate(kind, opts) {
|
|
2015
1970
|
const log = createLogger({ quiet: opts.quiet });
|
|
2016
1971
|
const prompt = opts.prompt?.trim();
|
|
@@ -2027,13 +1982,7 @@ async function runGenerate(kind, opts) {
|
|
|
2027
1982
|
}
|
|
2028
1983
|
const meta = await readProject();
|
|
2029
1984
|
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
2030
|
-
const options =
|
|
2031
|
-
if (kind === "texture" && opts.terrain) options.terrain = true;
|
|
2032
|
-
if (kind === "sfx" && opts.duration) options.durationSeconds = opts.duration;
|
|
2033
|
-
if (kind === "image" && opts.transparent) options.transparent = true;
|
|
2034
|
-
if (kind === "image" && opts.aspect) options.aspect = opts.aspect;
|
|
2035
|
-
if (kind === "video" && opts.duration) options.durationSeconds = opts.duration;
|
|
2036
|
-
if (kind === "video" && opts.loop) options.loop = true;
|
|
1985
|
+
const options = buildGenOptions(kind, opts);
|
|
2037
1986
|
log.plain(c.bold(`genex ${kind}`));
|
|
2038
1987
|
log.dim(` ${prompt}`);
|
|
2039
1988
|
log.plain("");
|
|
@@ -2054,7 +2003,15 @@ async function runGenerate(kind, opts) {
|
|
|
2054
2003
|
return;
|
|
2055
2004
|
}
|
|
2056
2005
|
if (!res.ok) {
|
|
2057
|
-
|
|
2006
|
+
let message;
|
|
2007
|
+
if (res.status >= 400 && res.status < 500) {
|
|
2008
|
+
try {
|
|
2009
|
+
const body = await res.json();
|
|
2010
|
+
if (typeof body.message === "string" && body.message.trim()) message = body.message;
|
|
2011
|
+
} catch {
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
log.error(message ?? `Generation request failed (HTTP ${res.status}).`);
|
|
2058
2015
|
process.exitCode = 1;
|
|
2059
2016
|
return;
|
|
2060
2017
|
}
|
|
@@ -2065,9 +2022,14 @@ async function runGenerate(kind, opts) {
|
|
|
2065
2022
|
return;
|
|
2066
2023
|
}
|
|
2067
2024
|
if (opts.noWait) {
|
|
2068
|
-
log.success(`Queued (${id})
|
|
2025
|
+
log.success(`Queued (${id}).`);
|
|
2026
|
+
log.plain(` Pick it up any time with: ${c.cyan(`npx genex wait ${id}`)}`);
|
|
2027
|
+
log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
|
|
2069
2028
|
return;
|
|
2070
2029
|
}
|
|
2030
|
+
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2031
|
+
}
|
|
2032
|
+
async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
2071
2033
|
log.step("Generating\u2026 (this can take up to a minute)");
|
|
2072
2034
|
const onProgress = (p) => log.dim(` ${p}%`);
|
|
2073
2035
|
const timeoutMs = waitTimeoutFor(kind);
|
|
@@ -2079,6 +2041,9 @@ async function runGenerate(kind, opts) {
|
|
|
2079
2041
|
process.exitCode = 1;
|
|
2080
2042
|
return;
|
|
2081
2043
|
}
|
|
2044
|
+
reportTerminal(kind, view, log);
|
|
2045
|
+
}
|
|
2046
|
+
function reportTerminal(kind, view, log) {
|
|
2082
2047
|
if (view.status !== "completed") {
|
|
2083
2048
|
log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
2084
2049
|
process.exitCode = 1;
|
|
@@ -2098,7 +2063,6 @@ async function runGenerate(kind, opts) {
|
|
|
2098
2063
|
}
|
|
2099
2064
|
log.plain("");
|
|
2100
2065
|
printHint(kind, files, log);
|
|
2101
|
-
await firstPreviewNudge(log);
|
|
2102
2066
|
}
|
|
2103
2067
|
var WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2104
2068
|
var KIND_WAIT_TIMEOUT_MS = {
|
|
@@ -2196,6 +2160,63 @@ function printHint(kind, files, log) {
|
|
|
2196
2160
|
log.dim(" Reference the URL directly in your code \u2014 don't download it into the repo.");
|
|
2197
2161
|
}
|
|
2198
2162
|
|
|
2163
|
+
// src/commands/wait.ts
|
|
2164
|
+
var TERMINAL2 = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
2165
|
+
async function runWait(opts) {
|
|
2166
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
2167
|
+
const id = opts.id?.trim();
|
|
2168
|
+
if (!id) {
|
|
2169
|
+
log.error(
|
|
2170
|
+
`Missing generation id. Usage: ${c.cyan("genex wait <id>")} \u2014 the id printed when you enqueued with --no-wait.`
|
|
2171
|
+
);
|
|
2172
|
+
process.exitCode = 1;
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
const token = opts.token ?? await readUserToken(opts.envPath);
|
|
2176
|
+
if (!token) {
|
|
2177
|
+
log.error("Not authorized. Run `genex init` first to sign in.");
|
|
2178
|
+
process.exitCode = 1;
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
const meta = await readProject();
|
|
2182
|
+
const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
|
|
2183
|
+
let view;
|
|
2184
|
+
try {
|
|
2185
|
+
const res = await apiFetch(`${apiUrl}/api/generations/${id}`, {
|
|
2186
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
2187
|
+
});
|
|
2188
|
+
if (res.status === 401) {
|
|
2189
|
+
log.error("Unauthorized \u2014 your token may have expired. Re-run `genex init`.");
|
|
2190
|
+
process.exitCode = 1;
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (res.status === 404) {
|
|
2194
|
+
log.error(`No generation ${id} on this account \u2014 check the id printed by --no-wait.`);
|
|
2195
|
+
process.exitCode = 1;
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
if (!res.ok) {
|
|
2199
|
+
log.error(`Couldn't look up the generation (HTTP ${res.status}).`);
|
|
2200
|
+
process.exitCode = 1;
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
({ generation: view } = await res.json());
|
|
2204
|
+
} catch (err) {
|
|
2205
|
+
log.error(`Couldn't reach the API at ${apiUrl}: ${String(err)}`);
|
|
2206
|
+
process.exitCode = 1;
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
const kind = view.kind ?? "image";
|
|
2210
|
+
log.plain(c.bold("genex wait"));
|
|
2211
|
+
log.dim(` ${kind} ${id}`);
|
|
2212
|
+
log.plain("");
|
|
2213
|
+
if (TERMINAL2.has(view.status)) {
|
|
2214
|
+
reportTerminal(kind, view, log);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2199
2220
|
// src/commands/controller.ts
|
|
2200
2221
|
import fs12 from "fs/promises";
|
|
2201
2222
|
import path13 from "path";
|
|
@@ -2669,7 +2690,6 @@ async function runController(opts) {
|
|
|
2669
2690
|
for (const line of set.sketch) {
|
|
2670
2691
|
log.dim(` ${line}`);
|
|
2671
2692
|
}
|
|
2672
|
-
await firstPreviewNudge(log);
|
|
2673
2693
|
}
|
|
2674
2694
|
async function installOwnerAvatar(args) {
|
|
2675
2695
|
const { root, srcDir, apiUrl, token, log } = args;
|
|
@@ -2776,6 +2796,906 @@ function rank(items, query) {
|
|
|
2776
2796
|
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || b.p.playsCount - a.p.playsCount).map((x) => x.p);
|
|
2777
2797
|
}
|
|
2778
2798
|
|
|
2799
|
+
// src/commands/ui.ts
|
|
2800
|
+
import fs14 from "fs/promises";
|
|
2801
|
+
import path14 from "path";
|
|
2802
|
+
import { PNG as PNG2 } from "pngjs";
|
|
2803
|
+
|
|
2804
|
+
// src/lib/png-tools.ts
|
|
2805
|
+
import fs13 from "fs/promises";
|
|
2806
|
+
import { PNG } from "pngjs";
|
|
2807
|
+
var ALPHA_TRANSPARENT_MAX = 16;
|
|
2808
|
+
var isHttpUrl = (s) => /^https?:\/\//i.test(s);
|
|
2809
|
+
async function loadPng(input) {
|
|
2810
|
+
let buf;
|
|
2811
|
+
if (isHttpUrl(input)) {
|
|
2812
|
+
const res = await fetch(input);
|
|
2813
|
+
if (!res.ok) throw new Error(`Couldn't fetch ${input} (HTTP ${res.status}).`);
|
|
2814
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
2815
|
+
} else {
|
|
2816
|
+
buf = await fs13.readFile(input);
|
|
2817
|
+
}
|
|
2818
|
+
return PNG.sync.read(buf);
|
|
2819
|
+
}
|
|
2820
|
+
async function writePng(file, png) {
|
|
2821
|
+
await fs13.writeFile(file, PNG.sync.write(png));
|
|
2822
|
+
}
|
|
2823
|
+
function cropPng(image, box) {
|
|
2824
|
+
const out = new PNG({ width: box.w, height: box.h });
|
|
2825
|
+
PNG.bitblt(image, out, box.x, box.y, box.w, box.h, 0, 0);
|
|
2826
|
+
return out;
|
|
2827
|
+
}
|
|
2828
|
+
function floodFill(width, height, visited, queue, startX, startY, inRegion, onPixel) {
|
|
2829
|
+
let head = 0;
|
|
2830
|
+
let tail = 0;
|
|
2831
|
+
const start = startY * width + startX;
|
|
2832
|
+
visited[start] = 1;
|
|
2833
|
+
queue[tail++] = start;
|
|
2834
|
+
let pixels = 0;
|
|
2835
|
+
let minX = startX;
|
|
2836
|
+
let minY = startY;
|
|
2837
|
+
let maxX = startX;
|
|
2838
|
+
let maxY = startY;
|
|
2839
|
+
let touchesEdge = false;
|
|
2840
|
+
while (head < tail) {
|
|
2841
|
+
const idx = queue[head++];
|
|
2842
|
+
const x = idx % width;
|
|
2843
|
+
const y = idx / width | 0;
|
|
2844
|
+
pixels++;
|
|
2845
|
+
if (onPixel) onPixel(idx);
|
|
2846
|
+
if (x < minX) minX = x;
|
|
2847
|
+
if (x > maxX) maxX = x;
|
|
2848
|
+
if (y < minY) minY = y;
|
|
2849
|
+
if (y > maxY) maxY = y;
|
|
2850
|
+
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) touchesEdge = true;
|
|
2851
|
+
if (x + 1 < width && !visited[idx + 1] && inRegion(idx + 1)) {
|
|
2852
|
+
visited[idx + 1] = 1;
|
|
2853
|
+
queue[tail++] = idx + 1;
|
|
2854
|
+
}
|
|
2855
|
+
if (x > 0 && !visited[idx - 1] && inRegion(idx - 1)) {
|
|
2856
|
+
visited[idx - 1] = 1;
|
|
2857
|
+
queue[tail++] = idx - 1;
|
|
2858
|
+
}
|
|
2859
|
+
if (y + 1 < height && !visited[idx + width] && inRegion(idx + width)) {
|
|
2860
|
+
visited[idx + width] = 1;
|
|
2861
|
+
queue[tail++] = idx + width;
|
|
2862
|
+
}
|
|
2863
|
+
if (y > 0 && !visited[idx - width] && inRegion(idx - width)) {
|
|
2864
|
+
visited[idx - width] = 1;
|
|
2865
|
+
queue[tail++] = idx - width;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
return { pixels, minX, minY, maxX, maxY, touchesEdge };
|
|
2869
|
+
}
|
|
2870
|
+
function scanComponents(width, height, inRegion, onComponent, onPixel) {
|
|
2871
|
+
const visited = new Uint8Array(width * height);
|
|
2872
|
+
const queue = new Int32Array(width * height);
|
|
2873
|
+
for (let y = 0; y < height; y++) {
|
|
2874
|
+
for (let x = 0; x < width; x++) {
|
|
2875
|
+
const idx = y * width + x;
|
|
2876
|
+
if (visited[idx] || !inRegion(idx)) continue;
|
|
2877
|
+
onComponent(floodFill(width, height, visited, queue, x, y, inRegion, onPixel));
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
function alphaBBoxPx(png) {
|
|
2882
|
+
const { width, height, data } = png;
|
|
2883
|
+
let minX = width;
|
|
2884
|
+
let minY = height;
|
|
2885
|
+
let maxX = -1;
|
|
2886
|
+
let maxY = -1;
|
|
2887
|
+
for (let y = 0; y < height; y++) {
|
|
2888
|
+
for (let x = 0; x < width; x++) {
|
|
2889
|
+
if (data[(y * width + x) * 4 + 3] > ALPHA_TRANSPARENT_MAX) {
|
|
2890
|
+
if (x < minX) minX = x;
|
|
2891
|
+
if (x > maxX) maxX = x;
|
|
2892
|
+
if (y < minY) minY = y;
|
|
2893
|
+
if (y > maxY) maxY = y;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
return maxX < 0 ? null : { minX, minY, maxX, maxY };
|
|
2898
|
+
}
|
|
2899
|
+
function computeBBoxes(png) {
|
|
2900
|
+
const { width, height, data } = png;
|
|
2901
|
+
const r3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
2902
|
+
const result = {
|
|
2903
|
+
size: { w: width, h: height },
|
|
2904
|
+
aspectRatio: r3(width / height),
|
|
2905
|
+
outerBBox: null,
|
|
2906
|
+
innerBBox: null
|
|
2907
|
+
};
|
|
2908
|
+
const outer = alphaBBoxPx(png);
|
|
2909
|
+
if (!outer) return result;
|
|
2910
|
+
const { minX, minY, maxX, maxY } = outer;
|
|
2911
|
+
result.outerBBox = {
|
|
2912
|
+
x: r3(minX / width),
|
|
2913
|
+
y: r3(minY / height),
|
|
2914
|
+
w: r3((maxX - minX + 1) / width),
|
|
2915
|
+
h: r3((maxY - minY + 1) / height)
|
|
2916
|
+
};
|
|
2917
|
+
const cx = Math.floor((minX + maxX) / 2);
|
|
2918
|
+
const cy = Math.floor((minY + maxY) / 2);
|
|
2919
|
+
if (data[(cy * width + cx) * 4 + 3] > ALPHA_TRANSPARENT_MAX) return result;
|
|
2920
|
+
const cavity = floodFill(
|
|
2921
|
+
width,
|
|
2922
|
+
height,
|
|
2923
|
+
new Uint8Array(width * height),
|
|
2924
|
+
new Int32Array(width * height),
|
|
2925
|
+
cx,
|
|
2926
|
+
cy,
|
|
2927
|
+
(idx) => data[idx * 4 + 3] <= ALPHA_TRANSPARENT_MAX
|
|
2928
|
+
);
|
|
2929
|
+
if (cavity.touchesEdge) return result;
|
|
2930
|
+
const cavW = cavity.maxX - cavity.minX + 1;
|
|
2931
|
+
const cavH = cavity.maxY - cavity.minY + 1;
|
|
2932
|
+
if (cavW > width * 0.1 && cavH > height * 0.1) {
|
|
2933
|
+
result.innerBBox = {
|
|
2934
|
+
x: r3(cavity.minX / width),
|
|
2935
|
+
y: r3(cavity.minY / height),
|
|
2936
|
+
w: r3(cavW / width),
|
|
2937
|
+
h: r3(cavH / height)
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
return result;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// src/commands/ui.ts
|
|
2944
|
+
var UI_NUMBER_FLAGS = {
|
|
2945
|
+
"--min-pixels": "minPixels",
|
|
2946
|
+
"--padding": "padding",
|
|
2947
|
+
"--chroma": "chroma",
|
|
2948
|
+
"--dilate": "dilate",
|
|
2949
|
+
"--registration-tolerance": "registrationTolerance",
|
|
2950
|
+
"--coverage-min": "coverageMin",
|
|
2951
|
+
"--coverage-max": "coverageMax",
|
|
2952
|
+
"--component-min-pixels": "componentMinPixels",
|
|
2953
|
+
"--edge-flush-max": "edgeFlushMax",
|
|
2954
|
+
"--quantize": "quantize",
|
|
2955
|
+
"--ink-dist": "inkDist",
|
|
2956
|
+
"--min-frac": "minFrac"
|
|
2957
|
+
};
|
|
2958
|
+
var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or https URL>)")}
|
|
2959
|
+
ui extract --out-dir <dir> --names a,b,c (reading order) \u2014 split an
|
|
2960
|
+
asset sheet into per-element transparent PNGs + .bbox.json
|
|
2961
|
+
sidecars. [--min-pixels 2000] [--padding 8] [--chroma 240]
|
|
2962
|
+
[--dilate 0]
|
|
2963
|
+
ui masks --out-dir <dir> --pairs "name:cX,cY,w,h:aX,aY,w,h[:n];\u2026" \u2014
|
|
2964
|
+
clean frame + green-annotated duplicate \u2192 frame + fill mask
|
|
2965
|
+
+ fillBox (+ overlay + provenance JSON). [--registration-
|
|
2966
|
+
tolerance 0.04] [--coverage-min 0.01] [--coverage-max 0.85]
|
|
2967
|
+
[--component-min-pixels 100] [--edge-flush-max 0.04]
|
|
2968
|
+
ui text-color --box x,y,w,h \u2014 eyedropper a text region: plate color +
|
|
2969
|
+
ink candidates as JSON. [--quantize 12] [--ink-dist 60]
|
|
2970
|
+
[--min-frac 0.004] [--out <json>] [--crop <png>]
|
|
2971
|
+
ui trim [--out <png>] \u2014 crop to the alpha bbox (default: overwrite
|
|
2972
|
+
--in), print real dims + refresh the .bbox.json sidecar.`;
|
|
2973
|
+
async function runUi(opts) {
|
|
2974
|
+
const log = createLogger({ quiet: opts.quiet });
|
|
2975
|
+
try {
|
|
2976
|
+
switch (opts.sub?.trim()) {
|
|
2977
|
+
case "extract":
|
|
2978
|
+
await uiExtract(opts, log);
|
|
2979
|
+
return;
|
|
2980
|
+
case "masks":
|
|
2981
|
+
await uiMasks(opts, log);
|
|
2982
|
+
return;
|
|
2983
|
+
case "text-color":
|
|
2984
|
+
await uiTextColor(opts, log);
|
|
2985
|
+
return;
|
|
2986
|
+
case "trim":
|
|
2987
|
+
await uiTrim(opts, log);
|
|
2988
|
+
return;
|
|
2989
|
+
default:
|
|
2990
|
+
log.error(
|
|
2991
|
+
opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim.` : "Missing ui tool. Tools: extract, masks, text-color, trim."
|
|
2992
|
+
);
|
|
2993
|
+
log.plain(UI_USAGE);
|
|
2994
|
+
process.exitCode = 1;
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
} catch (err) {
|
|
2998
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
2999
|
+
process.exitCode = 1;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
function fail(message, details) {
|
|
3003
|
+
throw new Error(details === void 0 ? message : `${message} ${JSON.stringify(details)}`);
|
|
3004
|
+
}
|
|
3005
|
+
function requireOpt(value, flag, sub) {
|
|
3006
|
+
if (!value?.trim()) fail(`Missing ${flag}. Run \`genex ui ${sub}\` \u2014 see \`genex --help\`.`);
|
|
3007
|
+
return value.trim();
|
|
3008
|
+
}
|
|
3009
|
+
async function uiExtract(opts, log) {
|
|
3010
|
+
const input = requireOpt(opts.input, "--in", "extract");
|
|
3011
|
+
const outDir = requireOpt(opts.outDir, "--out-dir", "extract");
|
|
3012
|
+
const names = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
3013
|
+
if (names.length === 0) fail("Missing --names (comma-separated element names, reading order).");
|
|
3014
|
+
const minPixels = opts.minPixels ?? 2e3;
|
|
3015
|
+
const padding = opts.padding ?? 8;
|
|
3016
|
+
const chroma = opts.chroma ?? 240;
|
|
3017
|
+
const dilatePx = opts.dilate ?? 0;
|
|
3018
|
+
const sheet = await loadPng(input);
|
|
3019
|
+
const { width: W, height: H, data } = sheet;
|
|
3020
|
+
await fs14.mkdir(outDir, { recursive: true });
|
|
3021
|
+
log.plain(c.bold("genex ui extract"));
|
|
3022
|
+
log.dim(` ${input} (${W}x${H}) \u2192 ${outDir}, ${names.length} names`);
|
|
3023
|
+
let hasTransparency = false;
|
|
3024
|
+
for (let i = 0; i < W * H; i++) {
|
|
3025
|
+
if (data[i * 4 + 3] < 250) {
|
|
3026
|
+
hasTransparency = true;
|
|
3027
|
+
break;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
const inputMode = hasTransparency ? "alpha" : "chroma";
|
|
3031
|
+
const subject = new Uint8Array(W * H);
|
|
3032
|
+
let subjectCount = 0;
|
|
3033
|
+
for (let i = 0; i < W * H; i++) {
|
|
3034
|
+
const a = data[i * 4 + 3];
|
|
3035
|
+
const isOpaque = a > ALPHA_TRANSPARENT_MAX;
|
|
3036
|
+
let isSubject = false;
|
|
3037
|
+
if (inputMode === "alpha") {
|
|
3038
|
+
isSubject = isOpaque;
|
|
3039
|
+
} else {
|
|
3040
|
+
const r = data[i * 4 + 0];
|
|
3041
|
+
const g = data[i * 4 + 1];
|
|
3042
|
+
const b = data[i * 4 + 2];
|
|
3043
|
+
const isWhite = r >= chroma && g >= chroma && b >= chroma;
|
|
3044
|
+
isSubject = isOpaque && !isWhite;
|
|
3045
|
+
}
|
|
3046
|
+
if (isSubject) {
|
|
3047
|
+
subject[i] = 1;
|
|
3048
|
+
subjectCount++;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
log.dim(
|
|
3052
|
+
` mask: ${inputMode} mode, ${subjectCount} subject px (${(subjectCount / (W * H) * 100).toFixed(1)}%)`
|
|
3053
|
+
);
|
|
3054
|
+
let connect = subject;
|
|
3055
|
+
if (dilatePx > 0) {
|
|
3056
|
+
const tmp = new Uint8Array(W * H);
|
|
3057
|
+
for (let y = 0; y < H; y++) {
|
|
3058
|
+
let run2 = 0;
|
|
3059
|
+
for (let x = 0; x < W; x++) {
|
|
3060
|
+
if (subject[y * W + x]) run2 = dilatePx;
|
|
3061
|
+
else if (run2 > 0) run2--;
|
|
3062
|
+
tmp[y * W + x] = run2 > 0 || subject[y * W + x] ? 1 : 0;
|
|
3063
|
+
}
|
|
3064
|
+
run2 = 0;
|
|
3065
|
+
for (let x = W - 1; x >= 0; x--) {
|
|
3066
|
+
if (subject[y * W + x]) run2 = dilatePx;
|
|
3067
|
+
else if (run2 > 0) run2--;
|
|
3068
|
+
if (run2 > 0) tmp[y * W + x] = 1;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
const tmp2 = new Uint8Array(W * H);
|
|
3072
|
+
for (let x = 0; x < W; x++) {
|
|
3073
|
+
let run2 = 0;
|
|
3074
|
+
for (let y = 0; y < H; y++) {
|
|
3075
|
+
if (tmp[y * W + x]) run2 = dilatePx;
|
|
3076
|
+
else if (run2 > 0) run2--;
|
|
3077
|
+
tmp2[y * W + x] = run2 > 0 || tmp[y * W + x] ? 1 : 0;
|
|
3078
|
+
}
|
|
3079
|
+
run2 = 0;
|
|
3080
|
+
for (let y = H - 1; y >= 0; y--) {
|
|
3081
|
+
if (tmp[y * W + x]) run2 = dilatePx;
|
|
3082
|
+
else if (run2 > 0) run2--;
|
|
3083
|
+
if (run2 > 0) tmp2[y * W + x] = 1;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
connect = tmp2;
|
|
3087
|
+
}
|
|
3088
|
+
const components = [];
|
|
3089
|
+
let realSubjectPixels = 0;
|
|
3090
|
+
scanComponents(
|
|
3091
|
+
W,
|
|
3092
|
+
H,
|
|
3093
|
+
(idx) => connect[idx] !== 0,
|
|
3094
|
+
(stats) => {
|
|
3095
|
+
components.push({
|
|
3096
|
+
minX: stats.minX,
|
|
3097
|
+
minY: stats.minY,
|
|
3098
|
+
maxX: stats.maxX,
|
|
3099
|
+
maxY: stats.maxY,
|
|
3100
|
+
pixels: stats.pixels,
|
|
3101
|
+
subjectPixels: realSubjectPixels,
|
|
3102
|
+
cx: 0,
|
|
3103
|
+
cy: 0
|
|
3104
|
+
});
|
|
3105
|
+
realSubjectPixels = 0;
|
|
3106
|
+
},
|
|
3107
|
+
(idx) => {
|
|
3108
|
+
if (subject[idx]) realSubjectPixels++;
|
|
3109
|
+
}
|
|
3110
|
+
);
|
|
3111
|
+
const big = components.filter((comp) => comp.subjectPixels >= minPixels);
|
|
3112
|
+
log.dim(
|
|
3113
|
+
` components: ${big.length} kept, ${components.length - big.length} discarded (< ${minPixels} px)`
|
|
3114
|
+
);
|
|
3115
|
+
if (big.length < names.length) {
|
|
3116
|
+
log.warn(
|
|
3117
|
+
`Only ${big.length} components \u2265 ${minPixels}px; expected ${names.length}. Lower --min-pixels or check the sheet.`
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
const heights = big.map((comp) => comp.maxY - comp.minY + 1).sort((a, b) => a - b);
|
|
3121
|
+
const medianH = heights[Math.floor(heights.length / 2)] || 50;
|
|
3122
|
+
const bandH = Math.max(20, medianH * 0.6);
|
|
3123
|
+
for (const comp of big) {
|
|
3124
|
+
comp.cx = (comp.minX + comp.maxX) / 2;
|
|
3125
|
+
comp.cy = (comp.minY + comp.maxY) / 2;
|
|
3126
|
+
}
|
|
3127
|
+
big.sort((a, b) => a.cy - b.cy);
|
|
3128
|
+
const bands = [];
|
|
3129
|
+
for (const comp of big) {
|
|
3130
|
+
const last = bands[bands.length - 1];
|
|
3131
|
+
if (!last || comp.cy - last.maxCy > bandH) {
|
|
3132
|
+
bands.push({ items: [comp], maxCy: comp.cy });
|
|
3133
|
+
} else {
|
|
3134
|
+
last.items.push(comp);
|
|
3135
|
+
last.maxCy = Math.max(last.maxCy, comp.cy);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
const sorted = [];
|
|
3139
|
+
for (const band of bands) {
|
|
3140
|
+
band.items.sort((a, b) => a.cx - b.cx);
|
|
3141
|
+
sorted.push(...band.items);
|
|
3142
|
+
}
|
|
3143
|
+
const extractions = [];
|
|
3144
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
3145
|
+
const comp = sorted[i];
|
|
3146
|
+
const name = i < names.length ? names[i] : `extra-${i - names.length}`;
|
|
3147
|
+
const padX0 = Math.max(0, comp.minX - padding);
|
|
3148
|
+
const padY0 = Math.max(0, comp.minY - padding);
|
|
3149
|
+
const padX1 = Math.min(W, comp.maxX + padding + 1);
|
|
3150
|
+
const padY1 = Math.min(H, comp.maxY + padding + 1);
|
|
3151
|
+
const cropW = padX1 - padX0;
|
|
3152
|
+
const cropH = padY1 - padY0;
|
|
3153
|
+
if (cropW < 4 || cropH < 4) {
|
|
3154
|
+
log.warn(`Skipping "${name}" \u2014 crop too small (${cropW}x${cropH}).`);
|
|
3155
|
+
continue;
|
|
3156
|
+
}
|
|
3157
|
+
const out = new PNG2({ width: cropW, height: cropH });
|
|
3158
|
+
for (let yo = 0; yo < cropH; yo++) {
|
|
3159
|
+
const sy = padY0 + yo;
|
|
3160
|
+
for (let xo = 0; xo < cropW; xo++) {
|
|
3161
|
+
const sx = padX0 + xo;
|
|
3162
|
+
const sIdx = (sy * W + sx) * 4;
|
|
3163
|
+
const oIdx = (yo * cropW + xo) * 4;
|
|
3164
|
+
const r = data[sIdx + 0];
|
|
3165
|
+
const g = data[sIdx + 1];
|
|
3166
|
+
const b = data[sIdx + 2];
|
|
3167
|
+
const a = data[sIdx + 3];
|
|
3168
|
+
out.data[oIdx + 0] = r;
|
|
3169
|
+
out.data[oIdx + 1] = g;
|
|
3170
|
+
out.data[oIdx + 2] = b;
|
|
3171
|
+
if (inputMode === "alpha") {
|
|
3172
|
+
out.data[oIdx + 3] = a;
|
|
3173
|
+
} else {
|
|
3174
|
+
const isWhite = r >= chroma && g >= chroma && b >= chroma;
|
|
3175
|
+
out.data[oIdx + 3] = isWhite ? 0 : 255;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
const outPath = path14.join(outDir, `${name}.png`);
|
|
3180
|
+
await writePng(outPath, out);
|
|
3181
|
+
const sidecar = {
|
|
3182
|
+
name,
|
|
3183
|
+
out: outPath,
|
|
3184
|
+
sheet: { w: W, h: H },
|
|
3185
|
+
sheetBBox: { x: padX0, y: padY0, w: cropW, h: cropH },
|
|
3186
|
+
sheetBBoxNorm: {
|
|
3187
|
+
x: +(padX0 / W).toFixed(3),
|
|
3188
|
+
y: +(padY0 / H).toFixed(3),
|
|
3189
|
+
w: +(cropW / W).toFixed(3),
|
|
3190
|
+
h: +(cropH / H).toFixed(3)
|
|
3191
|
+
},
|
|
3192
|
+
cropDims: { w: cropW, h: cropH },
|
|
3193
|
+
aspectRatio: +(cropW / cropH).toFixed(3),
|
|
3194
|
+
componentPixels: comp.pixels
|
|
3195
|
+
};
|
|
3196
|
+
const { name: _n, out: _o, ...sidecarBody } = sidecar;
|
|
3197
|
+
await fs14.writeFile(
|
|
3198
|
+
outPath.replace(/\.png$/i, "") + ".bbox.json",
|
|
3199
|
+
JSON.stringify(sidecarBody, null, 2)
|
|
3200
|
+
);
|
|
3201
|
+
extractions.push(sidecar);
|
|
3202
|
+
log.success(
|
|
3203
|
+
`${name}.png ${cropW}x${cropH} (ar ${sidecar.aspectRatio}) at sheet ${padX0},${padY0}`
|
|
3204
|
+
);
|
|
3205
|
+
}
|
|
3206
|
+
const debugPath = path14.join(outDir, "extract-debug.json");
|
|
3207
|
+
await fs14.writeFile(
|
|
3208
|
+
debugPath,
|
|
3209
|
+
JSON.stringify(
|
|
3210
|
+
{
|
|
3211
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3212
|
+
input,
|
|
3213
|
+
sheet: { w: W, h: H },
|
|
3214
|
+
mode: inputMode,
|
|
3215
|
+
rawComponentCount: components.length,
|
|
3216
|
+
filteredComponentCount: big.length,
|
|
3217
|
+
bandH,
|
|
3218
|
+
extractions: extractions.map((e) => ({
|
|
3219
|
+
name: e.name,
|
|
3220
|
+
sheetBBox: e.sheetBBox,
|
|
3221
|
+
sheetBBoxNorm: e.sheetBBoxNorm,
|
|
3222
|
+
ar: e.aspectRatio
|
|
3223
|
+
}))
|
|
3224
|
+
},
|
|
3225
|
+
null,
|
|
3226
|
+
2
|
|
3227
|
+
)
|
|
3228
|
+
);
|
|
3229
|
+
const missing = names.filter((n) => !extractions.some((e) => e.name === n));
|
|
3230
|
+
log.plain("");
|
|
3231
|
+
log.success(`Extracted ${extractions.length} elements \u2192 ${outDir} (+ .bbox.json sidecars).`);
|
|
3232
|
+
if (missing.length > 0) {
|
|
3233
|
+
log.warn(`Missing: ${missing.join(", ")} \u2014 fewer components than names on the sheet.`);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
function parseBox(raw, label) {
|
|
3237
|
+
const parts = raw.split(",").map((part) => Number.parseInt(part.trim(), 10));
|
|
3238
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
3239
|
+
fail(`Invalid ${label} box \u2014 expected x,y,w,h.`, { raw });
|
|
3240
|
+
}
|
|
3241
|
+
const [x, y, w, h] = parts;
|
|
3242
|
+
if (w <= 0 || h <= 0) fail(`Invalid ${label} box dimensions.`, { raw });
|
|
3243
|
+
return { x, y, w, h };
|
|
3244
|
+
}
|
|
3245
|
+
function parsePairs(raw) {
|
|
3246
|
+
return raw.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
3247
|
+
const pieces = part.split(":");
|
|
3248
|
+
if (pieces.length !== 3 && pieces.length !== 4) {
|
|
3249
|
+
fail(
|
|
3250
|
+
"Invalid pair \u2014 expected name:cleanX,cleanY,w,h:annX,annY,w,h[:expectedComponents].",
|
|
3251
|
+
{ pair: part }
|
|
3252
|
+
);
|
|
3253
|
+
}
|
|
3254
|
+
const [name, cleanRaw, annotatedRaw, expectedRaw] = pieces;
|
|
3255
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) fail("Invalid pair name.", { name });
|
|
3256
|
+
const expectedComponents = expectedRaw === void 0 ? null : Number.parseInt(expectedRaw, 10);
|
|
3257
|
+
if (expectedRaw !== void 0 && (!Number.isFinite(expectedComponents) || expectedComponents < 1)) {
|
|
3258
|
+
fail("Invalid expected component count.", { name, expectedRaw });
|
|
3259
|
+
}
|
|
3260
|
+
return {
|
|
3261
|
+
name,
|
|
3262
|
+
cleanBox: parseBox(cleanRaw, `${name} clean`),
|
|
3263
|
+
annotatedBox: parseBox(annotatedRaw, `${name} annotated`),
|
|
3264
|
+
expectedComponents
|
|
3265
|
+
};
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
function assertInside(box, image, label) {
|
|
3269
|
+
if (box.x < 0 || box.y < 0 || box.x + box.w > image.width || box.y + box.h > image.height) {
|
|
3270
|
+
fail(`${label} crop is outside the source image.`, {
|
|
3271
|
+
box,
|
|
3272
|
+
image: { w: image.width, h: image.height }
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
function edgeOpaqueFraction(png, alphaThresh = 40) {
|
|
3277
|
+
const { width: w, height: h, data } = png;
|
|
3278
|
+
const alphaAt = (x, y) => data[(y * w + x << 2) + 3];
|
|
3279
|
+
let top = 0;
|
|
3280
|
+
let bottom = 0;
|
|
3281
|
+
let left = 0;
|
|
3282
|
+
let right = 0;
|
|
3283
|
+
for (let x = 0; x < w; x += 1) {
|
|
3284
|
+
if (alphaAt(x, 0) > alphaThresh) top += 1;
|
|
3285
|
+
if (alphaAt(x, h - 1) > alphaThresh) bottom += 1;
|
|
3286
|
+
}
|
|
3287
|
+
for (let y = 0; y < h; y += 1) {
|
|
3288
|
+
if (alphaAt(0, y) > alphaThresh) left += 1;
|
|
3289
|
+
if (alphaAt(w - 1, y) > alphaThresh) right += 1;
|
|
3290
|
+
}
|
|
3291
|
+
return { top: top / w, bottom: bottom / w, left: left / h, right: right / h };
|
|
3292
|
+
}
|
|
3293
|
+
function assertNotFlush(png, label, maxFrac) {
|
|
3294
|
+
const edges = edgeOpaqueFraction(png);
|
|
3295
|
+
const flush2 = Object.entries(edges).filter(([, frac]) => frac > maxFrac);
|
|
3296
|
+
if (flush2.length > 0) {
|
|
3297
|
+
fail(
|
|
3298
|
+
`${label}: crop box is slicing the element \u2014 opaque content is flush against the image edge. Widen the crop box (or pass --edge-flush-max 1 for a deliberate full-bleed asset).`,
|
|
3299
|
+
{
|
|
3300
|
+
flushEdges: flush2.map(([edge, frac]) => ({ edge, opaqueFraction: Number(frac.toFixed(3)) })),
|
|
3301
|
+
maxFraction: maxFrac
|
|
3302
|
+
}
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
var isWhitePx = (data, idx) => data[idx + 3] > 16 && data[idx] >= 245 && data[idx + 1] >= 245 && data[idx + 2] >= 245;
|
|
3307
|
+
var isSubjectPx = (data, idx) => data[idx + 3] > 16 && !isWhitePx(data, idx);
|
|
3308
|
+
function greenAlpha(r, g, b, a) {
|
|
3309
|
+
if (a === 0) return 0;
|
|
3310
|
+
const greenStrength = g - Math.max(r, b);
|
|
3311
|
+
const isGreen = g > r * 1.4 && g > b * 1.4 && greenStrength >= 50 && g >= 45;
|
|
3312
|
+
if (isGreen) {
|
|
3313
|
+
return Math.max(160, Math.min(255, Math.round(greenStrength / 180 * 255)));
|
|
3314
|
+
}
|
|
3315
|
+
return 0;
|
|
3316
|
+
}
|
|
3317
|
+
function convertGreenMask(annotatedCrop) {
|
|
3318
|
+
const out = new PNG2({ width: annotatedCrop.width, height: annotatedCrop.height });
|
|
3319
|
+
let filled = 0;
|
|
3320
|
+
let minX = annotatedCrop.width;
|
|
3321
|
+
let minY = annotatedCrop.height;
|
|
3322
|
+
let maxX = -1;
|
|
3323
|
+
let maxY = -1;
|
|
3324
|
+
for (let y = 0; y < annotatedCrop.height; y += 1) {
|
|
3325
|
+
for (let x = 0; x < annotatedCrop.width; x += 1) {
|
|
3326
|
+
const idx = y * annotatedCrop.width + x << 2;
|
|
3327
|
+
const alpha = greenAlpha(
|
|
3328
|
+
annotatedCrop.data[idx],
|
|
3329
|
+
annotatedCrop.data[idx + 1],
|
|
3330
|
+
annotatedCrop.data[idx + 2],
|
|
3331
|
+
annotatedCrop.data[idx + 3]
|
|
3332
|
+
);
|
|
3333
|
+
out.data[idx] = 255;
|
|
3334
|
+
out.data[idx + 1] = 255;
|
|
3335
|
+
out.data[idx + 2] = 255;
|
|
3336
|
+
out.data[idx + 3] = alpha;
|
|
3337
|
+
if (alpha > 0) {
|
|
3338
|
+
filled += 1;
|
|
3339
|
+
minX = Math.min(minX, x);
|
|
3340
|
+
minY = Math.min(minY, y);
|
|
3341
|
+
maxX = Math.max(maxX, x);
|
|
3342
|
+
maxY = Math.max(maxY, y);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
const coverage = filled / (annotatedCrop.width * annotatedCrop.height);
|
|
3347
|
+
const bbox = filled > 0 ? {
|
|
3348
|
+
x: minX / annotatedCrop.width,
|
|
3349
|
+
y: minY / annotatedCrop.height,
|
|
3350
|
+
w: (maxX - minX + 1) / annotatedCrop.width,
|
|
3351
|
+
h: (maxY - minY + 1) / annotatedCrop.height
|
|
3352
|
+
} : null;
|
|
3353
|
+
return { png: out, coverage, filledPixels: filled, bbox };
|
|
3354
|
+
}
|
|
3355
|
+
function subjectBBox(image) {
|
|
3356
|
+
let pixels = 0;
|
|
3357
|
+
let minX = image.width;
|
|
3358
|
+
let minY = image.height;
|
|
3359
|
+
let maxX = -1;
|
|
3360
|
+
let maxY = -1;
|
|
3361
|
+
for (let y = 0; y < image.height; y += 1) {
|
|
3362
|
+
for (let x = 0; x < image.width; x += 1) {
|
|
3363
|
+
const idx = y * image.width + x << 2;
|
|
3364
|
+
if (!isSubjectPx(image.data, idx)) continue;
|
|
3365
|
+
pixels += 1;
|
|
3366
|
+
minX = Math.min(minX, x);
|
|
3367
|
+
minY = Math.min(minY, y);
|
|
3368
|
+
maxX = Math.max(maxX, x);
|
|
3369
|
+
maxY = Math.max(maxY, y);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
return pixels > 0 ? {
|
|
3373
|
+
pixels,
|
|
3374
|
+
x: minX / image.width,
|
|
3375
|
+
y: minY / image.height,
|
|
3376
|
+
w: (maxX - minX + 1) / image.width,
|
|
3377
|
+
h: (maxY - minY + 1) / image.height
|
|
3378
|
+
} : null;
|
|
3379
|
+
}
|
|
3380
|
+
function bboxDelta(a, b) {
|
|
3381
|
+
if (!a || !b) return Infinity;
|
|
3382
|
+
return Math.max(
|
|
3383
|
+
Math.abs(a.x - b.x),
|
|
3384
|
+
Math.abs(a.y - b.y),
|
|
3385
|
+
Math.abs(a.w - b.w),
|
|
3386
|
+
Math.abs(a.h - b.h)
|
|
3387
|
+
);
|
|
3388
|
+
}
|
|
3389
|
+
function greenComponents(mask, minPixels) {
|
|
3390
|
+
const components = [];
|
|
3391
|
+
scanComponents(
|
|
3392
|
+
mask.width,
|
|
3393
|
+
mask.height,
|
|
3394
|
+
(idx) => mask.data[(idx << 2) + 3] > 0,
|
|
3395
|
+
(stats) => {
|
|
3396
|
+
if (stats.pixels < minPixels) return;
|
|
3397
|
+
components.push({
|
|
3398
|
+
pixels: stats.pixels,
|
|
3399
|
+
bbox: {
|
|
3400
|
+
x: stats.minX / mask.width,
|
|
3401
|
+
y: stats.minY / mask.height,
|
|
3402
|
+
w: (stats.maxX - stats.minX + 1) / mask.width,
|
|
3403
|
+
h: (stats.maxY - stats.minY + 1) / mask.height
|
|
3404
|
+
}
|
|
3405
|
+
});
|
|
3406
|
+
}
|
|
3407
|
+
);
|
|
3408
|
+
return components;
|
|
3409
|
+
}
|
|
3410
|
+
function blendPixel(image, x, y, rgba) {
|
|
3411
|
+
if (x < 0 || y < 0 || x >= image.width || y >= image.height) return;
|
|
3412
|
+
const idx = y * image.width + x << 2;
|
|
3413
|
+
const a = rgba[3] / 255;
|
|
3414
|
+
image.data[idx] = Math.round(rgba[0] * a + image.data[idx] * (1 - a));
|
|
3415
|
+
image.data[idx + 1] = Math.round(rgba[1] * a + image.data[idx + 1] * (1 - a));
|
|
3416
|
+
image.data[idx + 2] = Math.round(rgba[2] * a + image.data[idx + 2] * (1 - a));
|
|
3417
|
+
image.data[idx + 3] = Math.max(image.data[idx + 3], rgba[3]);
|
|
3418
|
+
}
|
|
3419
|
+
function makeOverlay(clean2, mask) {
|
|
3420
|
+
const out = new PNG2({ width: clean2.width, height: clean2.height });
|
|
3421
|
+
clean2.data.copy(out.data);
|
|
3422
|
+
for (let y = 0; y < mask.height; y += 1) {
|
|
3423
|
+
for (let x = 0; x < mask.width; x += 1) {
|
|
3424
|
+
const idx = y * mask.width + x << 2;
|
|
3425
|
+
const alpha = mask.data[idx + 3];
|
|
3426
|
+
if (alpha > 0) blendPixel(out, x, y, [0, 255, 0, Math.min(190, alpha)]);
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
return out;
|
|
3430
|
+
}
|
|
3431
|
+
async function uiMasks(opts, log) {
|
|
3432
|
+
const input = requireOpt(opts.input, "--in", "masks");
|
|
3433
|
+
const outDir = requireOpt(opts.outDir, "--out-dir", "masks");
|
|
3434
|
+
const pairs = parsePairs(requireOpt(opts.pairs, "--pairs", "masks"));
|
|
3435
|
+
const minCoverage = opts.coverageMin ?? 0.01;
|
|
3436
|
+
const maxCoverage = opts.coverageMax ?? 0.85;
|
|
3437
|
+
const minComponentPixels = opts.componentMinPixels ?? 100;
|
|
3438
|
+
const registrationTolerance = opts.registrationTolerance ?? 0.04;
|
|
3439
|
+
const edgeFlushMax = opts.edgeFlushMax ?? 0.04;
|
|
3440
|
+
const image = await loadPng(input);
|
|
3441
|
+
await fs14.mkdir(outDir, { recursive: true });
|
|
3442
|
+
log.plain(c.bold("genex ui masks"));
|
|
3443
|
+
log.dim(` ${input} (${image.width}x${image.height}), ${pairs.length} pair(s) \u2192 ${outDir}`);
|
|
3444
|
+
const results = [];
|
|
3445
|
+
for (const pair of pairs) {
|
|
3446
|
+
if (pair.cleanBox.w !== pair.annotatedBox.w || pair.cleanBox.h !== pair.annotatedBox.h) {
|
|
3447
|
+
fail("Clean and annotated crop boxes must have identical dimensions.", pair);
|
|
3448
|
+
}
|
|
3449
|
+
assertInside(pair.cleanBox, image, `${pair.name} clean`);
|
|
3450
|
+
assertInside(pair.annotatedBox, image, `${pair.name} annotated`);
|
|
3451
|
+
const clean2 = cropPng(image, pair.cleanBox);
|
|
3452
|
+
const annotated = cropPng(image, pair.annotatedBox);
|
|
3453
|
+
assertNotFlush(clean2, `${pair.name} clean frame`, edgeFlushMax);
|
|
3454
|
+
const cleanBBox = subjectBBox(clean2);
|
|
3455
|
+
const annotatedBBox = subjectBBox(annotated);
|
|
3456
|
+
const registrationDelta = bboxDelta(cleanBBox, annotatedBBox);
|
|
3457
|
+
if (registrationDelta > registrationTolerance) {
|
|
3458
|
+
fail("Annotated copy registration differs too much from the clean asset.", {
|
|
3459
|
+
name: pair.name,
|
|
3460
|
+
registrationDelta: Number(registrationDelta.toFixed(4)),
|
|
3461
|
+
registrationTolerance,
|
|
3462
|
+
cleanBBox,
|
|
3463
|
+
annotatedBBox
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3466
|
+
const converted = convertGreenMask(annotated);
|
|
3467
|
+
if (converted.coverage < minCoverage || converted.coverage > maxCoverage) {
|
|
3468
|
+
fail("Green mask coverage outside the accepted range.", {
|
|
3469
|
+
name: pair.name,
|
|
3470
|
+
coverage: converted.coverage,
|
|
3471
|
+
minCoverage,
|
|
3472
|
+
maxCoverage
|
|
3473
|
+
});
|
|
3474
|
+
}
|
|
3475
|
+
const components = greenComponents(converted.png, minComponentPixels);
|
|
3476
|
+
if (pair.expectedComponents && components.length !== pair.expectedComponents) {
|
|
3477
|
+
fail("Green mask component count mismatch.", {
|
|
3478
|
+
name: pair.name,
|
|
3479
|
+
expectedComponents: pair.expectedComponents,
|
|
3480
|
+
actualComponents: components.length
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3483
|
+
const overlay = makeOverlay(clean2, converted.png);
|
|
3484
|
+
const framePath = path14.join(outDir, `${pair.name}-frame.png`);
|
|
3485
|
+
const maskPath = path14.join(outDir, `${pair.name}-mask.png`);
|
|
3486
|
+
const annotatedPath = path14.join(outDir, `${pair.name}-annotated-source.png`);
|
|
3487
|
+
const overlayPath = path14.join(outDir, `${pair.name}-overlay.png`);
|
|
3488
|
+
const metaPath = path14.join(outDir, `${pair.name}.annotated-progress.json`);
|
|
3489
|
+
await writePng(framePath, clean2);
|
|
3490
|
+
await writePng(maskPath, converted.png);
|
|
3491
|
+
await writePng(annotatedPath, annotated);
|
|
3492
|
+
await writePng(overlayPath, overlay);
|
|
3493
|
+
const meta = {
|
|
3494
|
+
name: pair.name,
|
|
3495
|
+
provenance: "clean asset + annotated duplicate green-key",
|
|
3496
|
+
source: input,
|
|
3497
|
+
// The DOM fill's channel box (FB) — duplicated from mask.bbox at the top
|
|
3498
|
+
// level so the HUD skill reads it without digging into the mask object.
|
|
3499
|
+
fillBox: converted.bbox,
|
|
3500
|
+
clean: {
|
|
3501
|
+
path: framePath,
|
|
3502
|
+
crop: pair.cleanBox,
|
|
3503
|
+
size: { w: clean2.width, h: clean2.height },
|
|
3504
|
+
subjectBBox: cleanBBox
|
|
3505
|
+
},
|
|
3506
|
+
annotated: {
|
|
3507
|
+
path: annotatedPath,
|
|
3508
|
+
crop: pair.annotatedBox,
|
|
3509
|
+
size: { w: annotated.width, h: annotated.height },
|
|
3510
|
+
subjectBBox: annotatedBBox
|
|
3511
|
+
},
|
|
3512
|
+
registration: {
|
|
3513
|
+
bboxDelta: Number(registrationDelta.toFixed(4)),
|
|
3514
|
+
tolerance: registrationTolerance
|
|
3515
|
+
},
|
|
3516
|
+
mask: {
|
|
3517
|
+
path: maskPath,
|
|
3518
|
+
size: { w: converted.png.width, h: converted.png.height },
|
|
3519
|
+
coverage: Number(converted.coverage.toFixed(4)),
|
|
3520
|
+
filledPixels: converted.filledPixels,
|
|
3521
|
+
bbox: converted.bbox,
|
|
3522
|
+
components,
|
|
3523
|
+
expectedComponents: pair.expectedComponents
|
|
3524
|
+
},
|
|
3525
|
+
overlay: overlayPath
|
|
3526
|
+
};
|
|
3527
|
+
await fs14.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}
|
|
3528
|
+
`);
|
|
3529
|
+
results.push(meta);
|
|
3530
|
+
const fb = converted.bbox;
|
|
3531
|
+
const fbText = fb ? ` fillBox ${[fb.x, fb.y, fb.w, fb.h].map((v) => Number(v.toFixed(4))).join(",")},` : "";
|
|
3532
|
+
log.success(
|
|
3533
|
+
`${pair.name}: coverage ${meta.mask.coverage},${fbText} ${components.length} component(s), registration \u0394 ${meta.registration.bboxDelta}`
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
const indexPath = path14.join(outDir, "annotated-progress.json");
|
|
3537
|
+
await fs14.writeFile(indexPath, `${JSON.stringify({ input, pairs: results }, null, 2)}
|
|
3538
|
+
`);
|
|
3539
|
+
log.plain("");
|
|
3540
|
+
log.success(`Wrote ${pairs.length} frame+mask pair(s) \u2192 ${outDir} (index: ${indexPath}).`);
|
|
3541
|
+
log.dim(" Eyeball each *-overlay.png \u2014 green must cover exactly the fill region.");
|
|
3542
|
+
}
|
|
3543
|
+
var hexColor = (r, g, b) => `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
3544
|
+
var luminance = (r, g, b) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
3545
|
+
var chromaOf = (r, g, b) => (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
|
|
3546
|
+
var rgbDist = (a, r, g, b) => Math.hypot(a[0] - r, a[1] - g, a[2] - b);
|
|
3547
|
+
var newBucket = () => ({
|
|
3548
|
+
n: 0,
|
|
3549
|
+
r: 0,
|
|
3550
|
+
g: 0,
|
|
3551
|
+
b: 0,
|
|
3552
|
+
minX: Infinity,
|
|
3553
|
+
minY: Infinity,
|
|
3554
|
+
maxX: -1,
|
|
3555
|
+
maxY: -1
|
|
3556
|
+
});
|
|
3557
|
+
function addToBucket(bucket, r, g, b, x, y) {
|
|
3558
|
+
bucket.n += 1;
|
|
3559
|
+
bucket.r += r;
|
|
3560
|
+
bucket.g += g;
|
|
3561
|
+
bucket.b += b;
|
|
3562
|
+
bucket.minX = Math.min(bucket.minX, x);
|
|
3563
|
+
bucket.minY = Math.min(bucket.minY, y);
|
|
3564
|
+
bucket.maxX = Math.max(bucket.maxX, x);
|
|
3565
|
+
bucket.maxY = Math.max(bucket.maxY, y);
|
|
3566
|
+
}
|
|
3567
|
+
function summarizeBucket(bucket, total, box) {
|
|
3568
|
+
if (bucket.n === 0) return null;
|
|
3569
|
+
const r = bucket.r / bucket.n;
|
|
3570
|
+
const g = bucket.g / bucket.n;
|
|
3571
|
+
const b = bucket.b / bucket.n;
|
|
3572
|
+
return {
|
|
3573
|
+
hex: hexColor(r, g, b),
|
|
3574
|
+
rgb: [Math.round(r), Math.round(g), Math.round(b)],
|
|
3575
|
+
coverage: Number((bucket.n / total).toFixed(4)),
|
|
3576
|
+
// bbox of these pixels, as fractions of the sampled box (the footprint)
|
|
3577
|
+
bbox: {
|
|
3578
|
+
x: Number((bucket.minX / box.w).toFixed(3)),
|
|
3579
|
+
y: Number((bucket.minY / box.h).toFixed(3)),
|
|
3580
|
+
w: Number(((bucket.maxX - bucket.minX + 1) / box.w).toFixed(3)),
|
|
3581
|
+
h: Number(((bucket.maxY - bucket.minY + 1) / box.h).toFixed(3))
|
|
3582
|
+
}
|
|
3583
|
+
};
|
|
3584
|
+
}
|
|
3585
|
+
async function uiTextColor(opts, log) {
|
|
3586
|
+
const input = requireOpt(opts.input, "--in", "text-color");
|
|
3587
|
+
const box = parseBox(requireOpt(opts.box, "--box", "text-color"), "sample");
|
|
3588
|
+
const quantize = opts.quantize ?? 12;
|
|
3589
|
+
const inkDist = opts.inkDist ?? 60;
|
|
3590
|
+
const minFrac = opts.minFrac ?? 4e-3;
|
|
3591
|
+
const image = await loadPng(input);
|
|
3592
|
+
if (box.x < 0 || box.y < 0 || box.x + box.w > image.width || box.y + box.h > image.height) {
|
|
3593
|
+
fail("Box is outside the source image.", { box, image: { w: image.width, h: image.height } });
|
|
3594
|
+
}
|
|
3595
|
+
const region = cropPng(image, box);
|
|
3596
|
+
if (opts.cropPath) await writePng(opts.cropPath, region);
|
|
3597
|
+
const total = box.w * box.h;
|
|
3598
|
+
const q = Math.max(1, quantize);
|
|
3599
|
+
const hist = /* @__PURE__ */ new Map();
|
|
3600
|
+
for (let i = 0; i < region.data.length; i += 4) {
|
|
3601
|
+
const r = region.data[i];
|
|
3602
|
+
const g = region.data[i + 1];
|
|
3603
|
+
const b = region.data[i + 2];
|
|
3604
|
+
const key = `${Math.round(r / q) * q},${Math.round(g / q) * q},${Math.round(b / q) * q}`;
|
|
3605
|
+
let e = hist.get(key);
|
|
3606
|
+
if (!e) hist.set(key, e = { n: 0, r: 0, g: 0, b: 0 });
|
|
3607
|
+
e.n += 1;
|
|
3608
|
+
e.r += r;
|
|
3609
|
+
e.g += g;
|
|
3610
|
+
e.b += b;
|
|
3611
|
+
}
|
|
3612
|
+
const dominant = [...hist.values()].sort((a, b) => b.n - a.n).slice(0, 10).map((e) => ({
|
|
3613
|
+
hex: hexColor(e.r / e.n, e.g / e.n, e.b / e.n),
|
|
3614
|
+
rgb: [Math.round(e.r / e.n), Math.round(e.g / e.n), Math.round(e.b / e.n)],
|
|
3615
|
+
coverage: Number((e.n / total).toFixed(4))
|
|
3616
|
+
}));
|
|
3617
|
+
const background = dominant[0];
|
|
3618
|
+
const bg = background.rgb;
|
|
3619
|
+
const ink = newBucket();
|
|
3620
|
+
const dark = newBucket();
|
|
3621
|
+
const light = newBucket();
|
|
3622
|
+
const chromatic = newBucket();
|
|
3623
|
+
for (let y = 0; y < region.height; y += 1) {
|
|
3624
|
+
for (let x = 0; x < region.width; x += 1) {
|
|
3625
|
+
const i = y * region.width + x << 2;
|
|
3626
|
+
const r = region.data[i];
|
|
3627
|
+
const g = region.data[i + 1];
|
|
3628
|
+
const b = region.data[i + 2];
|
|
3629
|
+
if (rgbDist(bg, r, g, b) < inkDist) continue;
|
|
3630
|
+
addToBucket(ink, r, g, b, x, y);
|
|
3631
|
+
const L = luminance(r, g, b);
|
|
3632
|
+
const C = chromaOf(r, g, b);
|
|
3633
|
+
if (C > 0.22) addToBucket(chromatic, r, g, b, x, y);
|
|
3634
|
+
else if (L < 0.4) addToBucket(dark, r, g, b, x, y);
|
|
3635
|
+
else if (L > 0.62) addToBucket(light, r, g, b, x, y);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
const pick = (bucket) => {
|
|
3639
|
+
const s = summarizeBucket(bucket, total, box);
|
|
3640
|
+
return s && s.coverage >= minFrac ? s : null;
|
|
3641
|
+
};
|
|
3642
|
+
const result = {
|
|
3643
|
+
status: "ok",
|
|
3644
|
+
source: input,
|
|
3645
|
+
box,
|
|
3646
|
+
background,
|
|
3647
|
+
dominant,
|
|
3648
|
+
ink: pick(ink),
|
|
3649
|
+
candidates: {
|
|
3650
|
+
darkInk: pick(dark),
|
|
3651
|
+
lightInk: pick(light),
|
|
3652
|
+
chromaticInk: pick(chromatic)
|
|
3653
|
+
},
|
|
3654
|
+
params: { quantize: q, inkDist, minFrac }
|
|
3655
|
+
};
|
|
3656
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3657
|
+
`);
|
|
3658
|
+
if (opts.out) await fs14.writeFile(opts.out, `${JSON.stringify(result, null, 2)}
|
|
3659
|
+
`);
|
|
3660
|
+
if (opts.cropPath) log.dim(` crop \u2192 ${opts.cropPath}`);
|
|
3661
|
+
}
|
|
3662
|
+
async function uiTrim(opts, log) {
|
|
3663
|
+
const input = requireOpt(opts.input, "--in", "trim");
|
|
3664
|
+
const outPath = opts.out ?? input;
|
|
3665
|
+
if (/^https?:\/\//i.test(outPath)) {
|
|
3666
|
+
fail("--in is a URL \u2014 pass --out <png> to say where the trimmed copy goes.");
|
|
3667
|
+
}
|
|
3668
|
+
const png = await loadPng(input);
|
|
3669
|
+
const bbox = alphaBBoxPx(png);
|
|
3670
|
+
if (!bbox) fail("Image is fully transparent \u2014 nothing to trim.", { input });
|
|
3671
|
+
const cropBox = {
|
|
3672
|
+
x: bbox.minX,
|
|
3673
|
+
y: bbox.minY,
|
|
3674
|
+
w: bbox.maxX - bbox.minX + 1,
|
|
3675
|
+
h: bbox.maxY - bbox.minY + 1
|
|
3676
|
+
};
|
|
3677
|
+
const trimmed = cropPng(png, cropBox);
|
|
3678
|
+
await writePng(outPath, trimmed);
|
|
3679
|
+
const sidecar = computeBBoxes(trimmed);
|
|
3680
|
+
const sidecarPath = outPath.replace(/\.png$/i, "") + ".bbox.json";
|
|
3681
|
+
await fs14.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
|
|
3682
|
+
log.success(
|
|
3683
|
+
`Trimmed ${png.width}x${png.height} \u2192 ${trimmed.width}x${trimmed.height} (${outPath}).`
|
|
3684
|
+
);
|
|
3685
|
+
process.stdout.write(
|
|
3686
|
+
`${JSON.stringify({
|
|
3687
|
+
out: outPath,
|
|
3688
|
+
width: trimmed.width,
|
|
3689
|
+
height: trimmed.height,
|
|
3690
|
+
aspectRatio: sidecar.aspectRatio,
|
|
3691
|
+
outerBBox: sidecar.outerBBox,
|
|
3692
|
+
innerBBox: sidecar.innerBBox,
|
|
3693
|
+
sidecar: sidecarPath
|
|
3694
|
+
})}
|
|
3695
|
+
`
|
|
3696
|
+
);
|
|
3697
|
+
}
|
|
3698
|
+
|
|
2779
3699
|
// src/index.ts
|
|
2780
3700
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
|
|
2781
3701
|
var HELP = `${c.bold("genex")} \u2014 set up your ~/.claude workspace, authorize, and publish 3D games.
|
|
@@ -2795,6 +3715,9 @@ ${c.bold("Usage")}
|
|
|
2795
3715
|
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
2796
3716
|
genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
|
|
2797
3717
|
genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
|
|
3718
|
+
genex wait <id> Attach to a generation enqueued with --no-wait and
|
|
3719
|
+
print its asset URL(s) when it finishes. Safe to
|
|
3720
|
+
re-run \u2014 it never creates a new generation.
|
|
2798
3721
|
genex controller <type> [--force] Install a physics controller
|
|
2799
3722
|
(character|car|drone|networked-physics)
|
|
2800
3723
|
into src/controllers (+ assets into public/assets).
|
|
@@ -2805,14 +3728,31 @@ ${c.bold("Usage")}
|
|
|
2805
3728
|
genex explore ["<query>"] [options] Search the curated community gallery \u2014 proven
|
|
2806
3729
|
Three.js systems you can clone or borrow parts
|
|
2807
3730
|
from. No query lists the whole catalog.
|
|
3731
|
+
genex ui <tool> [options] Local pixel toolbox for generated UI art \u2014
|
|
3732
|
+
extract | masks | text-color | trim. Pure
|
|
3733
|
+
local PNG work (no API), reads a file or URL.
|
|
2808
3734
|
|
|
2809
3735
|
${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `video`)")}
|
|
2810
3736
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
2811
3737
|
--duration <sec> (sfx, video) target clip length in seconds.
|
|
2812
3738
|
--transparent (image) transparent background (alpha) \u2014 for decals/stickers.
|
|
2813
3739
|
--aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
|
|
3740
|
+
--quality <q> (image) quality preset: low | medium | high.
|
|
3741
|
+
--candidates <n> (image) generate 2-4 variants in ONE call and print every
|
|
3742
|
+
URL \u2014 pick the best instead of re-rolling. Needs
|
|
3743
|
+
--quality or --size; not with --remove-bg/--transparent.
|
|
3744
|
+
--size <WxH> (image) exact output size in px (e.g. 2560x1440).
|
|
3745
|
+
--edit <url> (image) edit THIS image (asset URL) with the prompt (i2i).
|
|
3746
|
+
--clean <url> (image) ML-remove the background of THIS image (asset URL);
|
|
3747
|
+
prompt is recorded but unused. Defaults --bg-mode sheet.
|
|
3748
|
+
--remove-bg (image) chain ML background removal after the gen/edit.
|
|
3749
|
+
--bg-mode <mode> (image) background-removal model: sprite | glyph | sheet.
|
|
2814
3750
|
--loop (video) generate a seamless loop.
|
|
2815
|
-
--
|
|
3751
|
+
--frame <url> (video) animate from THIS frame back to itself (seamless loop).
|
|
3752
|
+
--first-frame <url> (video) start frame for a two-frame motion \u2026
|
|
3753
|
+
--last-frame <url> (video) \u2026 and its end frame.
|
|
3754
|
+
--no-wait Enqueue only; don't block waiting for the asset. Prints
|
|
3755
|
+
the id that "genex wait <id>" picks the result up with.
|
|
2816
3756
|
--api-url <url> Override the API base URL.
|
|
2817
3757
|
--env <path> Token env file (default: ~/.genex/env).
|
|
2818
3758
|
|
|
@@ -2863,6 +3803,8 @@ ${c.bold("Options for `explore`")}
|
|
|
2863
3803
|
--api-url <url> Override the API base URL.
|
|
2864
3804
|
(No query at all lists the entire curated catalog.)
|
|
2865
3805
|
|
|
3806
|
+
${UI_USAGE}
|
|
3807
|
+
|
|
2866
3808
|
${c.bold("Global")}
|
|
2867
3809
|
--quiet Reduce output.
|
|
2868
3810
|
-h, --help Show this help.
|
|
@@ -2889,8 +3831,12 @@ ${c.bold("Examples")}
|
|
|
2889
3831
|
genex sfx "punchy laser zap" --duration 2
|
|
2890
3832
|
genex texture "mossy cracked cobblestone" --terrain
|
|
2891
3833
|
genex image "retro arcade poster art" --aspect portrait
|
|
2892
|
-
genex image "
|
|
2893
|
-
genex video "
|
|
3834
|
+
genex image "chalk graffiti tag, hand-drawn style" --transparent
|
|
3835
|
+
genex video "candle flame flickering in the dark, seamless loop" --loop
|
|
3836
|
+
genex image "misty citadel at dusk, cinematic wide shot" --aspect 16:9 --quality high
|
|
3837
|
+
genex video "slow drifting fog, embers rising" --frame <that-image-url> --duration 8 --no-wait
|
|
3838
|
+
genex wait <that-generation-id>
|
|
3839
|
+
genex ui extract --in <sheet-url> --out-dir ui/ --names health-bar,minimap,ammo
|
|
2894
3840
|
genex controller character
|
|
2895
3841
|
genex controller networked-physics
|
|
2896
3842
|
genex explore "grass"
|
|
@@ -2918,6 +3864,24 @@ function parseArgs(argv) {
|
|
|
2918
3864
|
"--timeout",
|
|
2919
3865
|
"--duration",
|
|
2920
3866
|
"--aspect",
|
|
3867
|
+
"--quality",
|
|
3868
|
+
"--candidates",
|
|
3869
|
+
"--size",
|
|
3870
|
+
"--edit",
|
|
3871
|
+
"--clean",
|
|
3872
|
+
"--bg-mode",
|
|
3873
|
+
"--frame",
|
|
3874
|
+
"--first-frame",
|
|
3875
|
+
"--last-frame",
|
|
3876
|
+
// `genex ui` string flags (the numeric ones come from UI_NUMBER_FLAGS).
|
|
3877
|
+
"--in",
|
|
3878
|
+
"--out-dir",
|
|
3879
|
+
"--names",
|
|
3880
|
+
"--pairs",
|
|
3881
|
+
"--box",
|
|
3882
|
+
"--out",
|
|
3883
|
+
"--crop",
|
|
3884
|
+
...Object.keys(UI_NUMBER_FLAGS),
|
|
2921
3885
|
"--source-repo-url",
|
|
2922
3886
|
"--source-author",
|
|
2923
3887
|
"--license",
|
|
@@ -2958,6 +3922,9 @@ function parseArgs(argv) {
|
|
|
2958
3922
|
case "--loop":
|
|
2959
3923
|
parsed.options.loop = true;
|
|
2960
3924
|
break;
|
|
3925
|
+
case "--remove-bg":
|
|
3926
|
+
parsed.options.removeBackground = true;
|
|
3927
|
+
break;
|
|
2961
3928
|
case "--force":
|
|
2962
3929
|
parsed.options.force = true;
|
|
2963
3930
|
break;
|
|
@@ -3009,6 +3976,15 @@ function parseArgs(argv) {
|
|
|
3009
3976
|
return parsed;
|
|
3010
3977
|
}
|
|
3011
3978
|
function applyValueFlag(options, flag, value) {
|
|
3979
|
+
const uiNumKey = UI_NUMBER_FLAGS[flag];
|
|
3980
|
+
if (uiNumKey) {
|
|
3981
|
+
const n = Number(value);
|
|
3982
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
3983
|
+
throw new Error(`Invalid ${flag} value: ${value}`);
|
|
3984
|
+
}
|
|
3985
|
+
options[uiNumKey] = n;
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3012
3988
|
switch (flag) {
|
|
3013
3989
|
case "--dir":
|
|
3014
3990
|
options.dir = value;
|
|
@@ -3077,6 +4053,73 @@ function applyValueFlag(options, flag, value) {
|
|
|
3077
4053
|
case "--aspect":
|
|
3078
4054
|
options.aspect = value;
|
|
3079
4055
|
break;
|
|
4056
|
+
case "--quality":
|
|
4057
|
+
if (!["low", "medium", "high"].includes(value)) {
|
|
4058
|
+
throw new Error(`Invalid --quality value: ${value} (expected low|medium|high)`);
|
|
4059
|
+
}
|
|
4060
|
+
options.quality = value;
|
|
4061
|
+
break;
|
|
4062
|
+
case "--candidates": {
|
|
4063
|
+
const n = Number(value);
|
|
4064
|
+
if (!Number.isInteger(n) || n < 1 || n > 4) {
|
|
4065
|
+
throw new Error(`Invalid --candidates value: ${value} (expected an integer 1-4)`);
|
|
4066
|
+
}
|
|
4067
|
+
options.candidates = n;
|
|
4068
|
+
break;
|
|
4069
|
+
}
|
|
4070
|
+
case "--size": {
|
|
4071
|
+
const m = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(value.trim());
|
|
4072
|
+
const w = m ? Number(m[1]) : NaN;
|
|
4073
|
+
const h = m ? Number(m[2]) : NaN;
|
|
4074
|
+
if (!m || !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0) {
|
|
4075
|
+
throw new Error(`Invalid --size value: ${value} (expected WxH, e.g. 2560x1440)`);
|
|
4076
|
+
}
|
|
4077
|
+
options.width = w;
|
|
4078
|
+
options.height = h;
|
|
4079
|
+
break;
|
|
4080
|
+
}
|
|
4081
|
+
case "--edit":
|
|
4082
|
+
options.editUrl = value;
|
|
4083
|
+
break;
|
|
4084
|
+
case "--clean":
|
|
4085
|
+
options.cleanUrl = value;
|
|
4086
|
+
break;
|
|
4087
|
+
case "--bg-mode":
|
|
4088
|
+
if (!["sprite", "glyph", "sheet"].includes(value)) {
|
|
4089
|
+
throw new Error(`Invalid --bg-mode value: ${value} (expected sprite|glyph|sheet)`);
|
|
4090
|
+
}
|
|
4091
|
+
options.bgMode = value;
|
|
4092
|
+
break;
|
|
4093
|
+
case "--frame":
|
|
4094
|
+
options.frameUrl = value;
|
|
4095
|
+
break;
|
|
4096
|
+
case "--first-frame":
|
|
4097
|
+
options.firstFrameUrl = value;
|
|
4098
|
+
break;
|
|
4099
|
+
case "--last-frame":
|
|
4100
|
+
options.lastFrameUrl = value;
|
|
4101
|
+
break;
|
|
4102
|
+
case "--in":
|
|
4103
|
+
options.input = value;
|
|
4104
|
+
break;
|
|
4105
|
+
case "--out-dir":
|
|
4106
|
+
options.outDir = value;
|
|
4107
|
+
break;
|
|
4108
|
+
case "--names":
|
|
4109
|
+
options.names = value;
|
|
4110
|
+
break;
|
|
4111
|
+
case "--pairs":
|
|
4112
|
+
options.pairs = value;
|
|
4113
|
+
break;
|
|
4114
|
+
case "--box":
|
|
4115
|
+
options.box = value;
|
|
4116
|
+
break;
|
|
4117
|
+
case "--out":
|
|
4118
|
+
options.out = value;
|
|
4119
|
+
break;
|
|
4120
|
+
case "--crop":
|
|
4121
|
+
options.cropPath = value;
|
|
4122
|
+
break;
|
|
3080
4123
|
}
|
|
3081
4124
|
}
|
|
3082
4125
|
async function main() {
|
|
@@ -3147,6 +4190,12 @@ async function main() {
|
|
|
3147
4190
|
case "explore":
|
|
3148
4191
|
await runExplore({ ...parsed.options, query: parsed.options.name });
|
|
3149
4192
|
break;
|
|
4193
|
+
case "ui":
|
|
4194
|
+
await runUi({ ...parsed.options, sub: parsed.options.name });
|
|
4195
|
+
break;
|
|
4196
|
+
case "wait":
|
|
4197
|
+
await runWait({ ...parsed.options, id: parsed.options.name });
|
|
4198
|
+
break;
|
|
3150
4199
|
default:
|
|
3151
4200
|
log.error(`Unknown command: ${parsed.command}`);
|
|
3152
4201
|
log.plain(`Run ${c.cyan("genex --help")} for usage.`);
|