@mcptoolshop/style-dataset-lab 1.2.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +138 -113
  3. package/bin/sdlab.js +126 -0
  4. package/lib/args.js +106 -0
  5. package/lib/card.js +145 -0
  6. package/lib/comfyui.js +109 -0
  7. package/lib/config.js +248 -0
  8. package/lib/deprecation.js +14 -0
  9. package/lib/eligibility.js +110 -0
  10. package/lib/eval-pack.js +283 -0
  11. package/lib/export.js +259 -0
  12. package/lib/paths.js +38 -0
  13. package/lib/records.js +69 -0
  14. package/lib/snapshot.js +211 -0
  15. package/lib/split.js +409 -0
  16. package/package.json +20 -9
  17. package/scripts/bulk-curate-wave2-5.js +1 -1
  18. package/scripts/bulk-curate-waves11-18.js +1 -1
  19. package/scripts/canon-bind.js +188 -338
  20. package/scripts/card.js +73 -0
  21. package/scripts/compare.js +40 -30
  22. package/scripts/curate-wave25.js +1 -1
  23. package/scripts/curate.js +34 -28
  24. package/scripts/doctor.js +324 -0
  25. package/scripts/eligibility.js +91 -0
  26. package/scripts/eval-pack.js +92 -0
  27. package/scripts/export.js +85 -0
  28. package/scripts/generate-controlnet.js +36 -121
  29. package/scripts/generate-identity.js +38 -106
  30. package/scripts/generate-ipadapter.js +42 -119
  31. package/scripts/generate.js +22 -81
  32. package/scripts/init.js +229 -0
  33. package/scripts/migrate-records.js +15 -21
  34. package/scripts/painterly-test.js +20 -47
  35. package/scripts/painterly.js +27 -106
  36. package/scripts/snapshot.js +132 -0
  37. package/scripts/split.js +154 -0
  38. package/templates/domains/architecture/constitution.json +97 -0
  39. package/templates/domains/architecture/lanes.json +41 -0
  40. package/templates/domains/architecture/project.json +20 -0
  41. package/templates/domains/architecture/rubric.json +40 -0
  42. package/templates/domains/architecture/terminology.json +19 -0
  43. package/templates/domains/character-design/constitution.json +88 -0
  44. package/templates/domains/character-design/lanes.json +35 -0
  45. package/templates/domains/character-design/project.json +17 -0
  46. package/templates/domains/character-design/rubric.json +38 -0
  47. package/templates/domains/character-design/terminology.json +21 -0
  48. package/templates/domains/creature-design/constitution.json +87 -0
  49. package/templates/domains/creature-design/lanes.json +41 -0
  50. package/templates/domains/creature-design/project.json +18 -0
  51. package/templates/domains/creature-design/rubric.json +39 -0
  52. package/templates/domains/creature-design/terminology.json +20 -0
  53. package/templates/domains/game-art/constitution.json +113 -0
  54. package/templates/domains/game-art/lanes.json +47 -0
  55. package/templates/domains/game-art/project.json +17 -0
  56. package/templates/domains/game-art/rubric.json +36 -0
  57. package/templates/domains/game-art/terminology.json +30 -0
  58. package/templates/domains/vehicle-mech/constitution.json +87 -0
  59. package/templates/domains/vehicle-mech/lanes.json +41 -0
  60. package/templates/domains/vehicle-mech/project.json +17 -0
  61. package/templates/domains/vehicle-mech/rubric.json +40 -0
  62. package/templates/domains/vehicle-mech/terminology.json +21 -0
@@ -4,29 +4,17 @@
4
4
  * generate.js — Drive ComfyUI to produce candidate assets from a prompt pack.
5
5
  *
6
6
  * Usage: node scripts/generate.js [--prompt-pack inputs/prompts/rpg-icons-lane1.json]
7
+ * sdlab generate inputs/prompts/wave1.json --project star-freight
7
8
  *
8
9
  * For each subject × variation, submits a workflow to ComfyUI HTTP API,
9
10
  * downloads the output PNG, and writes a provenance record to records/.
10
11
  */
11
12
 
12
13
  import { readFile, writeFile, mkdir } from "node:fs/promises";
