@mcptoolshop/style-dataset-lab 1.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.
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.es.md +142 -0
- package/README.fr.md +142 -0
- package/README.hi.md +142 -0
- package/README.it.md +142 -0
- package/README.ja.md +142 -0
- package/README.md +178 -0
- package/README.pt-BR.md +142 -0
- package/README.zh.md +142 -0
- package/SECURITY.md +28 -0
- package/package.json +45 -0
- package/scripts/bulk-curate-wave2-5.js +138 -0
- package/scripts/bulk-curate-waves11-18.js +413 -0
- package/scripts/canon-bind.js +437 -0
- package/scripts/compare.js +116 -0
- package/scripts/curate-wave25.js +209 -0
- package/scripts/curate.js +136 -0
- package/scripts/generate-controlnet.js +357 -0
- package/scripts/generate-identity.js +633 -0
- package/scripts/generate-ipadapter.js +339 -0
- package/scripts/generate.js +351 -0
- package/scripts/migrate-records.js +147 -0
- package/scripts/painterly-test.js +149 -0
- package/scripts/painterly.js +349 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Curation for wave 25 (expanded universe).
|
|
4
|
+
*
|
|
5
|
+
* Based on visual review of all 60 candidates:
|
|
6
|
+
* - REJECT25* subjects → rejected (anti-examples)
|
|
7
|
+
* - Strong hits → approved with high scores
|
|
8
|
+
* - Partial hits → approved with moderate scores + notes
|
|
9
|
+
* - Misses → rejected with explanations of what went wrong
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/curate-wave25.js
|
|
13
|
+
* node scripts/curate-wave25.js --dry-run
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, readdirSync, renameSync, existsSync, mkdirSync } from 'fs';
|
|
17
|
+
import { join, basename } from 'path';
|
|
18
|
+
|
|
19
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
20
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
21
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
22
|
+
|
|
23
|
+
const RECORDS_DIR = join(GAME_ROOT, 'records');
|
|
24
|
+
const CANDIDATES_DIR = join(GAME_ROOT, 'outputs', 'candidates');
|
|
25
|
+
const APPROVED_DIR = join(GAME_ROOT, 'outputs', 'approved');
|
|
26
|
+
const REJECTED_DIR = join(GAME_ROOT, 'outputs', 'rejected');
|
|
27
|
+
const DRY_RUN = process.argv.includes('--dry-run');
|
|
28
|
+
|
|
29
|
+
for (const dir of [APPROVED_DIR, REJECTED_DIR]) {
|
|
30
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Scoring profiles ──────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const STRONG_HIT = {
|
|
36
|
+
silhouette_clarity: 0.85, palette_adherence: 0.80, material_fidelity: 0.80,
|
|
37
|
+
faction_read: 0.75, wear_level: 0.85, style_consistency: 0.85,
|
|
38
|
+
clothing_logic: 0.70, composition: 0.85
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const PARTIAL_HIT = {
|
|
42
|
+
silhouette_clarity: 0.70, palette_adherence: 0.70, material_fidelity: 0.70,
|
|
43
|
+
faction_read: 0.50, wear_level: 0.75, style_consistency: 0.75,
|
|
44
|
+
clothing_logic: 0.55, composition: 0.75
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ─── Subject classifications ──────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
// Strong hits — approved with high scores
|
|
50
|
+
const STRONG_HITS = new Set([
|
|
51
|
+
'keth_molt_before', 'keth_molt_emerging', 'keth_hivesong', 'keth_pheromone_library',
|
|
52
|
+
'veshan_forge_trial', 'veshan_forge_trial_judgment',
|
|
53
|
+
'orryn_sealed_mantle', 'orryn_crystal_inheritance',
|
|
54
|
+
'thresh_young',
|
|
55
|
+
'mire_at_work',
|
|
56
|
+
'vaelk_proxy_market',
|
|
57
|
+
'architect_jump_gate', 'architect_archive_node', 'architect_ruin_surface',
|
|
58
|
+
'drift_kelp_bed',
|
|
59
|
+
'translator_rig'
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Partial hits — approved with moderate scores
|
|
63
|
+
const PARTIAL_HITS = new Set([
|
|
64
|
+
'veshan_battle_opera', 'veshan_scale_reader',
|
|
65
|
+
'orryn_drift_parliament', 'orryn_drift_parliament_failed',
|
|
66
|
+
'drift_kelp_ambush',
|
|
67
|
+
'vaelk_proxy_trade',
|
|
68
|
+
'station_climate_border',
|
|
69
|
+
'pidgin_trade_sign'
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// Misses — rejected with explanations
|
|
73
|
+
const MISS_EXPLANATIONS = {
|
|
74
|
+
thresh_old_station: {
|
|
75
|
+
explanation: "Thresh crystalline organism not visible — rendered as standard station corridor with human. Need to foreground the crystalline growth formation.",
|
|
76
|
+
failures: ["species_not_rendered", "concept_miss"],
|
|
77
|
+
scores: { silhouette_clarity: 0.6, palette_adherence: 0.5, material_fidelity: 0.6, faction_read: 0.1, wear_level: 0.7, style_consistency: 0.7, clothing_logic: 0.3, composition: 0.6 }
|
|
78
|
+
},
|
|
79
|
+
smuggler_cant_meeting: {
|
|
80
|
+
explanation: "All figures rendered as human — no multi-species element. The cross-species communication concept requires at least 2 different species visible.",
|
|
81
|
+
failures: ["species_not_rendered", "concept_miss"],
|
|
82
|
+
scores: { silhouette_clarity: 0.7, palette_adherence: 0.6, material_fidelity: 0.7, faction_read: 0.1, wear_level: 0.7, style_consistency: 0.7, clothing_logic: 0.5, composition: 0.7 }
|
|
83
|
+
},
|
|
84
|
+
mire_reforming: {
|
|
85
|
+
explanation: "Green-tinted corridor with organic matter but no clear colonial organism reforming. The aggregation process needs to be the focal point.",
|
|
86
|
+
failures: ["concept_miss"],
|
|
87
|
+
scores: { silhouette_clarity: 0.5, palette_adherence: 0.6, material_fidelity: 0.6, faction_read: 0.3, wear_level: 0.7, style_consistency: 0.7, clothing_logic: 0.3, composition: 0.6 }
|
|
88
|
+
},
|
|
89
|
+
mire_hull_breach: {
|
|
90
|
+
explanation: "Rendered as military cockpit scene — no Mire organism flowing through hull breach. Complete concept miss.",
|
|
91
|
+
failures: ["species_not_rendered", "concept_miss"],
|
|
92
|
+
scores: { silhouette_clarity: 0.6, palette_adherence: 0.4, material_fidelity: 0.5, faction_read: 0.0, wear_level: 0.6, style_consistency: 0.5, clothing_logic: 0.3, composition: 0.6 }
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Reject examples — always rejected
|
|
97
|
+
const REJECT_EXPLANATIONS = {
|
|
98
|
+
REJECT25_clean_alien: {
|
|
99
|
+
explanation: "Clean sterile alien civilization — Star Trek utopia aesthetic. Violates every grit principle.",
|
|
100
|
+
failures: ["too_clean", "generic_scifi"],
|
|
101
|
+
scores: { silhouette_clarity: 0.7, palette_adherence: 0.1, material_fidelity: 0.2, faction_read: 0.0, wear_level: 0.0, style_consistency: 0.3, clothing_logic: 0.2, composition: 0.7 }
|
|
102
|
+
},
|
|
103
|
+
REJECT25_fantasy_alien: {
|
|
104
|
+
explanation: "Fantasy wizard alien with robes and magic staff — high fantasy, not grounded sci-fi.",
|
|
105
|
+
failures: ["wrong_genre", "costume_not_clothes"],
|
|
106
|
+
scores: { silhouette_clarity: 0.6, palette_adherence: 0.1, material_fidelity: 0.2, faction_read: 0.0, wear_level: 0.1, style_consistency: 0.1, clothing_logic: 0.1, composition: 0.6 }
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ─── Process ──────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const wave25Files = readdirSync(CANDIDATES_DIR).filter(f =>
|
|
113
|
+
f.endsWith('.png') && readdirSync(RECORDS_DIR).some(r => {
|
|
114
|
+
try {
|
|
115
|
+
const rec = JSON.parse(readFileSync(join(RECORDS_DIR, r), 'utf-8'));
|
|
116
|
+
return rec.file === f && rec.lane === 'wave25-expanded-universe';
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Fallback: match by known subject IDs
|
|
122
|
+
const ALL_SUBJECTS = [
|
|
123
|
+
...STRONG_HITS, ...PARTIAL_HITS,
|
|
124
|
+
...Object.keys(MISS_EXPLANATIONS),
|
|
125
|
+
...Object.keys(REJECT_EXPLANATIONS)
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
// Find wave25 records directly by filename match
|
|
129
|
+
const recordFiles = readdirSync(RECORDS_DIR).filter(r => r.endsWith('.json'));
|
|
130
|
+
const candidates = recordFiles.filter(rf => {
|
|
131
|
+
return ALL_SUBJECTS.some(subj => rf.startsWith(subj));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
let approved = 0, rejected = 0, skipped = 0;
|
|
135
|
+
|
|
136
|
+
for (const recordFile of candidates) {
|
|
137
|
+
let record;
|
|
138
|
+
try {
|
|
139
|
+
record = JSON.parse(readFileSync(join(RECORDS_DIR, recordFile), 'utf-8'));
|
|
140
|
+
} catch { continue; }
|
|
141
|
+
|
|
142
|
+
const file = basename(record.asset_path || '');
|
|
143
|
+
const subjectId = record.id?.replace(/_v[12]$/, '') || file.replace(/_v[12]\.png$/, '');
|
|
144
|
+
|
|
145
|
+
// Already curated?
|
|
146
|
+
if (record.verdict) {
|
|
147
|
+
console.log(` ⏭ ${file} already curated (${record.verdict})`);
|
|
148
|
+
skipped++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let verdict, scores, explanation, failures;
|
|
153
|
+
|
|
154
|
+
if (REJECT_EXPLANATIONS[subjectId]) {
|
|
155
|
+
const r = REJECT_EXPLANATIONS[subjectId];
|
|
156
|
+
verdict = 'rejected';
|
|
157
|
+
scores = r.scores;
|
|
158
|
+
explanation = r.explanation;
|
|
159
|
+
failures = r.failures;
|
|
160
|
+
} else if (MISS_EXPLANATIONS[subjectId]) {
|
|
161
|
+
const m = MISS_EXPLANATIONS[subjectId];
|
|
162
|
+
verdict = 'rejected';
|
|
163
|
+
scores = m.scores;
|
|
164
|
+
explanation = m.explanation;
|
|
165
|
+
failures = m.failures;
|
|
166
|
+
} else if (STRONG_HITS.has(subjectId)) {
|
|
167
|
+
verdict = 'approved';
|
|
168
|
+
scores = { ...STRONG_HIT };
|
|
169
|
+
explanation = `Strong hit — concept reads clearly, style consistent, gritty lived-in aesthetic.`;
|
|
170
|
+
failures = [];
|
|
171
|
+
} else if (PARTIAL_HITS.has(subjectId)) {
|
|
172
|
+
verdict = 'approved';
|
|
173
|
+
scores = { ...PARTIAL_HIT };
|
|
174
|
+
explanation = `Partial hit — atmosphere and style correct, some species anatomy or concept details missed.`;
|
|
175
|
+
failures = ['partial_species_accuracy'];
|
|
176
|
+
} else {
|
|
177
|
+
console.log(` ⚠ Unknown subject ${subjectId} for ${file}, skipping`);
|
|
178
|
+
skipped++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Update record
|
|
183
|
+
record.verdict = verdict;
|
|
184
|
+
record.scores = scores;
|
|
185
|
+
record.explanation = explanation;
|
|
186
|
+
record.failures = failures;
|
|
187
|
+
record.curated_at = new Date().toISOString();
|
|
188
|
+
record.curator = 'wave25-script';
|
|
189
|
+
|
|
190
|
+
// Move file
|
|
191
|
+
const srcPath = join(CANDIDATES_DIR, file);
|
|
192
|
+
const dstDir = verdict === 'approved' ? APPROVED_DIR : REJECTED_DIR;
|
|
193
|
+
const dstPath = join(dstDir, file);
|
|
194
|
+
|
|
195
|
+
if (!DRY_RUN) {
|
|
196
|
+
writeFileSync(join(RECORDS_DIR, recordFile), JSON.stringify(record, null, 2));
|
|
197
|
+
if (existsSync(srcPath)) {
|
|
198
|
+
renameSync(srcPath, dstPath);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const icon = verdict === 'approved' ? '✓' : '✗';
|
|
203
|
+
console.log(` ${icon} ${file} → ${verdict}`);
|
|
204
|
+
if (verdict === 'approved') approved++;
|
|
205
|
+
else rejected++;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(`\nWave 25 curation: ${approved} approved, ${rejected} rejected, ${skipped} skipped`);
|
|
209
|
+
if (DRY_RUN) console.log(' (dry run — no files moved)');
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* curate.js — Move a candidate to approved/rejected/borderline and record judgment.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/curate.js <asset_id> approved "Clean silhouette, correct palette" --scores silhouette:0.9,palette:0.8
|
|
8
|
+
* node scripts/curate.js <asset_id> rejected "3D render look, alpha halo" --failures alpha_halo,3d_render_look
|
|
9
|
+
* node scripts/curate.js --list (show uncurated candidates)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile, writeFile, rename, readdir, mkdir } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
16
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
17
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
|
|
22
|
+
if (args.includes("--list")) {
|
|
23
|
+
return listUncurated();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const [assetId, status, explanation, ...rest] = args;
|
|
27
|
+
|
|
28
|
+
if (!assetId || !status || !explanation) {
|
|
29
|
+
console.log("Usage: node scripts/curate.js <asset_id> <approved|rejected|borderline> <explanation> [--scores k:v,...] [--failures f1,f2]");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!["approved", "rejected", "borderline"].includes(status)) {
|
|
34
|
+
console.error(`Invalid status: ${status}. Must be approved, rejected, or borderline.`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Parse optional flags
|
|
39
|
+
const scoresStr = getFlagValue(rest, "scores");
|
|
40
|
+
const failuresStr = getFlagValue(rest, "failures");
|
|
41
|
+
const notesStr = getFlagValue(rest, "notes");
|
|
42
|
+
|
|
43
|
+
const criteria_scores = {};
|
|
44
|
+
if (scoresStr) {
|
|
45
|
+
for (const pair of scoresStr.split(",")) {
|
|
46
|
+
const [k, v] = pair.split(":");
|
|
47
|
+
if (k && v) criteria_scores[k.trim()] = parseFloat(v);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const failure_modes = failuresStr ? failuresStr.split(",").map((s) => s.trim()) : [];
|
|
52
|
+
|
|
53
|
+
// Load existing record
|
|
54
|
+
const recordPath = join(GAME_ROOT, `records/${assetId}.json`);
|
|
55
|
+
let record;
|
|
56
|
+
try {
|
|
57
|
+
record = JSON.parse(await readFile(recordPath, "utf-8"));
|
|
58
|
+
} catch {
|
|
59
|
+
console.error(`Record not found: ${recordPath}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Update record first (write before move to avoid orphaned images)
|
|
64
|
+
const oldPath = join(GAME_ROOT, record.asset_path);
|
|
65
|
+
const newDir = `outputs/${status}`;
|
|
66
|
+
const newPath = `${newDir}/${assetId}.png`;
|
|
67
|
+
await mkdir(join(GAME_ROOT, newDir), { recursive: true });
|
|
68
|
+
|
|
69
|
+
record.asset_path = newPath;
|
|
70
|
+
record.judgment = {
|
|
71
|
+
status,
|
|
72
|
+
reviewer: "human:mike",
|
|
73
|
+
reviewed_at: new Date().toISOString(),
|
|
74
|
+
explanation,
|
|
75
|
+
criteria_scores,
|
|
76
|
+
failure_modes,
|
|
77
|
+
improvement_notes: notesStr || null,
|
|
78
|
+
confidence: 0.9,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await writeFile(recordPath, JSON.stringify(record, null, 2));
|
|
82
|
+
|
|
83
|
+
// Move image file
|
|
84
|
+
try {
|
|
85
|
+
await rename(oldPath, join(GAME_ROOT, newPath));
|
|
86
|
+
} catch {
|
|
87
|
+
console.error(`Could not move ${oldPath} to ${newPath}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`\x1b[32m✓\x1b[0m ${assetId} → ${status}`);
|
|
92
|
+
console.log(` ${explanation}`);
|
|
93
|
+
if (failure_modes.length) console.log(` Failures: ${failure_modes.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function listUncurated() {
|
|
97
|
+
const recordsDir = join(GAME_ROOT, "records");
|
|
98
|
+
let files;
|
|
99
|
+
try {
|
|
100
|
+
files = await readdir(recordsDir);
|
|
101
|
+
} catch {
|
|
102
|
+
console.log("No records directory.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let uncurated = 0;
|
|
107
|
+
let curated = 0;
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (!file.endsWith(".json")) continue;
|
|
111
|
+
const record = JSON.parse(await readFile(join(recordsDir, file), "utf-8"));
|
|
112
|
+
if (!record.judgment) {
|
|
113
|
+
console.log(` ${record.id}`);
|
|
114
|
+
uncurated++;
|
|
115
|
+
} else {
|
|
116
|
+
curated++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`\n${uncurated} uncurated, ${curated} curated`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getFlagValue(args, flag) {
|
|
124
|
+
const prefix = `--${flag}=`;
|
|
125
|
+
for (const a of args) {
|
|
126
|
+
if (a.startsWith(prefix)) return a.slice(prefix.length);
|
|
127
|
+
}
|
|
128
|
+
const idx = args.indexOf(`--${flag}`);
|
|
129
|
+
if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main().catch((err) => {
|
|
134
|
+
console.error(err);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* generate-controlnet.js — ControlNet-guided discovery for structural subjects.
|
|
5
|
+
*
|
|
6
|
+
* For subjects where txt2img fails on anatomy (Naia) or scale (Corrigan),
|
|
7
|
+
* this script uses a structural guide image with ControlNet to force
|
|
8
|
+
* the body plan / hull shape, then lets SDXL fill surface language.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/generate-controlnet.js --subject naia --guide inputs/control-guides/naia_structural_guide.png --seeds 4
|
|
12
|
+
* node scripts/generate-controlnet.js --subject corrigan --guide inputs/control-guides/corrigan_structural_guide.png --seeds 4
|
|
13
|
+
* node scripts/generate-controlnet.js --subject naia --guide ... --dry-run
|
|
14
|
+
*
|
|
15
|
+
* Options:
|
|
16
|
+
* --subject NAME Subject name (used for output file naming)
|
|
17
|
+
* --guide PATH Path to structural guide image (white-on-black silhouette)
|
|
18
|
+
* --prompt TEXT Generation prompt (or reads from --prompt-file)
|
|
19
|
+
* --prompt-file F JSON file with {prompt, negative} fields
|
|
20
|
+
* --negative TEXT Negative prompt override
|
|
21
|
+
* --seeds N Number of seeds (default: 4)
|
|
22
|
+
* --weight W ControlNet weight (default: 0.70)
|
|
23
|
+
* --guidance-end E ControlNet guidance end (default: 0.65)
|
|
24
|
+
* --model M ControlNet model filename (default: controlnet-canny-sdxl-1.0.safetensors)
|
|
25
|
+
* --dry-run Print workflow without running
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
|
|
31
|
+
const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
|
|
32
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
33
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
34
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
35
|
+
|
|
36
|
+
const DEFAULTS = {
|
|
37
|
+
checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
|
|
38
|
+
loras: [{ name: "classipeintxl_v21.safetensors", weight: 1.0 }],
|
|
39
|
+
steps: 12,
|
|
40
|
+
cfg: 2.5,
|
|
41
|
+
sampler: "dpmpp_sde",
|
|
42
|
+
scheduler: "karras",
|
|
43
|
+
width: 1024,
|
|
44
|
+
height: 1024,
|
|
45
|
+
base_seed: 27100,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function parseArgs() {
|
|
49
|
+
const args = process.argv.slice(2);
|
|
50
|
+
const get = (flag, def) => {
|
|
51
|
+
const i = args.indexOf(flag);
|
|
52
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : def;
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
subject: get("--subject", "unknown"),
|
|
56
|
+
guidePath: get("--guide", null),
|
|
57
|
+
prompt: get("--prompt", null),
|
|
58
|
+
promptFile: get("--prompt-file", null),
|
|
59
|
+
negative: get("--negative", null),
|
|
60
|
+
seeds: parseInt(get("--seeds", "4"), 10),
|
|
61
|
+
weight: parseFloat(get("--weight", "0.70")),
|
|
62
|
+
guidanceEnd: parseFloat(get("--guidance-end", "0.65")),
|
|
63
|
+
controlModel: get("--model", "controlnet-canny-sdxl-1.0.safetensors"),
|
|
64
|
+
dryRun: args.includes("--dry-run"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildControlNetWorkflow(prompt, negativePrompt, seed, guideImagePath, controlModel, weight, guidanceEnd) {
|
|
69
|
+
const nodes = {};
|
|
70
|
+
let nextId = 1;
|
|
71
|
+
|
|
72
|
+
// Checkpoint
|
|
73
|
+
const ckptId = String(nextId++);
|
|
74
|
+
nodes[ckptId] = {
|
|
75
|
+
class_type: "CheckpointLoaderSimple",
|
|
76
|
+
inputs: { ckpt_name: DEFAULTS.checkpoint },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let modelOut = [ckptId, 0];
|
|
80
|
+
let clipOut = [ckptId, 1];
|
|
81
|
+
|
|
82
|
+
// LoRA
|
|
83
|
+
for (const lora of DEFAULTS.loras) {
|
|
84
|
+
const loraId = String(nextId++);
|
|
85
|
+
nodes[loraId] = {
|
|
86
|
+
class_type: "LoraLoader",
|
|
87
|
+
inputs: {
|
|
88
|
+
model: modelOut,
|
|
89
|
+
clip: clipOut,
|
|
90
|
+
lora_name: lora.name,
|
|
91
|
+
strength_model: lora.weight,
|
|
92
|
+
strength_clip: lora.weight,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
modelOut = [loraId, 0];
|
|
96
|
+
clipOut = [loraId, 1];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Load guide image
|
|
100
|
+
const loadGuideId = String(nextId++);
|
|
101
|
+
nodes[loadGuideId] = {
|
|
102
|
+
class_type: "LoadImage",
|
|
103
|
+
inputs: { image: guideImagePath },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Load ControlNet model
|
|
107
|
+
const cnLoadId = String(nextId++);
|
|
108
|
+
nodes[cnLoadId] = {
|
|
109
|
+
class_type: "ControlNetLoader",
|
|
110
|
+
inputs: { control_net_name: controlModel },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Positive prompt
|
|
114
|
+
const posId = String(nextId++);
|
|
115
|
+
nodes[posId] = {
|
|
116
|
+
class_type: "CLIPTextEncode",
|
|
117
|
+
inputs: { text: prompt, clip: clipOut },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Negative prompt
|
|
121
|
+
const negId = String(nextId++);
|
|
122
|
+
nodes[negId] = {
|
|
123
|
+
class_type: "CLIPTextEncode",
|
|
124
|
+
inputs: { text: negativePrompt, clip: clipOut },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Apply ControlNet (Advanced for start/end control)
|
|
128
|
+
const cnApplyId = String(nextId++);
|
|
129
|
+
nodes[cnApplyId] = {
|
|
130
|
+
class_type: "ControlNetApplyAdvanced",
|
|
131
|
+
inputs: {
|
|
132
|
+
positive: [posId, 0],
|
|
133
|
+
negative: [negId, 0],
|
|
134
|
+
control_net: [cnLoadId, 0],
|
|
135
|
+
image: [loadGuideId, 0],
|
|
136
|
+
strength: weight,
|
|
137
|
+
start_percent: 0.0,
|
|
138
|
+
end_percent: guidanceEnd,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Empty latent
|
|
143
|
+
const latentId = String(nextId++);
|
|
144
|
+
nodes[latentId] = {
|
|
145
|
+
class_type: "EmptyLatentImage",
|
|
146
|
+
inputs: { width: DEFAULTS.width, height: DEFAULTS.height, batch_size: 1 },
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// KSampler
|
|
150
|
+
const samplerId = String(nextId++);
|
|
151
|
+
nodes[samplerId] = {
|
|
152
|
+
class_type: "KSampler",
|
|
153
|
+
inputs: {
|
|
154
|
+
model: modelOut,
|
|
155
|
+
positive: [cnApplyId, 0],
|
|
156
|
+
negative: [cnApplyId, 1],
|
|
157
|
+
latent_image: [latentId, 0],
|
|
158
|
+
seed,
|
|
159
|
+
steps: DEFAULTS.steps,
|
|
160
|
+
cfg: DEFAULTS.cfg,
|
|
161
|
+
sampler_name: DEFAULTS.sampler,
|
|
162
|
+
scheduler: DEFAULTS.scheduler,
|
|
163
|
+
denoise: 1.0,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// VAE Decode
|
|
168
|
+
const vaeId = String(nextId++);
|
|
169
|
+
nodes[vaeId] = {
|
|
170
|
+
class_type: "VAEDecode",
|
|
171
|
+
inputs: { samples: [samplerId, 0], vae: [ckptId, 2] },
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Save
|
|
175
|
+
const saveId = String(nextId++);
|
|
176
|
+
nodes[saveId] = {
|
|
177
|
+
class_type: "SaveImage",
|
|
178
|
+
inputs: { images: [vaeId, 0], filename_prefix: "cn" },
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return nodes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function comfyHealth() {
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(`${COMFY_URL}/system_stats`);
|
|
187
|
+
return res.ok;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const MAX_POLL_MS = 600_000; // 10 minutes
|
|
194
|
+
|
|
195
|
+
async function submitAndWait(workflow) {
|
|
196
|
+
const clientId = `cn-${Date.now()}`;
|
|
197
|
+
const res = await fetch(`${COMFY_URL}/prompt`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const text = await res.text();
|
|
205
|
+
throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { prompt_id: promptId } = await res.json();
|
|
209
|
+
|
|
210
|
+
const start = Date.now();
|
|
211
|
+
while (true) {
|
|
212
|
+
if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
|
|
213
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
214
|
+
const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
|
|
215
|
+
if (!histRes.ok) continue;
|
|
216
|
+
const history = await histRes.json();
|
|
217
|
+
const entry = history[promptId];
|
|
218
|
+
if (!entry) continue;
|
|
219
|
+
if (entry.status?.completed) return entry;
|
|
220
|
+
if (entry.status?.status_str === "error") {
|
|
221
|
+
throw new Error(`ComfyUI error: ${JSON.stringify(entry.status)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function downloadImage(filename, subfolder, type = "output") {
|
|
227
|
+
const url = `${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=${type}`;
|
|
228
|
+
const res = await fetch(url);
|
|
229
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
230
|
+
return Buffer.from(await res.arrayBuffer());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function main() {
|
|
234
|
+
const opts = parseArgs();
|
|
235
|
+
|
|
236
|
+
if (!opts.guidePath) {
|
|
237
|
+
console.error("Error: --guide <path> is required");
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Resolve prompt
|
|
242
|
+
let prompt = opts.prompt;
|
|
243
|
+
let negative = opts.negative;
|
|
244
|
+
|
|
245
|
+
if (opts.promptFile && !prompt) {
|
|
246
|
+
const pf = JSON.parse(await readFile(join(GAME_ROOT, opts.promptFile), "utf-8"));
|
|
247
|
+
prompt = pf.prompt;
|
|
248
|
+
negative = negative || pf.negative;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!prompt) {
|
|
252
|
+
console.error("Error: --prompt or --prompt-file required");
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!negative) {
|
|
257
|
+
negative = "photorealistic, photograph, 3d render, smooth CG, anime, cartoon, text, watermark, blurry, low quality";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log("\x1b[1mstyle-dataset-lab\x1b[0m ControlNet structural discovery");
|
|
261
|
+
console.log(` Subject: ${opts.subject}`);
|
|
262
|
+
console.log(` Guide: ${opts.guidePath}`);
|
|
263
|
+
console.log(` ControlNet model: ${opts.controlModel}`);
|
|
264
|
+
console.log(` Weight: ${opts.weight}, Guidance end: ${opts.guidanceEnd}`);
|
|
265
|
+
console.log(` Seeds: ${opts.seeds}`);
|
|
266
|
+
console.log(` Steps: ${DEFAULTS.steps}, CFG: ${DEFAULTS.cfg}`);
|
|
267
|
+
console.log("");
|
|
268
|
+
|
|
269
|
+
if (!opts.dryRun) {
|
|
270
|
+
const online = await comfyHealth();
|
|
271
|
+
if (!online) {
|
|
272
|
+
console.error("\x1b[31mError:\x1b[0m ComfyUI not reachable at " + COMFY_URL);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
console.log("\x1b[32m+\x1b[0m ComfyUI online");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The guide image needs to be in ComfyUI's input directory
|
|
279
|
+
// Copy it there
|
|
280
|
+
const guideFullPath = join(GAME_ROOT, opts.guidePath);
|
|
281
|
+
const guideData = await readFile(guideFullPath);
|
|
282
|
+
const guideFilename = `cn_guide_${opts.subject}.png`;
|
|
283
|
+
|
|
284
|
+
if (!opts.dryRun) {
|
|
285
|
+
// Upload via ComfyUI upload endpoint
|
|
286
|
+
const formData = new FormData();
|
|
287
|
+
formData.append("image", new Blob([guideData], { type: "image/png" }), guideFilename);
|
|
288
|
+
formData.append("overwrite", "true");
|
|
289
|
+
|
|
290
|
+
const uploadRes = await fetch(`${COMFY_URL}/upload/image`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
body: formData,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (!uploadRes.ok) {
|
|
296
|
+
const text = await uploadRes.text();
|
|
297
|
+
console.error(`Guide upload failed: ${uploadRes.status} ${text}`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
const uploadResult = await uploadRes.json();
|
|
301
|
+
console.log(`\x1b[32m+\x1b[0m Guide uploaded: ${uploadResult.name}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
|
|
305
|
+
|
|
306
|
+
for (let si = 0; si < opts.seeds; si++) {
|
|
307
|
+
const seed = DEFAULTS.base_seed + si;
|
|
308
|
+
const assetId = `${opts.subject}_cn_s${si}`;
|
|
309
|
+
const destPath = `outputs/candidates/${assetId}.png`;
|
|
310
|
+
|
|
311
|
+
console.log(` [${si + 1}/${opts.seeds}] ${assetId} (seed: ${seed})`);
|
|
312
|
+
|
|
313
|
+
if (opts.dryRun) continue;
|
|
314
|
+
|
|
315
|
+
const startMs = Date.now();
|
|
316
|
+
const workflow = buildControlNetWorkflow(
|
|
317
|
+
prompt, negative, seed,
|
|
318
|
+
guideFilename, opts.controlModel,
|
|
319
|
+
opts.weight, opts.guidanceEnd
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const result = await submitAndWait(workflow);
|
|
324
|
+
const elapsed = Date.now() - startMs;
|
|
325
|
+
|
|
326
|
+
const outputs = result.outputs || {};
|
|
327
|
+
let imageFile = null;
|
|
328
|
+
let imageSubfolder = "";
|
|
329
|
+
for (const nodeOut of Object.values(outputs)) {
|
|
330
|
+
if (nodeOut.images?.length > 0) {
|
|
331
|
+
imageFile = nodeOut.images[0].filename;
|
|
332
|
+
imageSubfolder = nodeOut.images[0].subfolder || "";
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!imageFile) {
|
|
338
|
+
console.log(" \x1b[33m!\x1b[0m No output image");
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const imgData = await downloadImage(imageFile, imageSubfolder);
|
|
343
|
+
await writeFile(join(GAME_ROOT, destPath), imgData);
|
|
344
|
+
console.log(` \x1b[32m+\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.log(` \x1b[31mx\x1b[0m ${err.message}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
console.log("\n\x1b[32m+\x1b[0m ControlNet discovery complete");
|
|
351
|
+
if (opts.dryRun) console.log(" (dry run)");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
main().catch((err) => {
|
|
355
|
+
console.error(err);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
});
|