pi-crew 0.9.26 → 0.9.28
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/CHANGELOG.md +102 -0
- package/NOTICE.md +14 -0
- package/assets/crew-vibes.ttf +0 -0
- package/assets/runner-spritesheet.png +0 -0
- package/dist/build-meta.json +245 -48
- package/dist/index.mjs +1611 -243
- package/dist/index.mjs.map +4 -4
- package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
- package/package.json +9 -2
- package/scripts/bench-check.mjs +96 -0
- package/scripts/bench-cold-start.mjs +216 -0
- package/scripts/build-bundle.mjs +84 -0
- package/scripts/build-crew-vibes-font.py +328 -0
- package/scripts/check-all-skills.ts +294 -0
- package/scripts/check-bundle-staleness.mjs +97 -0
- package/scripts/check-conflict-markers.mjs +91 -0
- package/scripts/check-lazy-imports.mjs +28 -0
- package/scripts/install-crew-vibes-font.mjs +91 -0
- package/scripts/postinstall.mjs +47 -0
- package/scripts/profile-startup.mjs +125 -0
- package/scripts/release-smoke.mjs +74 -0
- package/scripts/run-bench.mjs +47 -0
- package/scripts/run-real-chain.ts +41 -0
- package/scripts/test-issue-29-crash.ts +111 -0
- package/scripts/test-issue-29-e2e.ts +183 -0
- package/scripts/test-issue-29-real-runtime.ts +330 -0
- package/scripts/test-issue-29-real-tasks.ts +387 -0
- package/scripts/test-issue-29-team-tool.ts +105 -0
- package/scripts/test-runner.mjs +74 -0
- package/scripts/verify-flicker-fix.ts +109 -0
- package/scripts/verify-skill.ts +550 -0
- package/scripts/watch-bundle.mjs +259 -0
- package/scripts/watchdog-harness.ts +119 -0
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +10 -1
- package/src/config/defaults.ts +6 -0
- package/src/extension/crew-vibes/cat-frames.ts +18 -0
- package/src/extension/crew-vibes/config.ts +195 -0
- package/src/extension/crew-vibes/figures.ts +94 -0
- package/src/extension/crew-vibes/font-detect.ts +58 -0
- package/src/extension/crew-vibes/index.ts +385 -0
- package/src/extension/crew-vibes/provider-usage.ts +396 -0
- package/src/extension/crew-vibes/render.ts +206 -0
- package/src/extension/crew-vibes/speed.ts +286 -0
- package/src/extension/knowledge-injection.ts +12 -3
- package/src/extension/management.ts +23 -3
- package/src/extension/register.ts +7 -0
- package/src/extension/team-tool.ts +11 -0
- package/src/prompt/prompt-runtime.ts +65 -0
- package/src/runtime/child-pi.ts +123 -10
- package/src/runtime/crew-agent-records.ts +10 -3
- package/src/runtime/pi-args.ts +2 -0
- package/src/runtime/retry-executor.ts +4 -1
- package/src/runtime/task-runner/state-helpers.ts +9 -30
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/team-runner.ts +5 -3
- package/src/state/atomic-write.ts +153 -49
- package/src/state/event-log.ts +16 -10
- package/src/state/locks.ts +7 -8
- package/src/state/mailbox.ts +15 -4
- package/src/state/state-store.ts +39 -10
- package/src/teams/discover-teams.ts +56 -1
- package/src/utils/safe-paths.ts +2 -1
- package/src/workflows/discover-workflows.ts +72 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cold-start benchmark: bundle vs strip-types.
|
|
4
|
+
*
|
|
5
|
+
* Compares two entry paths from a fresh node process:
|
|
6
|
+
* - STRIP-TYPES: node --experimental-strip-types loads src/extension/register.ts
|
|
7
|
+
* (or src/extension/index.ts via the package entry)
|
|
8
|
+
* - BUNDLE: node loads dist/index.mjs (esbuild-bundled 2.9MB single file)
|
|
9
|
+
*
|
|
10
|
+
* For each path we spawn N child node processes and measure:
|
|
11
|
+
* - totalMs: time from process spawn to its exit
|
|
12
|
+
* - importMs: time the child reports for `await import(...)`
|
|
13
|
+
* - registerMs: time the child reports for registerPiTeams(mockPi)
|
|
14
|
+
*
|
|
15
|
+
* We report:
|
|
16
|
+
* - per-path median, p95, max for each metric
|
|
17
|
+
* - bundle vs strip-types speedup (negative = bundle faster)
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node scripts/bench-cold-start.mjs # default 10 iters
|
|
21
|
+
* node scripts/bench-cold-start.mjs --iters 20
|
|
22
|
+
*
|
|
23
|
+
* Output goes to stdout as JSON plus a human-readable summary.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
29
|
+
|
|
30
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
31
|
+
const args = process.argv.slice(2);
|
|
32
|
+
let iters = 10;
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
if (args[i] === "--iters" && args[i + 1]) {
|
|
35
|
+
iters = Number(args[i + 1]);
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Driver template. We capture wall-clock per sample using performance.now()
|
|
41
|
+
// and emit JSON lines (one per sample). The outer harness parses them.
|
|
42
|
+
const driverTemplate = (entrySpec, registerImport) => `
|
|
43
|
+
import { performance } from "node:perf_hooks";
|
|
44
|
+
|
|
45
|
+
const tSpawn = performance.now();
|
|
46
|
+
|
|
47
|
+
function createEvents() {
|
|
48
|
+
const handlers = new Map();
|
|
49
|
+
return {
|
|
50
|
+
on(event, handler) {
|
|
51
|
+
const set = handlers.get(event) ?? new Set();
|
|
52
|
+
set.add(handler);
|
|
53
|
+
handlers.set(event, set);
|
|
54
|
+
return () => set.delete(handler);
|
|
55
|
+
},
|
|
56
|
+
emit(event, payload) {
|
|
57
|
+
for (const h of handlers.get(event) ?? []) h(payload);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mockPi = {
|
|
63
|
+
cwd: process.cwd(),
|
|
64
|
+
events: createEvents(),
|
|
65
|
+
hasUI: false,
|
|
66
|
+
getFlag: () => undefined,
|
|
67
|
+
getAllFlags: () => ({}),
|
|
68
|
+
settings: {},
|
|
69
|
+
storage: undefined,
|
|
70
|
+
on() {},
|
|
71
|
+
registerTool() {},
|
|
72
|
+
registerCommand() {},
|
|
73
|
+
appendEntry: async () => undefined,
|
|
74
|
+
sendMessage: async () => undefined,
|
|
75
|
+
sendUserMessage: async () => undefined,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const tImport0 = performance.now();
|
|
79
|
+
const entry = await import(${JSON.stringify(entrySpec)});
|
|
80
|
+
const tImportMs = performance.now() - tImport0;
|
|
81
|
+
|
|
82
|
+
const register = ${registerImport};
|
|
83
|
+
|
|
84
|
+
const tReg0 = performance.now();
|
|
85
|
+
register(mockPi);
|
|
86
|
+
const tRegMs = performance.now() - tReg0;
|
|
87
|
+
|
|
88
|
+
const tTotal = performance.now();
|
|
89
|
+
process.stdout.write(JSON.stringify({
|
|
90
|
+
total: tTotal - tSpawn,
|
|
91
|
+
import: tImportMs,
|
|
92
|
+
register: tRegMs,
|
|
93
|
+
defaultKind: typeof entry.default,
|
|
94
|
+
hasRegister: typeof ${registerImport === "entry.registerPiTeams" ? "entry.registerPiTeams" : "register"},
|
|
95
|
+
}) + "\\n");
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
async function runPath(label, entrySpec, registerImport, itersCount) {
|
|
99
|
+
const driverPath = path.join(root, `.bench-${label}-${process.pid}.mjs`);
|
|
100
|
+
const driverSrc = driverTemplate(entrySpec, registerImport);
|
|
101
|
+
fs.writeFileSync(driverPath, driverSrc, "utf-8");
|
|
102
|
+
|
|
103
|
+
const cmd = label === "strip-types"
|
|
104
|
+
? ["--experimental-strip-types", "--no-warnings", driverPath]
|
|
105
|
+
: ["--no-warnings", driverPath];
|
|
106
|
+
|
|
107
|
+
const samples = [];
|
|
108
|
+
for (let i = 0; i < itersCount; i++) {
|
|
109
|
+
const t0 = performance.now();
|
|
110
|
+
const r = spawnSync(process.execPath, cmd, { encoding: "utf-8", cwd: root });
|
|
111
|
+
const wall = performance.now() - t0;
|
|
112
|
+
if (r.status !== 0) {
|
|
113
|
+
console.error(`[bench:${label}] iter ${i} failed:`, r.stderr?.slice(0, 200));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const lastLine = r.stdout.trim().split("\n").pop();
|
|
117
|
+
try {
|
|
118
|
+
const sample = JSON.parse(lastLine);
|
|
119
|
+
samples.push({ ...sample, wall });
|
|
120
|
+
} catch {
|
|
121
|
+
console.error(`[bench:${label}] iter ${i} parse error:`, lastLine);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fs.unlinkSync(driverPath);
|
|
126
|
+
return samples;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stats(arr) {
|
|
130
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
131
|
+
const p = (q) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * q))];
|
|
132
|
+
return {
|
|
133
|
+
n: sorted.length,
|
|
134
|
+
min: sorted[0],
|
|
135
|
+
p50: p(0.5),
|
|
136
|
+
p95: p(0.95),
|
|
137
|
+
max: sorted[sorted.length - 1],
|
|
138
|
+
mean: sorted.reduce((s, x) => s + x, 0) / sorted.length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pctDelta(newer, older) {
|
|
143
|
+
// positive = newer is slower than older
|
|
144
|
+
return (((newer - older) / older) * 100).toFixed(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`[bench] Running ${iters} iters per path...`);
|
|
148
|
+
console.log("");
|
|
149
|
+
|
|
150
|
+
const stripSpec = pathToFileURL(path.join(root, "src/extension/register.ts")).href;
|
|
151
|
+
const stripRegister = "(await import(" + JSON.stringify(stripSpec) + ")).registerPiTeams";
|
|
152
|
+
const stripSamples = await runPath("strip-types", stripSpec, stripRegister, iters);
|
|
153
|
+
|
|
154
|
+
const bundleSpec = pathToFileURL(path.join(root, "dist/index.mjs")).href;
|
|
155
|
+
const bundleRegister = "(await import(" + JSON.stringify(bundleSpec) + ")).registerPiTeams";
|
|
156
|
+
const bundleSamples = await runPath("bundle", bundleSpec, bundleRegister, iters);
|
|
157
|
+
|
|
158
|
+
if (stripSamples.length === 0 || bundleSamples.length === 0) {
|
|
159
|
+
console.error("[bench] insufficient samples; aborting");
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const summary = {
|
|
164
|
+
timestamp: new Date().toISOString(),
|
|
165
|
+
iters,
|
|
166
|
+
strip: {
|
|
167
|
+
total: stats(stripSamples.map((s) => s.total)),
|
|
168
|
+
import: stats(stripSamples.map((s) => s.import)),
|
|
169
|
+
register: stats(stripSamples.map((s) => s.register)),
|
|
170
|
+
wall: stats(stripSamples.map((s) => s.wall)),
|
|
171
|
+
},
|
|
172
|
+
bundle: {
|
|
173
|
+
total: stats(bundleSamples.map((s) => s.total)),
|
|
174
|
+
import: stats(bundleSamples.map((s) => s.import)),
|
|
175
|
+
register: stats(bundleSamples.map((s) => s.register)),
|
|
176
|
+
wall: stats(bundleSamples.map((s) => s.wall)),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Speedup = strip - bundle (positive = bundle faster)
|
|
181
|
+
summary.speedup = {
|
|
182
|
+
total: summary.strip.total.p50 - summary.bundle.total.p50,
|
|
183
|
+
totalPct: pctDelta(summary.bundle.total.p50, summary.strip.total.p50),
|
|
184
|
+
import: summary.strip.import.p50 - summary.bundle.import.p50,
|
|
185
|
+
importPct: pctDelta(summary.bundle.import.p50, summary.strip.import.p50),
|
|
186
|
+
register: summary.strip.register.p50 - summary.bundle.register.p50,
|
|
187
|
+
registerPct: pctDelta(summary.bundle.register.p50, summary.strip.register.p50),
|
|
188
|
+
wall: summary.strip.wall.p50 - summary.bundle.wall.p50,
|
|
189
|
+
wallPct: pctDelta(summary.bundle.wall.p50, summary.strip.wall.p50),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Print human-readable summary
|
|
193
|
+
console.log("=== Cold-start benchmark: strip-types vs bundle ===\n");
|
|
194
|
+
|
|
195
|
+
const printPath = (label, data) => {
|
|
196
|
+
console.log(` ${label}:`);
|
|
197
|
+
console.log(` total (child process wall-clock): p50=${data.total.p50.toFixed(1)}ms p95=${data.total.p95.toFixed(1)}ms min=${data.total.min.toFixed(1)}ms`);
|
|
198
|
+
console.log(` import (await import(...)): p50=${data.import.p50.toFixed(1)}ms p95=${data.import.p95.toFixed(1)}ms`);
|
|
199
|
+
console.log(` register (registerPiTeams(mockPi)): p50=${data.register.p50.toFixed(1)}ms p95=${data.register.p95.toFixed(1)}ms`);
|
|
200
|
+
console.log("");
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
printPath("STRIP-TYPES (--experimental-strip-types + 1100 .ts files)", summary.strip);
|
|
204
|
+
printPath("BUNDLE (dist/index.mjs, 2.9MB single file)", summary.bundle);
|
|
205
|
+
|
|
206
|
+
console.log(` Speedup (bundle vs strip-types, p50):`);
|
|
207
|
+
console.log(` total: ${summary.speedup.total >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.total).toFixed(1)}ms (${summary.speedup.totalPct}%)`);
|
|
208
|
+
console.log(` import: ${summary.speedup.import >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.import).toFixed(1)}ms (${summary.speedup.importPct}%)`);
|
|
209
|
+
console.log(` register: ${summary.speedup.register >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.register).toFixed(1)}ms (${summary.speedup.registerPct}%)`);
|
|
210
|
+
console.log(` outer wall: ${summary.speedup.wall >= 0 ? "FASTER" : "SLOWER"} by ${Math.abs(summary.speedup.wall).toFixed(1)}ms (${summary.speedup.wallPct}%)`);
|
|
211
|
+
|
|
212
|
+
const benchDir = path.join(root, ".bench");
|
|
213
|
+
fs.mkdirSync(benchDir, { recursive: true });
|
|
214
|
+
const outFile = path.join(benchDir, `cold-start-${Date.now()}.json`);
|
|
215
|
+
fs.writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n", "utf-8");
|
|
216
|
+
console.log(`\n[bench] Raw results: ${outFile}`);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 5.5 — Bundle pi-crew into a single ESM file using esbuild.
|
|
4
|
+
*
|
|
5
|
+
* Output:
|
|
6
|
+
* dist/index.mjs — bundled extension entrypoint
|
|
7
|
+
* dist/index.mjs.map — source map
|
|
8
|
+
*
|
|
9
|
+
* Pi peer dependencies are kept external. Bundling shrinks parse+module-
|
|
10
|
+
* resolution cost on cold start: with strip-types Node still has to parse
|
|
11
|
+
* each .ts file individually, so a single .mjs cuts the per-file overhead.
|
|
12
|
+
*
|
|
13
|
+
* This script is invoked by `npm run build:bundle`. The `package.json#exports`
|
|
14
|
+
* field is configured so:
|
|
15
|
+
* - `dist/index.mjs` is the preferred entrypoint when present (set by Pi
|
|
16
|
+
* extension loader via "pi.extensions").
|
|
17
|
+
* - `index.ts` remains the fallback when dist/ is missing (e.g. running
|
|
18
|
+
* directly out of a clone without prior build).
|
|
19
|
+
*/
|
|
20
|
+
import { build } from "esbuild";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const root = path.resolve(here, "..");
|
|
27
|
+
|
|
28
|
+
const distDir = path.join(root, "dist");
|
|
29
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
const result = await build({
|
|
33
|
+
// Bundle entry must be the bare extension (index.bundle.ts) NOT the
|
|
34
|
+
// entry shell (index.ts). If we bundled index.ts, the bundled code
|
|
35
|
+
// would re-resolve dist/index.mjs relative to ITS OWN location
|
|
36
|
+
// (file:///.../dist/index.mjs), producing dist/dist/index.mjs and a
|
|
37
|
+
// recursion error. index.bundle.ts has no shell logic so this is
|
|
38
|
+
// a clean single-file bundle. See index.bundle.ts header for details.
|
|
39
|
+
entryPoints: [path.join(root, "index.bundle.ts")],
|
|
40
|
+
bundle: true,
|
|
41
|
+
format: "esm",
|
|
42
|
+
platform: "node",
|
|
43
|
+
target: "node22",
|
|
44
|
+
outfile: path.join(distDir, "index.mjs"),
|
|
45
|
+
sourcemap: true,
|
|
46
|
+
logLevel: "info",
|
|
47
|
+
// Keep peer deps external so consumers' Pi versions resolve naturally.
|
|
48
|
+
external: [
|
|
49
|
+
"@earendil-works/pi-coding-agent",
|
|
50
|
+
"@earendil-works/pi-ai",
|
|
51
|
+
"@earendil-works/pi-agent-core",
|
|
52
|
+
"@earendil-works/pi-tui",
|
|
53
|
+
// Direct deps as well — bundling their full graph would inflate the
|
|
54
|
+
// output and override consumer-installed versions.
|
|
55
|
+
"cli-highlight",
|
|
56
|
+
"diff",
|
|
57
|
+
"jiti",
|
|
58
|
+
"typebox",
|
|
59
|
+
],
|
|
60
|
+
// All node:* and Node-builtin modules are external by default for
|
|
61
|
+
// platform=node, but list explicitly for clarity.
|
|
62
|
+
//
|
|
63
|
+
// CJS-shim banner: pi-crew's dependency graph includes CommonJS modules
|
|
64
|
+
// (notably `yaml`) whose source calls `require("process")` etc. esbuild
|
|
65
|
+
// emits these as runtime `__require(...)` calls; in a pure-ESM context
|
|
66
|
+
// `require` is undefined, so we inject a `createRequire`-backed shim
|
|
67
|
+
// at the top of the bundle. This is the standard pattern for shipping
|
|
68
|
+
// CJS-mixed bundles as `.mjs`. See phase-2 H2 investigation (2026-06-30).
|
|
69
|
+
banner: {
|
|
70
|
+
js:
|
|
71
|
+
"// pi-crew bundled by scripts/build-bundle.mjs (5.5)\n" +
|
|
72
|
+
"// CJS-shim for legacy deps (yaml, etc.) that call require() in ESM context.\n" +
|
|
73
|
+
"import { createRequire as __piCrewCreateRequire } from 'node:module';\n" +
|
|
74
|
+
"const require = __piCrewCreateRequire(import.meta.url);\n" +
|
|
75
|
+
"const module = { exports: {} };\n" +
|
|
76
|
+
"const exports = module.exports;\n",
|
|
77
|
+
},
|
|
78
|
+
metafile: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
fs.writeFileSync(path.join(distDir, "build-meta.json"), JSON.stringify(result.metafile, null, 2) + "\n", "utf-8");
|
|
82
|
+
const elapsedMs = Date.now() - start;
|
|
83
|
+
const stat = fs.statSync(path.join(distDir, "index.mjs"));
|
|
84
|
+
console.log(`[build-bundle] dist/index.mjs ${(stat.size / 1024).toFixed(1)} KB in ${elapsedMs} ms`);
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build assets/crew-vibes.ttf.
|
|
3
|
+
|
|
4
|
+
Speed indicator (16 runner frames, U+E700..U+E70F):
|
|
5
|
+
traced from assets/runner-spritesheet.png (a 4x4 silhouette sprite sheet)
|
|
6
|
+
via threshold -> morphological close -> Moore-neighbor boundary trace ->
|
|
7
|
+
Douglas-Peucker simplify -> Chaikin smooth.
|
|
8
|
+
|
|
9
|
+
Capacity meter (6 boat-with-crew stages, U+E710..U+E715):
|
|
10
|
+
drawn as vector silhouettes (half-ellipse hull + circle crew heads).
|
|
11
|
+
|
|
12
|
+
Regenerate after editing the sprite sheet or the capacity builder:
|
|
13
|
+
|
|
14
|
+
python scripts/build-crew-vibes-font.py
|
|
15
|
+
"""
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
from PIL import Image, ImageFilter
|
|
19
|
+
from fontTools.fontBuilder import FontBuilder
|
|
20
|
+
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
|
21
|
+
|
|
22
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
ROOT = os.path.join(HERE, "..")
|
|
24
|
+
SPRITE = os.path.join(ROOT, "assets", "runner-spritesheet.png")
|
|
25
|
+
TTF_OUT = os.path.join(ROOT, "assets", "crew-vibes.ttf")
|
|
26
|
+
UPM = 1000
|
|
27
|
+
ASCENT = 780
|
|
28
|
+
TH = 200 # threshold: pixel < TH is figure
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------- image -> contours ----------
|
|
32
|
+
|
|
33
|
+
def load_binary(path):
|
|
34
|
+
im = Image.open(path).convert("L")
|
|
35
|
+
W, H = im.size
|
|
36
|
+
px = im.load()
|
|
37
|
+
return [[px[x, y] < TH for x in range(W)] for y in range(H)], W, H
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_rows(b, W, H, min_height=80, gap=12):
|
|
41
|
+
row_sum = [sum(1 for x in range(W) if b[y][x]) for y in range(H)]
|
|
42
|
+
runs = []
|
|
43
|
+
i = 0
|
|
44
|
+
while i < H:
|
|
45
|
+
if row_sum[i] > 0:
|
|
46
|
+
j = i
|
|
47
|
+
while j < H and row_sum[j] > 0:
|
|
48
|
+
j += 1
|
|
49
|
+
if j - i >= min_height:
|
|
50
|
+
runs.append((i, j - 1))
|
|
51
|
+
i = j
|
|
52
|
+
else:
|
|
53
|
+
i += 1
|
|
54
|
+
merged = []
|
|
55
|
+
for r in runs:
|
|
56
|
+
if merged and r[0] - merged[-1][1] <= gap:
|
|
57
|
+
merged[-1] = (merged[-1][0], r[1])
|
|
58
|
+
else:
|
|
59
|
+
merged.append(r)
|
|
60
|
+
return merged
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def connected_components_in_band(b, W, y0, y1, min_area=400):
|
|
64
|
+
visited = [[False] * W for _ in range(y1 - y0)]
|
|
65
|
+
comps = []
|
|
66
|
+
for sy in range(y1 - y0):
|
|
67
|
+
for sx in range(W):
|
|
68
|
+
if b[y0 + sy][sx] and not visited[sy][sx]:
|
|
69
|
+
stack = [(sx, sy)]
|
|
70
|
+
visited[sy][sx] = True
|
|
71
|
+
xs, ys, area = [], [], 0
|
|
72
|
+
while stack:
|
|
73
|
+
x, y = stack.pop()
|
|
74
|
+
xs.append(x); ys.append(y); area += 1
|
|
75
|
+
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
76
|
+
nx, ny = x + dx, y + dy
|
|
77
|
+
if 0 <= nx < W and 0 <= ny < (y1 - y0) and b[y0 + ny][nx] and not visited[ny][nx]:
|
|
78
|
+
visited[ny][nx] = True
|
|
79
|
+
stack.append((nx, ny))
|
|
80
|
+
if area >= min_area:
|
|
81
|
+
comps.append((min(xs), y0 + min(ys), max(xs) + 1, y0 + max(ys) + 1))
|
|
82
|
+
comps.sort(key=lambda c: c[0])
|
|
83
|
+
return comps
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clean_binary(sub):
|
|
87
|
+
h = len(sub); w = len(sub[0]) if h else 0
|
|
88
|
+
if not h or not w:
|
|
89
|
+
return sub
|
|
90
|
+
im = Image.new("1", (w, h))
|
|
91
|
+
px = im.load()
|
|
92
|
+
for y in range(h):
|
|
93
|
+
for x in range(w):
|
|
94
|
+
px[x, y] = 1 if sub[y][x] else 0
|
|
95
|
+
im = im.filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.MinFilter(3))
|
|
96
|
+
px = im.load()
|
|
97
|
+
return [[bool(px[x, y]) for x in range(w)] for y in range(h)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def trim_bbox(cell):
|
|
101
|
+
h = len(cell); w = len(cell[0]) if h else 0
|
|
102
|
+
xs = [x for y in range(h) for x in range(w) if cell[y][x]]
|
|
103
|
+
ys = [y for y in range(h) for x in range(w) if cell[y][x]]
|
|
104
|
+
if not xs:
|
|
105
|
+
return None
|
|
106
|
+
return (min(xs), min(ys), max(xs) + 1, max(ys) + 1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def moore_trace(b):
|
|
110
|
+
h = len(b); w = len(b[0]) if h else 0
|
|
111
|
+
start = None
|
|
112
|
+
for y in range(h):
|
|
113
|
+
for x in range(w):
|
|
114
|
+
if b[y][x]:
|
|
115
|
+
start = (x, y); break
|
|
116
|
+
if start:
|
|
117
|
+
break
|
|
118
|
+
if not start:
|
|
119
|
+
return []
|
|
120
|
+
dirs = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
|
|
121
|
+
contour = [start]
|
|
122
|
+
cur = start
|
|
123
|
+
bdir = 4
|
|
124
|
+
while True:
|
|
125
|
+
found = False
|
|
126
|
+
for k in range(1, 9):
|
|
127
|
+
d = (bdir + k) % 8
|
|
128
|
+
nx, ny = cur[0] + dirs[d][0], cur[1] + dirs[d][1]
|
|
129
|
+
if 0 <= nx < w and 0 <= ny < h and b[ny][nx]:
|
|
130
|
+
bdir = (d + 4) % 8
|
|
131
|
+
cur = (nx, ny)
|
|
132
|
+
found = True
|
|
133
|
+
break
|
|
134
|
+
if not found:
|
|
135
|
+
break
|
|
136
|
+
if cur == start:
|
|
137
|
+
break
|
|
138
|
+
contour.append(cur)
|
|
139
|
+
return contour
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def point_line_dist(p, a, b):
|
|
143
|
+
if a == b:
|
|
144
|
+
return math.hypot(p[0] - a[0], p[1] - a[1])
|
|
145
|
+
t = ((p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1])) / ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2)
|
|
146
|
+
t = max(0, min(1, t))
|
|
147
|
+
return math.hypot(p[0] - (a[0] + t * (b[0] - a[0])), p[1] - (a[1] + t * (b[1] - a[1])))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def douglas_peucker(points, eps):
|
|
151
|
+
if len(points) < 3:
|
|
152
|
+
return points
|
|
153
|
+
dmax = 0; idx = 0
|
|
154
|
+
for i in range(1, len(points) - 1):
|
|
155
|
+
d = point_line_dist(points[i], points[0], points[-1])
|
|
156
|
+
if d > dmax:
|
|
157
|
+
dmax = d; idx = i
|
|
158
|
+
if dmax > eps:
|
|
159
|
+
left = douglas_peucker(points[: idx + 1], eps)
|
|
160
|
+
right = douglas_peucker(points[idx:], eps)
|
|
161
|
+
return left[:-1] + right
|
|
162
|
+
return [points[0], points[-1]]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def chaikin(points, iters=2):
|
|
166
|
+
pts = points[:]
|
|
167
|
+
for _ in range(iters):
|
|
168
|
+
out = [pts[0]]
|
|
169
|
+
for i in range(len(pts) - 1):
|
|
170
|
+
a, b = pts[i], pts[i + 1]
|
|
171
|
+
out.append((0.75 * a[0] + 0.25 * b[0], 0.75 * a[1] + 0.25 * b[1]))
|
|
172
|
+
out.append((0.25 * a[0] + 0.75 * b[0], 0.25 * a[1] + 0.75 * b[1]))
|
|
173
|
+
out.append(pts[-1])
|
|
174
|
+
pts = out
|
|
175
|
+
return pts
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def trace_frame(b, cx0, cy0, cx1, cy1, pad=4, target_h=720):
|
|
179
|
+
cell = [[b[y][x] for x in range(cx0, cx1)] for y in range(cy0, cy1)]
|
|
180
|
+
bbox = trim_bbox(cell)
|
|
181
|
+
if not bbox:
|
|
182
|
+
return None
|
|
183
|
+
x0, y0, x1, y1 = bbox
|
|
184
|
+
px0 = max(0, x0 - pad); py0 = max(0, y0 - pad)
|
|
185
|
+
px1 = min(cx1 - cx0, x1 + pad); py1 = min(cy1 - cy0, y1 + pad)
|
|
186
|
+
sub = [[cell[y][x] for x in range(px0, px1)] for y in range(py0, py1)]
|
|
187
|
+
sub = clean_binary(sub)
|
|
188
|
+
chain = moore_trace(sub)
|
|
189
|
+
if len(chain) < 6:
|
|
190
|
+
return None
|
|
191
|
+
chain = chain + [chain[0]]
|
|
192
|
+
simp = douglas_peucker(chain, 1.5)
|
|
193
|
+
sm = chaikin(simp, 2)
|
|
194
|
+
if len(sm) < 6:
|
|
195
|
+
return None
|
|
196
|
+
pts = [(p[0] + cx0 + px0, p[1] + cy0 + py0) for p in sm]
|
|
197
|
+
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
|
|
198
|
+
minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
|
|
199
|
+
fh = maxy - miny or 1
|
|
200
|
+
scale = target_h / fh
|
|
201
|
+
contour = [((p[0] - minx) * scale, (maxy - p[1]) * scale) for p in pts]
|
|
202
|
+
advance = int(round((maxx - minx) * scale)) + 60
|
|
203
|
+
return contour, advance
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------- vector capacity (boat + crew) ----------
|
|
207
|
+
|
|
208
|
+
def polygon(pen, pts):
|
|
209
|
+
pen.moveTo(pts[0])
|
|
210
|
+
for p in pts[1:]:
|
|
211
|
+
pen.lineTo(p)
|
|
212
|
+
pen.closePath()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def circle(pen, cx, cy, r, n=40):
|
|
216
|
+
pts = [(cx + r * math.cos(2 * math.pi * i / n), cy + r * math.sin(2 * math.pi * i / n)) for i in range(n)]
|
|
217
|
+
polygon(pen, pts)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def capsule(pen, ax, ay, bx, by, r):
|
|
221
|
+
dx, dy = bx - ax, by - ay
|
|
222
|
+
length = math.hypot(dx, dy) or 1.0
|
|
223
|
+
nx, ny = -dy / length, dx / length
|
|
224
|
+
polygon(pen, [(ax + nx * r, ay + ny * r), (bx + nx * r, by + ny * r), (bx - nx * r, by - ny * r), (ax - nx * r, ay - ny * r)])
|
|
225
|
+
circle(pen, ax, ay, r)
|
|
226
|
+
circle(pen, bx, by, r)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def half_ellipse(pen, cx, cy, rx, ry, n=48):
|
|
230
|
+
pts = [(cx + rx * math.cos(math.pi * i / n), cy - ry * math.sin(math.pi * i / n)) for i in range(n + 1)]
|
|
231
|
+
polygon(pen, pts)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def draw_boat(pen, n, target_h=720):
|
|
235
|
+
"""Draw boat + n crew heads, fitting in target_h font units (baseline 0)."""
|
|
236
|
+
s = target_h / 480.0 # design is 480 tall
|
|
237
|
+
half_ellipse(pen, 380 * s, 140 * s, 320 * s, 130 * s)
|
|
238
|
+
capsule(pen, 60 * s, 140 * s, 700 * s, 140 * s, 12 * s)
|
|
239
|
+
capsule(pen, 20 * s, 10 * s, 740 * s, 10 * s, 9 * s)
|
|
240
|
+
if n == 1:
|
|
241
|
+
xs = [380]
|
|
242
|
+
else:
|
|
243
|
+
xs = [round(80 + i * (600 / (n - 1))) for i in range(n)]
|
|
244
|
+
r = 52 if n <= 3 else (44 if n <= 4 else 36)
|
|
245
|
+
for x in xs:
|
|
246
|
+
head_y = 250 + (52 - r)
|
|
247
|
+
circle(pen, x * s, head_y * s, r * s)
|
|
248
|
+
capsule(pen, x * s, (head_y - r + 6) * s, x * s, 150 * s, r * 0.55 * s)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def capacity_glyph(n):
|
|
252
|
+
pen = TTGlyphPen(None)
|
|
253
|
+
draw_boat(pen, n)
|
|
254
|
+
g = pen.glyph()
|
|
255
|
+
xs = [pt[0] for pt in g.coordinates]
|
|
256
|
+
advance = int(round(max(xs))) + 40 if xs else 700
|
|
257
|
+
return g, advance
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------- assemble font ----------
|
|
261
|
+
|
|
262
|
+
def main():
|
|
263
|
+
b, W, H = load_binary(SPRITE)
|
|
264
|
+
rows = detect_rows(b, W, H)
|
|
265
|
+
print(f"sprite {W}x{H}, rows: {rows}")
|
|
266
|
+
glyphs = [] # (cp, name, glyph_obj_or_pen_draw, advance, lsb)
|
|
267
|
+
|
|
268
|
+
fb = FontBuilder(UPM, isTTF=True)
|
|
269
|
+
runner_names = [f"runner{i}" for i in range(16)]
|
|
270
|
+
capacity_names = [f"crew{i}" for i in range(6)]
|
|
271
|
+
order = [".notdef"] + runner_names + capacity_names
|
|
272
|
+
cmap = {}
|
|
273
|
+
for i, nm in enumerate(runner_names):
|
|
274
|
+
cmap[0xE700 + i] = nm
|
|
275
|
+
for i, nm in enumerate(capacity_names):
|
|
276
|
+
cmap[0xE710 + i] = nm
|
|
277
|
+
fb.setupGlyphOrder(order)
|
|
278
|
+
fb.setupCharacterMap(cmap)
|
|
279
|
+
fb.setupHorizontalHeader(ascent=ASCENT, descent=0)
|
|
280
|
+
|
|
281
|
+
h_metrics = {".notdef": (UPM, 0)}
|
|
282
|
+
glyf = {}
|
|
283
|
+
nd = TTGlyphPen(None)
|
|
284
|
+
glyf[".notdef"] = nd.glyph()
|
|
285
|
+
|
|
286
|
+
idx = 0
|
|
287
|
+
for (ry0, ry1) in rows:
|
|
288
|
+
comps = connected_components_in_band(b, W, ry0, ry1, min_area=400)
|
|
289
|
+
for (cx0, cy0, cx1, cy1) in comps:
|
|
290
|
+
res = trace_frame(b, cx0, cy0, cx1, cy1)
|
|
291
|
+
if res is None:
|
|
292
|
+
print(f" runner{idx}: empty")
|
|
293
|
+
glyf[f"runner{idx}"] = TTGlyphPen(None).glyph()
|
|
294
|
+
h_metrics[f"runner{idx}"] = (600, 0)
|
|
295
|
+
idx += 1
|
|
296
|
+
continue
|
|
297
|
+
contour, advance = res
|
|
298
|
+
pen = TTGlyphPen(None)
|
|
299
|
+
pen.moveTo(contour[0])
|
|
300
|
+
for p in contour[1:]:
|
|
301
|
+
pen.lineTo(p)
|
|
302
|
+
pen.closePath()
|
|
303
|
+
glyf[f"runner{idx}"] = pen.glyph()
|
|
304
|
+
h_metrics[f"runner{idx}"] = (advance, 0)
|
|
305
|
+
print(f" runner{idx}: {len(contour)} pts, advance {advance}")
|
|
306
|
+
idx += 1
|
|
307
|
+
if idx < 16:
|
|
308
|
+
for k in range(idx, 16):
|
|
309
|
+
glyf[f"runner{k}"] = TTGlyphPen(None).glyph()
|
|
310
|
+
h_metrics[f"runner{k}"] = (600, 0)
|
|
311
|
+
|
|
312
|
+
for i in range(6):
|
|
313
|
+
g, advance = capacity_glyph(i + 1)
|
|
314
|
+
glyf[f"crew{i}"] = g
|
|
315
|
+
h_metrics[f"crew{i}"] = (advance, 0)
|
|
316
|
+
print(f" crew{i}: advance {advance}")
|
|
317
|
+
|
|
318
|
+
fb.setupHorizontalMetrics(h_metrics)
|
|
319
|
+
fb.setupGlyf(glyf)
|
|
320
|
+
fb.setupNameTable({"familyName": "Crew Vibes", "styleName": "Regular"})
|
|
321
|
+
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=0, sTypoLineGap=0)
|
|
322
|
+
fb.setupPost()
|
|
323
|
+
fb.font.save(TTF_OUT)
|
|
324
|
+
print(f"Wrote {TTF_OUT} ({os.path.getsize(TTF_OUT)} bytes)")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|