13
- import { join, basename } from "node:path";
14
-
15
- const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
16
- const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
17
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
18
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
19
-
20
- // ── ComfyUI API helpers ──
21
-
22
- async function comfyHealth() {
23
- try {
24
- const res = await fetch(`${COMFY_URL}/system_stats`);
25
- return res.ok;
26
- } catch {
27
- return false;
28
- }
29
- }
14
+ import { join } from "node:path";
15
+ import { getProjectName } from "../lib/args.js";
16
+ import { REPO_ROOT } from "../lib/paths.js";
17
+ import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
30
18
 
31
19
  function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, cfg, sampler, scheduler, width, height, ipAdapterConfig) {
32
20
  const nodes = {};
@@ -164,57 +152,13 @@ function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, c
164
152
  return { nodes, saveNodeId: saveId };
165
153
  }
166
154
 
167
- const MAX_POLL_MS = 600_000; // 10 minutes
168
-
169
- async function submitAndWait(workflow) {
170
- const clientId = `sdl-${Date.now()}`;
171
-
172
- // Submit
173
- const res = await fetch(`${COMFY_URL}/prompt`, {
174
- method: "POST",
175
- headers: { "Content-Type": "application/json" },
176
- body: JSON.stringify({ prompt: workflow, client_id: clientId }),
177
- });
178
-
179
- if (!res.ok) {
180
- const text = await res.text();
181
- throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
182
- }
183
-
184
- const { prompt_id: promptId } = await res.json();
185
-
186
- // Poll history until complete
187
- const start = Date.now();
188
- while (true) {
189
- if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
190
- await new Promise((r) => setTimeout(r, 1000));
191
- const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
192
- if (!histRes.ok) continue;
193
- const history = await histRes.json();
194
- const entry = history[promptId];
195
- if (!entry) continue;
196
- if (entry.status?.completed) {
197
- return entry;
198
- }
199
- if (entry.status?.status_str === "error") {
200
- throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
201
- }
202
- }
203
- }
204
-
205
- async function downloadImage(filename, subfolder, type = "output") {
206
- const url = `${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=${type}`;
207
- const res = await fetch(url);
208
- if (!res.ok) throw new Error(`Download failed: ${res.status}`);
209
- return Buffer.from(await res.arrayBuffer());
210
- }
211
-
212
- // ── Main ──
155
+ export async function run(argv = process.argv.slice(2)) {
156
+ const projectName = getProjectName(argv);
157
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
158
+ const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
213
159
 
214
- async function main() {
215
- const args = process.argv.slice(2);
216
- const packPath = args.find((a) => !a.startsWith("--")) || "inputs/prompts/rpg-icons-lane1.json";
217
- const dryRun = args.includes("--dry-run");
160
+ const packPath = argv.find((a) => !a.startsWith("--")) || "inputs/prompts/rpg-icons-lane1.json";
161
+ const dryRun = argv.includes("--dry-run");
218
162
 
219
163
  const fullPackPath = join(GAME_ROOT, packPath);
220
164
  const pack = JSON.parse(await readFile(fullPackPath, "utf-8"));
@@ -227,11 +171,9 @@ async function main() {
227
171
  console.log("");
228
172
 
229
173
  if (!dryRun) {
230
- const online = await comfyHealth();
174
+ const online = await comfyHealth(COMFY_URL);
231
175
  if (!online) {
232
- console.error("\x1b[31mError:\x1b[0m ComfyUI not reachable at " + COMFY_URL);
233
- console.error("Start ComfyUI: cd F:/AI-Models/ComfyUI-runtime && python main.py --listen 127.0.0.1 --port 8188");
234
- process.exit(1);
176
+ throw new Error("ComfyUI not reachable at " + COMFY_URL);
235
177
  }
236
178
  console.log("\x1b[32m✓\x1b[0m ComfyUI online");
237
179
  }
@@ -244,7 +186,7 @@ async function main() {
244
186
  const totalExpected = pack.subjects.length * pack.variations.length;
245
187
 
246
188
  for (const subject of pack.subjects) {
247
- for (const variation of variation_list(pack.variations)) {
189
+ for (const variation of pack.variations) {
248
190
  const assetId = `${subject.id}_${variation.id}`;
249
191
  const seed = (d.base_seed + generated) + (variation.seed_offset || 0);
250
192
  const fullPrompt = `${pack.style_prefix}, ${subject.prompt}`;
@@ -266,7 +208,7 @@ async function main() {
266
208
  );
267
209
 
268
210
  try {
269
- const result = await submitAndWait(nodes);
211
+ const result = await submitAndWait(nodes, COMFY_URL);
270
212
  const elapsed = Date.now() - startMs;
271
213
 
272
214
  // Find output image
@@ -288,7 +230,7 @@ async function main() {
288
230
  }
289
231
 
290
232
  // Download and save
291
- const imgData = await downloadImage(imageFile, imageSubfolder);
233
+ const imgData = await downloadImage(imageFile, imageSubfolder, COMFY_URL);
292
234
  const destPath = `outputs/candidates/${assetId}.png`;
293
235
  await writeFile(join(GAME_ROOT, destPath), imgData);
294
236
 
@@ -341,11 +283,10 @@ async function main() {
341
283
  if (dryRun) console.log(" (dry run — no images produced)");
342
284
  }
343
285
 
344
- function variation_list(variations) {
345
- return variations;
286
+ // Direct execution guard
287
+ if (process.argv[1] && (process.argv[1].endsWith('generate.js') || process.argv[1].endsWith('generate'))) {
288
+ run().catch((err) => {
289
+ console.error(err.message || err);
290
+ process.exit(1);
291
+ });
346
292
  }
347
-
348
- main().catch((err) => {
349
- console.error(err);
350
- process.exit(1);
351
- });
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * init.js — Scaffold a new project from a domain template.
5
+ *
6
+ * Usage:
7
+ * sdlab init my-project --domain character-design
8
+ * sdlab init my-project # uses 'generic' domain
9
+ * node scripts/init.js my-project --domain game-art
10
+ */
11
+
12
+ import { mkdir, readdir, readFile, writeFile, copyFile } from 'node:fs/promises';
13
+ import { join, basename } from 'node:path';
14
+ import { existsSync } from 'node:fs';
15
+ import { REPO_ROOT } from '../lib/paths.js';
16
+
17
+ const TEMPLATES_DIR = join(REPO_ROOT, 'templates');
18
+ const PROJECTS_DIR = join(REPO_ROOT, 'projects');
19
+
20
+ const PROJECT_DIRS = [
21
+ 'canon',
22
+ 'records',
23
+ 'comparisons',
24
+ 'inputs/prompts',
25
+ 'inputs/identity-packets',
26
+ 'inputs/references',
27
+ 'inputs/control-guides',
28
+ 'outputs/candidates',
29
+ 'outputs/approved',
30
+ 'outputs/rejected',
31
+ 'outputs/borderline',
32
+ 'outputs/painterly',
33
+ 'outputs/painterly-test',
34
+ 'exports',
35
+ 'snapshots',
36
+ 'splits',
37
+ 'eval-packs',
38
+ ];
39
+
40
+ function isValidName(name) {
41
+ return /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(name) || /^[a-z0-9]$/.test(name);
42
+ }
43
+
44
+ async function listDomains() {
45
+ const domainsDir = join(TEMPLATES_DIR, 'domains');
46
+ if (!existsSync(domainsDir)) return [];
47
+ const entries = await readdir(domainsDir, { withFileTypes: true });
48
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
49
+ }
50
+
51
+ export async function run(argv = process.argv.slice(2)) {
52
+ const projectName = argv.find(a => !a.startsWith('--'));
53
+
54
+ // Parse --domain
55
+ let domain = 'generic';
56
+ const domainIdx = argv.indexOf('--domain');
57
+ if (domainIdx >= 0 && argv[domainIdx + 1]) {
58
+ domain = argv[domainIdx + 1];
59
+ }
60
+
61
+ if (!projectName) {
62
+ const domains = await listDomains();
63
+ console.log('Usage: sdlab init <project-name> [--domain <domain>]\n');
64
+ console.log('Available domains:');
65
+ console.log(' generic Minimal starter (default)');
66
+ for (const d of domains) {
67
+ console.log(` ${d.padEnd(20)} Domain-specific starter`);
68
+ }
69
+ return;
70
+ }
71
+
72
+ if (!isValidName(projectName)) {
73
+ throw new Error(
74
+ `Invalid project name "${projectName}".\n` +
75
+ `Use lowercase letters, numbers, and hyphens (e.g. "my-project").`
76
+ );
77
+ }
78
+
79
+ const projectDir = join(PROJECTS_DIR, projectName);
80
+ if (existsSync(projectDir)) {
81
+ throw new Error(`Project "${projectName}" already exists at ${projectDir}`);
82
+ }
83
+
84
+ // Resolve domain template source
85
+ let domainDir = null;
86
+ if (domain !== 'generic') {
87
+ domainDir = join(TEMPLATES_DIR, 'domains', domain);
88
+ if (!existsSync(domainDir)) {
89
+ const domains = await listDomains();
90
+ throw new Error(
91
+ `Domain "${domain}" not found.\n` +
92
+ `Available: generic, ${domains.join(', ')}`
93
+ );
94
+ }
95
+ }
96
+
97
+ console.log(`\x1b[1msdlab init\x1b[0m`);
98
+ console.log(` Project: ${projectName}`);
99
+ console.log(` Domain: ${domain}`);
100
+ console.log(` Path: ${projectDir}`);
101
+ console.log('');
102
+
103
+ // Create directory structure
104
+ for (const dir of PROJECT_DIRS) {
105
+ await mkdir(join(projectDir, dir), { recursive: true });
106
+ }
107
+ console.log(` \x1b[32m✓\x1b[0m Created ${PROJECT_DIRS.length} directories`);
108
+
109
+ // Copy config files from domain template or generate generic ones
110
+ const configFiles = ['project.json', 'constitution.json', 'lanes.json', 'rubric.json', 'terminology.json'];
111
+
112
+ if (domainDir) {
113
+ for (const file of configFiles) {
114
+ const src = join(domainDir, file);
115
+ if (existsSync(src)) {
116
+ let content = await readFile(src, 'utf-8');
117
+ // Replace template project name
118
+ if (file === 'project.json') {
119
+ const parsed = JSON.parse(content);
120
+ parsed.name = projectName;
121
+ content = JSON.stringify(parsed, null, 2) + '\n';
122
+ }
123
+ await writeFile(join(projectDir, file), content);
124
+ console.log(` \x1b[32m✓\x1b[0m ${file} (from ${domain} template)`);
125
+ }
126
+ }
127
+ } else {
128
+ // Generic starters
129
+ await writeFile(join(projectDir, 'project.json'), JSON.stringify({
130
+ name: projectName,
131
+ display_name: projectName.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
132
+ domain: 'generic',
133
+ version: '0.1.0',
134
+ defaults: {
135
+ checkpoint: '',
136
+ loras: [],
137
+ width: 1024,
138
+ height: 1024,
139
+ steps: 8,
140
+ cfg: 2.0,
141
+ sampler: 'dpmpp_sde',
142
+ scheduler: 'karras',
143
+ comfy_url: 'http://127.0.0.1:8188'
144
+ }
145
+ }, null, 2) + '\n');
146
+
147
+ await writeFile(join(projectDir, 'constitution.json'), JSON.stringify({
148
+ version: '0.1.0',
149
+ rules: [
150
+ { id: 'STY-001', category: 'style', desc: 'Consistent visual style across all assets',
151
+ dims: ['style_consistency'], faction_specific: false,
152
+ rationale_pass: 'Style consistency achieved (${scorePct}%)',
153
+ rationale_fail: 'Style inconsistent (${scorePct}%)' },
154
+ { id: 'CMP-001', category: 'composition', desc: 'Clean composition and framing',
155
+ dims: ['composition'], faction_specific: false,
156
+ rationale_pass: 'Composition reads clearly (${scorePct}%)',
157
+ rationale_fail: 'Composition unclear or cluttered (${scorePct}%)' },
158
+ ]
159
+ }, null, 2) + '\n');
160
+
161
+ await writeFile(join(projectDir, 'lanes.json'), JSON.stringify({
162
+ default_lane: 'default',
163
+ lanes: [
164
+ { id: 'default', label: 'Default', description: 'All assets', id_patterns: [] }
165
+ ]
166
+ }, null, 2) + '\n');
167
+
168
+ await writeFile(join(projectDir, 'rubric.json'), JSON.stringify({
169
+ dimensions: ['style_consistency', 'composition', 'detail', 'originality'],
170
+ thresholds: {
171
+ approved: { pass: 0.7, partial: 0.5 },
172
+ borderline: { pass: 0.7, partial: 0.5 },
173
+ rejected: { fail_ceiling: 0.4, partial_ceiling: 0.6 }
174
+ },
175
+ failure_to_rules: {}
176
+ }, null, 2) + '\n');
177
+
178
+ await writeFile(join(projectDir, 'terminology.json'), JSON.stringify({
179
+ group_label: 'group',
180
+ id_detection_order: [],
181
+ prompt_detection_order: [],
182
+ groups: {},
183
+ cross_group_patterns: {},
184
+ edge_defaults: {},
185
+ null_faction_patterns: [],
186
+ id_fallbacks: {},
187
+ faction_context: {}
188
+ }, null, 2) + '\n');
189
+
190
+ for (const file of configFiles) {
191
+ console.log(` \x1b[32m✓\x1b[0m ${file} (generic starter)`);
192
+ }
193
+ }
194
+
195
+ // Copy canon markdown templates
196
+ const canonTemplates = ['constitution.md', 'review-rubric.md'];
197
+ for (const file of canonTemplates) {
198
+ const src = join(TEMPLATES_DIR, 'canon', file);
199
+ if (existsSync(src)) {
200
+ await copyFile(src, join(projectDir, 'canon', file));
201
+ console.log(` \x1b[32m✓\x1b[0m canon/${file}`);
202
+ }
203
+ }
204
+
205
+ // Copy example prompt pack
206
+ const examplePack = join(TEMPLATES_DIR, 'inputs', 'prompts', 'example-wave.json');
207
+ if (existsSync(examplePack)) {
208
+ await copyFile(examplePack, join(projectDir, 'inputs', 'prompts', 'example-wave.json'));
209
+ console.log(` \x1b[32m✓\x1b[0m inputs/prompts/example-wave.json`);
210
+ }
211
+
212
+ console.log('');
213
+ console.log(`\x1b[32m✓\x1b[0m Project "${projectName}" initialized`);
214
+ console.log('');
215
+ console.log('Next steps:');
216
+ console.log(` 1. Edit ${projectName}/canon/constitution.md — define your visual rules`);
217
+ console.log(` 2. Edit ${projectName}/constitution.json — encode rules for canon-bind`);
218
+ console.log(` 3. Create prompt packs in ${projectName}/inputs/prompts/`);
219
+ console.log(` 4. Run: sdlab generate <pack> --project ${projectName}`);
220
+ console.log(` 5. Run: sdlab project doctor --project ${projectName}`);
221
+ }
222
+
223
+ // Direct execution guard
224
+ if (process.argv[1] && (process.argv[1].endsWith('init.js') || process.argv[1].endsWith('init'))) {
225
+ run().catch((err) => {
226
+ console.error(err.message || err);
227
+ process.exit(1);
228
+ });
229
+ }
@@ -12,43 +12,33 @@
12
12
  * Usage:
13
13
  * node scripts/migrate-records.js --dry-run # Preview changes
14
14
  * node scripts/migrate-records.js # Apply changes
15
+ * sdlab project migrate --dry-run --project star-freight
15
16
  */
16
17
 
17
18
  import { readdir, readFile, writeFile, access } from 'node:fs/promises';
18
19
  import { join, basename } from 'node:path';
19
- import { fileURLToPath } from 'node:url';
20
-
21
- const __dirname = new URL('.', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
22
- const REPO_ROOT = join(__dirname, '..');
23
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
24
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
25
- const RECORDS_DIR = join(GAME_ROOT, 'records');
26
- const DRY_RUN = process.argv.includes('--dry-run');
20
+ import { getProjectName } from '../lib/args.js';
21
+ import { REPO_ROOT } from '../lib/paths.js';
27
22
 
28
23
  async function fileExists(p) {
29
24
  try { await access(p); return true; } catch { return false; }
30
25
  }
31
26
 
32
27
  function isCuratorDivergent(rec) {
33
- // Shape 2/4: has verdict at top level, judgment is null
34
28
  return rec.judgment === null && typeof rec.verdict === 'string' && !rec.identity;
35
29
  }
36
30
 
37
31
  function isIdentityRecord(rec) {
38
- // Shape 3: has identity block — Grounded pipeline, DO NOT TOUCH
39
32
  return rec.identity != null;
40
33
  }
41
34
 
42
- async function migrateRecord(filePath) {
35
+ async function migrateRecord(filePath, GAME_ROOT, DRY_RUN) {
43
36
  const raw = await readFile(filePath, 'utf-8');
44
37
  const rec = JSON.parse(raw);
45
38
 
46
- // Skip identity records entirely — Grounded pipeline
47
39
  if (isIdentityRecord(rec)) return { file: basename(filePath), action: 'skip-identity' };
48
40
 
49
- // Skip records that are already in standard shape
50
41
  if (!isCuratorDivergent(rec)) {
51
- // Still check for stale asset_path on standard records
52
42
  if (rec.asset_path && rec.asset_path.includes('outputs/candidates/')) {
53
43
  const approvedPath = rec.asset_path.replace('outputs/candidates/', 'outputs/approved/');
54
44
  const fullApproved = join(GAME_ROOT, approvedPath);
@@ -61,10 +51,8 @@ async function migrateRecord(filePath) {
61
51
  return { file: basename(filePath), action: 'skip-standard' };
62
52
  }
63
53
 
64
- // Migrate curator-divergent record
65
54
  const changes = [];
66
55
 
67
- // Build judgment object from top-level fields
68
56
  rec.judgment = {
69
57
  status: rec.verdict || 'unknown',
70
58
  reviewer: rec.curator || 'wave25-script',
@@ -77,7 +65,6 @@ async function migrateRecord(filePath) {
77
65
  };
78
66
  changes.push(`verdict="${rec.verdict}" → judgment.status`);
79
67
 
80
- // Remove top-level divergent fields
81
68
  delete rec.verdict;
82
69
  delete rec.scores;
83
70
  delete rec.explanation;
@@ -86,7 +73,6 @@ async function migrateRecord(filePath) {
86
73
  delete rec.curator;
87
74
  changes.push('removed top-level verdict/scores/explanation/failures/curated_at/curator');
88
75
 
89
- // Fix asset_path if image moved to approved/
90
76
  if (rec.asset_path && rec.asset_path.includes('outputs/candidates/')) {
91
77
  const approvedPath = rec.asset_path.replace('outputs/candidates/', 'outputs/approved/');
92
78
  const fullApproved = join(GAME_ROOT, approvedPath);
@@ -105,7 +91,12 @@ async function migrateRecord(filePath) {
105
91
  return { file: basename(filePath), action: 'migrated', changes };
106
92
  }
107
93
 
108
- async function main() {
94
+ export async function run(argv = process.argv.slice(2)) {
95
+ const projectName = getProjectName(argv);
96
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
97
+ const RECORDS_DIR = join(GAME_ROOT, 'records');
98
+ const DRY_RUN = argv.includes('--dry-run');
99
+
109
100
  console.log(DRY_RUN ? '=== DRY RUN ===' : '=== APPLYING MIGRATION ===');
110
101
 
111
102
  const files = (await readdir(RECORDS_DIR)).filter(f => f.endsWith('.json'));
@@ -115,7 +106,7 @@ async function main() {
115
106
 
116
107
  for (const file of files) {
117
108
  try {
118
- const result = await migrateRecord(join(RECORDS_DIR, file));
109
+ const result = await migrateRecord(join(RECORDS_DIR, file), GAME_ROOT, DRY_RUN);
119
110
  switch (result.action) {
120
111
  case 'skip-standard': results.skipped++; break;
121
112
  case 'skip-identity': results.identity++; break;
@@ -144,4 +135,7 @@ async function main() {
144
135
  console.log(`Total: ${files.length}`);
145
136
  }
146
137
 
147
- main().catch(err => { console.error(err); process.exit(1); });
138
+ // Direct execution guard
139
+ if (process.argv[1] && (process.argv[1].endsWith('migrate-records.js') || process.argv[1].endsWith('migrate-records'))) {
140
+ run().catch(err => { console.error(err.message || err); process.exit(1); });
141
+ }
@@ -3,15 +3,17 @@
3
3
  /**
4
4
  * painterly-test.js — Test different denoise levels on a single image.
5
5
  * Outputs: outputs/painterly-test/<filename>_d<denoise>.png
6
+ *
7
+ * Usage:
8
+ * node scripts/painterly-test.js
9
+ * sdlab painterly:test --project star-freight
6
10
  */
7
11
 
8
- import { readFile, writeFile, mkdir } from "node:fs/promises";
12
+ import { writeFile, mkdir } from "node:fs/promises";
9
13
  import { join } from "node:path";
10
-
11
- const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
12
- const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
13
- const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
14
- const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
14
+ import { getProjectName } from "../lib/args.js";
15
+ import { REPO_ROOT } from "../lib/paths.js";
16
+ import { submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
15
17
 
16
18
  const PAINTERLY_PROMPT = [
17
19
  "oil painting, visible brushstrokes, painterly concept art,",
@@ -68,43 +70,11 @@ function buildWorkflow(imagePath, denoise, loraWeight, seed) {
68
70
  return nodes;
69
71
  }
70
72
 
71
- async function upload(filePath, filename) {
72
- const data = await readFile(filePath);
73
- const form = new FormData();
74
- form.append("image", new Blob([data], { type: "image/png" }), filename);
75
- form.append("overwrite", "true");
76
- const res = await fetch(`${COMFY_URL}/upload/image`, { method: "POST", body: form });
77
- if (!res.ok) throw new Error(`Upload failed: ${res.status} ${await res.text()}`);
78
- return (await res.json()).name;
79
- }
80
-
81
- const MAX_POLL_MS = 600_000; // 10 minutes
82
-
83
- async function run(workflow) {
84
- const res = await fetch(`${COMFY_URL}/prompt`, {
85
- method: "POST", headers: { "Content-Type": "application/json" },
86
- body: JSON.stringify({ prompt: workflow, client_id: `test-${Date.now()}` }),
87
- });
88
- if (!res.ok) throw new Error(`ComfyUI submit failed: ${res.status} ${await res.text()}`);
89
- const { prompt_id: promptId } = await res.json();
90
- const start = Date.now();
91
- while (true) {
92
- if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
93
- await new Promise(r => setTimeout(r, 1000));
94
- const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
95
- if (!histRes.ok) continue;
96
- const h = await histRes.json();
97
- if (h[promptId]?.status?.completed) return h[promptId];
98
- }
99
- }
100
-
101
- async function download(filename, subfolder) {
102
- const res = await fetch(`${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=output`);
103
- if (!res.ok) throw new Error(`Download failed: ${res.status}`);
104
- return Buffer.from(await res.arrayBuffer());
105
- }
73
+ export async function run(argv = process.argv.slice(2)) {
74
+ const projectName = getProjectName(argv);
75
+ const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
76
+ const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
106
77
 
107
- async function main() {
108
78
  const outDir = join(GAME_ROOT, "outputs/painterly-test");
109
79
  await mkdir(outDir, { recursive: true });
110
80
 
@@ -115,14 +85,14 @@ async function main() {
115
85
  ];
116
86
 
117
87
  const denoiseTests = [0.50, 0.60, 0.70];
118
- const loraWeight = 1.0; // Full LoRA weight for stronger effect
88
+ const loraWeight = 1.0;
119
89
 
120
90
  for (const testFile of testFiles) {
121
91
  const srcPath = join(GAME_ROOT, testFile);
122
92
  const fname = testFile.split("/").pop().replace(".png", "");
123
93
 
124
94
  console.log(`\nTesting: ${fname}`);
125
- const uploaded = await upload(srcPath, `test_${fname}.png`);
95
+ const uploaded = await uploadImage(srcPath, `test_${fname}.png`, COMFY_URL);
126
96
 
127
97
  for (const denoise of denoiseTests) {
128
98
  const label = `${fname}_d${String(denoise).replace(".", "")}`;
@@ -130,14 +100,14 @@ async function main() {
130
100
 
131
101
  const t = Date.now();
132
102
  const wf = buildWorkflow(uploaded, denoise, loraWeight, 42);
133
- const result = await run(wf);
103
+ const result = await submitAndWait(wf, COMFY_URL, { clientPrefix: 'test' });
134
104
 
135
105
  let imgFile, imgSub = "";
136
106
  for (const o of Object.values(result.outputs || {})) {
137
107
  if (o.images?.length) { imgFile = o.images[0].filename; imgSub = o.images[0].subfolder || ""; break; }
138
108
  }
139
109
 
140
- const data = await download(imgFile, imgSub);
110
+ const data = await downloadImage(imgFile, imgSub, COMFY_URL);
141
111
  await writeFile(join(outDir, `${label}.png`), data);
142
112
  console.log(` ✓ ${Date.now() - t}ms`);
143
113
  }
@@ -146,4 +116,7 @@ async function main() {
146
116
  console.log("\nDone — check outputs/painterly-test/");
147
117
  }
148
118
 
149
- main().catch(e => { console.error(e); process.exit(1); });
119
+ // Direct execution guard
120
+ if (process.argv[1] && (process.argv[1].endsWith('painterly-test.js') || process.argv[1].endsWith('painterly-test'))) {
121
+ run().catch(e => { console.error(e.message || e); process.exit(1); });
122
+ }