@mcptoolshop/style-dataset-lab 3.0.1 → 3.2.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 (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.es.md +154 -41
  3. package/README.fr.md +153 -40
  4. package/README.hi.md +160 -47
  5. package/README.it.md +155 -42
  6. package/README.ja.md +159 -46
  7. package/README.md +40 -0
  8. package/README.pt-BR.md +154 -41
  9. package/README.zh.md +154 -41
  10. package/bin/sdlab.js +81 -2
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-runner.js +11 -15
  13. package/lib/adapters/comfyui-workflows.js +133 -2
  14. package/lib/adapters/diffusers-lora.js +46 -66
  15. package/lib/adapters/generic-image-caption.js +22 -50
  16. package/lib/batch-runs.js +62 -0
  17. package/lib/canon-build/build.js +450 -0
  18. package/lib/canon-build/emitters/base.js +122 -0
  19. package/lib/canon-build/emitters/character.js +167 -0
  20. package/lib/canon-build/emitters/deity.js +165 -0
  21. package/lib/canon-build/emitters/faction.js +161 -0
  22. package/lib/canon-build/emitters/index.js +38 -0
  23. package/lib/canon-build/emitters/location.js +153 -0
  24. package/lib/canon-build/emitters/monster.js +131 -0
  25. package/lib/canon-build/emitters/relic.js +142 -0
  26. package/lib/canon-build/emitters/ship.js +154 -0
  27. package/lib/canon-build/emitters/species.js +165 -0
  28. package/lib/canon-build/frontmatter.js +76 -0
  29. package/lib/canon-build/lane-resolve.js +64 -0
  30. package/lib/canon-build/load-config.js +117 -0
  31. package/lib/canon-build/load-entry.js +62 -0
  32. package/lib/canon-build/load-schema.js +46 -0
  33. package/lib/captions.js +197 -0
  34. package/lib/comfyui-output.js +70 -0
  35. package/lib/config.js +19 -0
  36. package/lib/freeze-events.js +109 -0
  37. package/lib/freeze-gate.js +165 -0
  38. package/lib/freeze-stamp.js +161 -0
  39. package/lib/rows.js +228 -0
  40. package/lib/training-adapters.js +84 -0
  41. package/lib/training-packages.js +25 -32
  42. package/lib/training-profiles.js +43 -0
  43. package/package.json +29 -2
  44. package/scripts/_batch_cut.py +71 -0
  45. package/scripts/_seam_cutout.py +74 -0
  46. package/scripts/batch-generate.js +56 -13
  47. package/scripts/canon-build.js +88 -0
  48. package/scripts/canon-drift.js +193 -0
  49. package/scripts/canon-freeze-status.js +85 -0
  50. package/scripts/canon-freeze.js +211 -0
  51. package/scripts/canon-unfreeze.js +137 -0
  52. package/scripts/curate.js +12 -0
  53. package/scripts/generate-controlnet.js +12 -0
  54. package/scripts/generate-identity.js +20 -4
  55. package/scripts/generate-ipadapter.js +11 -0
  56. package/scripts/generate.js +250 -102
  57. package/scripts/qwen_generate.py +124 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Lane resolution for canon entries (D3 + D9).
3
+ *
4
+ * The build config declares, per schema, how to resolve an entry's lane:
5
+ * - { source: "constant", value: "creature" } → always that value
6
+ * - { source: "field", field: "visual.art_lane" } → read that dotted path
7
+ * from the entry's frontmatter
8
+ *
9
+ * Unmapped entries throw a structured error so an operator can fix the
10
+ * source of truth rather than letting the build silently drop a row.
11
+ */
12
+
13
+ import { inputError } from '../errors.js';
14
+ import { getByPath } from './load-config.js';
15
+
16
+ /**
17
+ * Resolve the lane for a single entry.
18
+ *
19
+ * @param {Object} entry — loaded canon entry ({ frontmatter, filePath, ... })
20
+ * @param {string} schemaName — e.g. "monster.schema.json"
21
+ * @param {Object} schemaToLane — config.schema_to_lane rule object
22
+ * @returns {string} resolved lane
23
+ * @throws SdlabError CANON_LANE_UNMAPPED when resolution fails
24
+ */
25
+ export function resolveLane(entry, schemaName, schemaToLane) {
26
+ const rule = schemaToLane[schemaName];
27
+ if (!rule) {
28
+ throw inputError(
29
+ 'CANON_LANE_UNMAPPED',
30
+ `canon-build config has no schema_to_lane rule for "${schemaName}" (entry ${entry.filePath})`,
31
+ `Add a rule to canon-build/config.json — e.g. { "source": "field", "field": "visual.art_lane" } or { "source": "constant", "value": "<lane>" }.`,
32
+ );
33
+ }
34
+
35
+ if (rule.source === 'constant') {
36
+ return rule.value;
37
+ }
38
+
39
+ if (rule.source === 'field') {
40
+ const value = getByPath(entry.frontmatter, rule.field);
41
+ if (value === undefined || value === null || value === '') {
42
+ throw inputError(
43
+ 'CANON_LANE_UNMAPPED',
44
+ `entry ${entry.filePath} has no value at "${rule.field}" (required to resolve lane for schema "${schemaName}")`,
45
+ `Add ${rule.field} to the entry's frontmatter, or switch the config rule to source: "constant" if the schema should use a fixed lane.`,
46
+ );
47
+ }
48
+ if (typeof value !== 'string') {
49
+ throw inputError(
50
+ 'CANON_LANE_UNMAPPED',
51
+ `entry ${entry.filePath} value at "${rule.field}" is not a string (got ${typeof value})`,
52
+ 'Lane resolution expects a string. Check the schema definition for this field.',
53
+ );
54
+ }
55
+ return value;
56
+ }
57
+
58
+ // load-config.js already rejects unknown sources; this is defense-in-depth.
59
+ throw inputError(
60
+ 'CANON_LANE_UNMAPPED',
61
+ `schema_to_lane rule for "${schemaName}" has unsupported source "${rule.source}"`,
62
+ 'Valid sources: "field" or "constant".',
63
+ );
64
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * canon-build/config.json loader + validator.
3
+ *
4
+ * The build config declares:
5
+ * - canon_root: absolute (or project-relative) path to the canon directory
6
+ * - schema_dir: absolute path to the schemas directory
7
+ * - entity_dirs: schema filename → subdirectory under canon_root
8
+ * - schema_to_lane: per-schema lane resolution (D3 / D9)
9
+ * - context_limits: per-schema max_lines (default 300)
10
+ * - profile_id: training profile id that supplies trigger + caption strategy
11
+ * (canon-build delegates to captions.js just like the adapters do).
12
+ * - freeze_watch_defaults: per-schema default watch_fields for the freeze
13
+ * witness chain (D5). Consumed by lib/freeze-stamp.js:resolveWatchFields.
14
+ */
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import { isAbsolute, resolve } from 'node:path';
18
+ import { existsSync } from 'node:fs';
19
+ import { inputError } from '../errors.js';
20
+
21
+ /**
22
+ * Load and validate a canon-build config.
23
+ *
24
+ * @param {string} configPath — absolute path to canon-build/config.json
25
+ * @param {string} projectRoot — for resolving project-relative paths
26
+ */
27
+ export async function loadBuildConfig(configPath, projectRoot) {
28
+ if (!existsSync(configPath)) {
29
+ throw inputError(
30
+ 'CANON_BUILD_CONFIG_NOT_FOUND',
31
+ `canon-build config not found at ${configPath}`,
32
+ `Create the file with a project_id, canon_root, schema_dir, entity_dirs, and schema_to_lane map. See the handbook page "Canon Build" for the schema.`,
33
+ );
34
+ }
35
+
36
+ const raw = await readFile(configPath, 'utf-8');
37
+ let config;
38
+ try {
39
+ config = JSON.parse(raw);
40
+ } catch (err) {
41
+ throw inputError(
42
+ 'CANON_BUILD_CONFIG_INVALID_JSON',
43
+ `canon-build config at ${configPath} is not valid JSON (${err.message})`,
44
+ 'Fix the JSON syntax; this file is the lane-resolution source of truth for the build.',
45
+ );
46
+ }
47
+
48
+ const errors = [];
49
+
50
+ if (!config.project_id) errors.push('missing required field: project_id');
51
+ if (!config.canon_root) errors.push('missing required field: canon_root');
52
+ if (!config.schema_dir) errors.push('missing required field: schema_dir');
53
+ if (!config.entity_dirs || typeof config.entity_dirs !== 'object') {
54
+ errors.push('missing or invalid entity_dirs (expected object mapping schema filename to subdirectory)');
55
+ }
56
+ if (!config.schema_to_lane || typeof config.schema_to_lane !== 'object') {
57
+ errors.push('missing or invalid schema_to_lane (expected object)');
58
+ } else {
59
+ for (const [schemaName, rule] of Object.entries(config.schema_to_lane)) {
60
+ if (!rule || typeof rule !== 'object') {
61
+ errors.push(`schema_to_lane["${schemaName}"] must be an object`);
62
+ continue;
63
+ }
64
+ if (rule.source === 'field') {
65
+ if (!rule.field || typeof rule.field !== 'string') {
66
+ errors.push(`schema_to_lane["${schemaName}"].source="field" requires a "field" string (dot-path)`);
67
+ }
68
+ } else if (rule.source === 'constant') {
69
+ if (!rule.value || typeof rule.value !== 'string') {
70
+ errors.push(`schema_to_lane["${schemaName}"].source="constant" requires a "value" string`);
71
+ }
72
+ } else {
73
+ errors.push(`schema_to_lane["${schemaName}"].source must be "field" or "constant" (got "${rule.source}")`);
74
+ }
75
+ }
76
+ }
77
+
78
+ if (errors.length) {
79
+ throw inputError(
80
+ 'CANON_BUILD_CONFIG_INVALID',
81
+ `canon-build config at ${configPath} is invalid:\n ${errors.join('\n ')}`,
82
+ 'Fix the listed fields and retry.',
83
+ );
84
+ }
85
+
86
+ // Resolve relative paths against the project root.
87
+ const canonRoot = isAbsolute(config.canon_root) ? config.canon_root : resolve(projectRoot, config.canon_root);
88
+ const schemaDir = isAbsolute(config.schema_dir) ? config.schema_dir : resolve(projectRoot, config.schema_dir);
89
+
90
+ return {
91
+ project_id: config.project_id,
92
+ canon_root: canonRoot,
93
+ schema_dir: schemaDir,
94
+ entity_dirs: config.entity_dirs,
95
+ schema_to_lane: config.schema_to_lane,
96
+ context_limits: config.context_limits || {},
97
+ profile_id: config.profile_id || null,
98
+ freeze_watch_defaults: config.freeze_watch_defaults || {},
99
+ // Preserve raw text for hashing — any change to the config invalidates cache.
100
+ raw_text: raw,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Read a nested value from an object via a dot-path (e.g. "visual.art_lane").
106
+ * Returns undefined if any segment is missing.
107
+ */
108
+ export function getByPath(obj, path) {
109
+ if (!obj || !path) return undefined;
110
+ const parts = path.split('.');
111
+ let cur = obj;
112
+ for (const p of parts) {
113
+ if (cur == null || typeof cur !== 'object') return undefined;
114
+ cur = cur[p];
115
+ }
116
+ return cur;
117
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Canon entry loader.
3
+ *
4
+ * Reads a canon MD file, splits frontmatter from body, returns a normalized
5
+ * entry object. Validation against the schema happens later in the
6
+ * orchestrator (after the schema has been loaded).
7
+ */
8
+
9
+ import { readFile, readdir } from 'node:fs/promises';
10
+ import { join, extname, basename } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { parseFrontmatter } from './frontmatter.js';
13
+
14
+ /**
15
+ * Load a single canon entry file.
16
+ *
17
+ * @param {string} filePath — absolute path to a .md file
18
+ * @returns {Promise<{ frontmatter: Object, body: string, rawYaml: string, rawText: string, filePath: string }>}
19
+ */
20
+ export async function loadCanonEntry(filePath) {
21
+ const text = await readFile(filePath, 'utf-8');
22
+ const parsed = parseFrontmatter(text, filePath);
23
+ return {
24
+ frontmatter: parsed.frontmatter,
25
+ body: parsed.body,
26
+ rawYaml: parsed.rawYaml,
27
+ rawText: text,
28
+ filePath,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Load all canon entries in a directory.
34
+ *
35
+ * Non-.md files are skipped; .md files that fail to parse throw with the
36
+ * filename included so the operator can fix the offending entry.
37
+ *
38
+ * @param {string} dir — absolute path to a canon-type directory
39
+ * @returns {Promise<Array<{frontmatter, body, rawYaml, rawText, filePath}>>}
40
+ */
41
+ export async function loadCanonEntriesInDir(dir) {
42
+ if (!existsSync(dir)) return [];
43
+ const files = await readdir(dir);
44
+ const entries = [];
45
+ for (const file of files.sort()) {
46
+ if (extname(file) !== '.md') continue;
47
+ const filePath = join(dir, file);
48
+ const entry = await loadCanonEntry(filePath);
49
+ entries.push(entry);
50
+ }
51
+ return entries;
52
+ }
53
+
54
+ /**
55
+ * Derive an entity id from a canon entry.
56
+ * Prefers frontmatter.id; falls back to filename stem for entries that
57
+ * haven't added the id field yet (validation will reject those downstream).
58
+ */
59
+ export function entryId(entry) {
60
+ if (entry.frontmatter?.id) return entry.frontmatter.id;
61
+ return basename(entry.filePath, '.md');
62
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Schema loader for canon-build.
3
+ *
4
+ * Reads a JSON Schema file and returns { schema, version }.
5
+ *
6
+ * `version` resolution (per post-research D7):
7
+ * 1. If the schema's top-level `version` field is a string, use it.
8
+ * 2. Otherwise fall back to SHA-256 of the schema file contents, prefixed
9
+ * with `content-sha:`. Any textual change (including whitespace)
10
+ * invalidates the fallback. Documented as a defensive bridge for
11
+ * projects whose schemas haven't been version-stamped yet.
12
+ */
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import { createHash } from 'node:crypto';
16
+ import { inputError } from '../errors.js';
17
+
18
+ /**
19
+ * Load and resolve a single schema file.
20
+ *
21
+ * @param {string} schemaPath — absolute path to <name>.schema.json
22
+ * @returns {Promise<{ schema: Object, version: string, fileBytes: Buffer }>}
23
+ */
24
+ export async function loadSchema(schemaPath) {
25
+ const buf = await readFile(schemaPath);
26
+ let schema;
27
+ try {
28
+ schema = JSON.parse(buf.toString('utf-8'));
29
+ } catch (err) {
30
+ throw inputError(
31
+ 'CANON_SCHEMA_INVALID_JSON',
32
+ `Schema file is not valid JSON: ${schemaPath} (${err.message})`,
33
+ 'Validate the schema file with a JSON linter; malformed schemas cannot be used to validate canon entries.',
34
+ );
35
+ }
36
+
37
+ let version;
38
+ if (typeof schema.version === 'string' && schema.version.length > 0) {
39
+ version = schema.version;
40
+ } else {
41
+ const hash = createHash('sha256').update(buf).digest('hex');
42
+ version = `content-sha:${hash}`;
43
+ }
44
+
45
+ return { schema, version, fileBytes: buf };
46
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Caption building — shared between training adapters.
3
+ *
4
+ * Research-backed strategies for producing LoRA-training captions from
5
+ * approved canon records. Previously, both adapters carried near-duplicate
6
+ * buildCaption stubs that spliced `provenance.prompt` into the caption —
7
+ * a prompt-bleed antipattern that bakes generation-time style vocabulary
8
+ * (e.g. "oil painting, semi-realistic painterly, muted dusty palette...")
9
+ * back into the trigger token and corrupts downstream training.
10
+ *
11
+ * Strategies (from `profile.caption_strategy`):
12
+ * - 'legacy' (default when unset): preserves prior behavior exactly —
13
+ * trigger, group, lane, provenance.prompt[0..200]. Kept so existing
14
+ * training manifests reproduce bit-identical.
15
+ * - 'structured-metadata': trigger, faction, group, lane,
16
+ * judgment.explanation. No generation prompt. Research-backed for
17
+ * SDXL-style LoRAs where the trigger should absorb the style and
18
+ * captions describe scene content as comma-separated tags.
19
+ * - 'flux-natural-language': trigger + subject noun phrase + scene as
20
+ * a natural-language description. Recommended when
21
+ * `profile.target_family === 'flux'` because Flux's T5 text encoder
22
+ * holds identity across natural-language prompts much better than
23
+ * SDXL's CLIP does on comma-separated tags. See research notes.
24
+ *
25
+ * References:
26
+ * - Apatero LoRA Best Practices 2025 — trigger-first tag ordering
27
+ * - alvdansen HF blog — caption format variety
28
+ * - Ye et al. 2025 (arXiv 2510.09475) — multi-token DreamBooth+LoRA
29
+ * - Pelayo Arbues — Flux caption template (NL + scene variations)
30
+ * - Civitai Flux Character Caption Diary (article 6868) — natural
31
+ * language outperforms booru-style tags on Flux
32
+ */
33
+
34
+ /**
35
+ * Derive the style trigger token from a profile.
36
+ *
37
+ * If `profile.trigger_override` is a non-empty string, use it verbatim.
38
+ * Otherwise fall back to the hyphenated-id derivation:
39
+ * `character-style-lora` → `character_style_lora`.
40
+ *
41
+ * `trigger_override` exists so profile IDs can stay descriptive (e.g.
42
+ * `character-style-lora-flux`) while triggers stay compact, spec-compliant
43
+ * (`^[a-z0-9_]+$`), and game-prefixed. Validation of the override value
44
+ * (format + generic-suffix deny-list) lives in training-profiles.js so
45
+ * captions are never the gate for a malformed trigger.
46
+ *
47
+ * @param {{profile_id?: string, trigger_override?: string}} profile
48
+ * @returns {string|null}
49
+ */
50
+ export function deriveStyleTrigger(profile) {
51
+ if (!profile) return null;
52
+ if (typeof profile.trigger_override === 'string' && profile.trigger_override.length > 0) {
53
+ return profile.trigger_override;
54
+ }
55
+ if (!profile.profile_id) return null;
56
+ return profile.profile_id.replace(/-/g, '_');
57
+ }
58
+
59
+ /**
60
+ * Build a training caption from a record + context + profile.
61
+ *
62
+ * @param {Object} record — approved canon record (see schema in projects/**)
63
+ * @param {string|null} lane — partition lane (costume, equipment, ...)
64
+ * @param {string|null} group — partition group (faction, region, ...)
65
+ * @param {Object} profile — loaded training profile JSON
66
+ * @returns {string}
67
+ */
68
+ export function buildCaption(record, lane, group, profile) {
69
+ const strategy = profile?.caption_strategy || 'legacy';
70
+
71
+ switch (strategy) {
72
+ case 'structured-metadata':
73
+ return buildStructuredMetadataCaption(record, lane, group, profile);
74
+
75
+ case 'flux-natural-language':
76
+ return buildFluxNaturalLanguageCaption(record, lane, group, profile);
77
+
78
+ case 'legacy':
79
+ default:
80
+ return buildLegacyCaption(record, lane, group, profile);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Legacy caption — preserved verbatim from pre-refactor adapters so existing
86
+ * training manifests remain reproducible.
87
+ *
88
+ * Shape: `<trigger>, <group>, <lane>, <provenance.prompt sliced to 200>`
89
+ *
90
+ * WARNING: This strategy splices the raw generation prompt into the caption,
91
+ * which causes prompt bleed during training. Kept for backward compatibility
92
+ * only. New profiles should use `structured-metadata`.
93
+ */
94
+ function buildLegacyCaption(record, lane, group, profile) {
95
+ const parts = [];
96
+
97
+ if (profile?.prompt_strategy === 'trigger-word' && profile?.profile_id) {
98
+ parts.push(profile.profile_id.replace(/-/g, '_'));
99
+ }
100
+
101
+ if (group) parts.push(group);
102
+ if (lane) parts.push(lane);
103
+
104
+ const prompt = record?.provenance?.prompt;
105
+ if (prompt) {
106
+ parts.push(prompt.slice(0, 200));
107
+ } else if (record?.id) {
108
+ parts.push(record.id.replace(/_/g, ' '));
109
+ }
110
+
111
+ return parts.join(', ');
112
+ }
113
+
114
+ /**
115
+ * Structured-metadata caption.
116
+ *
117
+ * Builds captions from clean, canon-anchored fields only. Never touches
118
+ * `provenance.prompt` (which contains style vocabulary that causes bleed).
119
+ *
120
+ * Order follows the research rule that earlier tokens are weighted higher:
121
+ * 1. Style trigger (profile-derived) — absorbs the house style
122
+ * 2. Faction / group (canon-derived) — primary discriminator
123
+ * 3. Lane — category context
124
+ * 4. Scene description from judgment — short human scene notes
125
+ *
126
+ * Dedupes faction vs group when they're the same value. Trims empty
127
+ * segments. No segment exceeds a reasonable bound individually; full
128
+ * caption is expected to stay well under the 75-token practical cap.
129
+ */
130
+ function buildStructuredMetadataCaption(record, lane, group, profile) {
131
+ const parts = [];
132
+
133
+ const trigger = deriveStyleTrigger(profile);
134
+ if (trigger) parts.push(trigger);
135
+
136
+ const faction = record?.canon?.faction;
137
+ if (faction) parts.push(`${faction} faction`);
138
+
139
+ if (group && group !== faction) parts.push(group);
140
+
141
+ if (lane) parts.push(lane);
142
+
143
+ const scene = record?.judgment?.explanation;
144
+ if (scene) parts.push(scene.trim());
145
+
146
+ return parts.join(', ');
147
+ }
148
+
149
+ /**
150
+ * Flux natural-language caption.
151
+ *
152
+ * Flux's T5 text encoder holds identity across long, natural-language
153
+ * prompts where SDXL's CLIP loses identity on comma-separated tag lists.
154
+ * For Flux LoRAs the recommended caption shape is a full sentence:
155
+ * trigger-style descriptor, then a subject noun phrase, then a short
156
+ * scene sentence.
157
+ *
158
+ * Template (from the research): `[Trigger] style, a [Subject], [Scene].`
159
+ *
160
+ * Unlike structured-metadata, this strategy is prose-shaped — sentences
161
+ * with articles ("a", "the") rather than bare comma-joined tags. But it
162
+ * still uses only clean canon fields (faction, lane, judgment.explanation).
163
+ * Never touches `provenance.prompt`.
164
+ *
165
+ * Example (anchor_03_officer_v3):
166
+ * "character_style_lora style, a compact faction costume, Steel-blue
167
+ * uniform, high collar, ship corridor. Clean and institutional."
168
+ *
169
+ * Richer captions (hair / eyes / clothing / pose breakouts) require
170
+ * structured visual fields on the canon record that don't exist yet.
171
+ * This strategy is the floor — on-contract Flux captions from the fields
172
+ * we have. Expand the canon schema first, then enrich this strategy.
173
+ */
174
+ function buildFluxNaturalLanguageCaption(record, lane, group, profile) {
175
+ const segments = [];
176
+
177
+ const trigger = deriveStyleTrigger(profile);
178
+ if (trigger) segments.push(`${trigger} style`);
179
+
180
+ const faction = record?.canon?.faction;
181
+ const subjectParts = [];
182
+ if (faction) subjectParts.push(`${faction} faction`);
183
+ if (lane) {
184
+ subjectParts.push(lane);
185
+ } else if (group && group !== faction) {
186
+ subjectParts.push(group);
187
+ }
188
+
189
+ if (subjectParts.length > 0) {
190
+ segments.push(`a ${subjectParts.join(' ')}`);
191
+ }
192
+
193
+ const scene = record?.judgment?.explanation;
194
+ if (scene) segments.push(scene.trim());
195
+
196
+ return segments.join(', ');
197
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pick the canonical output image from a ComfyUI run's `outputs` map.
3
+ *
4
+ * ComfyUI workflows can contain more than one SaveImage node (e.g. a
5
+ * preview save + a final save, or multiple intermediate saves). Picking
6
+ * the first via `Object.values()` is non-deterministic across builds —
7
+ * Node guarantees insertion order, but the order in which ComfyUI
8
+ * populates `outputs` depends on its execution scheduler.
9
+ *
10
+ * Selection precedence:
11
+ * 1. `preferNodeId` — explicit hint from the workflow builder
12
+ * (`buildWorkflowGraph` returns `{ saveNodeId }`) or from a
13
+ * workflow profile's `expected_outputs.save_node_id`.
14
+ * 2. Highest numeric node id among nodes that emitted images.
15
+ * This matches the typical convention where the final save node
16
+ * is appended last, and is deterministic regardless of map order.
17
+ * 3. First node in iteration order — last-resort fallback for the
18
+ * pathological case where node ids aren't numeric.
19
+ *
20
+ * @param {Object} nodeOutputs - the `outputs` field from a ComfyUI prompt result
21
+ * @param {Object} [opts]
22
+ * @param {string|number|null} [opts.preferNodeId] - hint to prefer
23
+ * @returns {{ filename: string, subfolder: string, nodeId: string } | null}
24
+ */
25
+ export function pickOutputImage(nodeOutputs, { preferNodeId = null } = {}) {
26
+ if (!nodeOutputs || typeof nodeOutputs !== 'object') return null;
27
+
28
+ const candidates = [];
29
+ for (const [nodeId, nodeOut] of Object.entries(nodeOutputs)) {
30
+ if (nodeOut?.images?.length > 0) {
31
+ candidates.push({ nodeId, image: nodeOut.images[0] });
32
+ }
33
+ }
34
+ if (candidates.length === 0) return null;
35
+
36
+ // 1. Honor explicit hint when the matching node actually produced an image.
37
+ if (preferNodeId !== null && preferNodeId !== undefined) {
38
+ const wanted = String(preferNodeId);
39
+ const match = candidates.find(c => c.nodeId === wanted);
40
+ if (match) {
41
+ return {
42
+ filename: match.image.filename,
43
+ subfolder: match.image.subfolder || '',
44
+ nodeId: match.nodeId,
45
+ };
46
+ }
47
+ }
48
+
49
+ // 2. Highest numeric node id wins (typical "final save" convention).
50
+ const numeric = candidates
51
+ .map(c => ({ ...c, n: Number(c.nodeId) }))
52
+ .filter(c => Number.isFinite(c.n));
53
+ if (numeric.length > 0) {
54
+ numeric.sort((a, b) => b.n - a.n);
55
+ const winner = numeric[0];
56
+ return {
57
+ filename: winner.image.filename,
58
+ subfolder: winner.image.subfolder || '',
59
+ nodeId: winner.nodeId,
60
+ };
61
+ }
62
+
63
+ // 3. Last resort: first iteration entry (non-numeric ids).
64
+ const first = candidates[0];
65
+ return {
66
+ filename: first.image.filename,
67
+ subfolder: first.image.subfolder || '',
68
+ nodeId: first.nodeId,
69
+ };
70
+ }
package/lib/config.js CHANGED
@@ -88,6 +88,25 @@ export function loadProjectMeta(projectRoot) {
88
88
  return data;
89
89
  }
90
90
 
91
+ /**
92
+ * Default GPU label written into provenance when a project doesn't pin one.
93
+ * This rig is the Robot rig (Omen 45L, RTX 5090 32GB); older records say
94
+ * "RTX 5080" because the generator hardcoded the prior card.
95
+ */
96
+ export const DEFAULT_GPU_MODEL = 'RTX 5090';
97
+
98
+ /**
99
+ * Resolve the GPU label for a generation provenance record.
100
+ * Precedence: project.json `defaults.gpu_model` → DEFAULT_GPU_MODEL.
101
+ *
102
+ * @param {Object} [projectDefaults] — the `defaults` block from project.json
103
+ * @returns {string}
104
+ */
105
+ export function resolveGpuModel(projectDefaults) {
106
+ const v = projectDefaults?.gpu_model;
107
+ return typeof v === 'string' && v.trim() ? v.trim() : DEFAULT_GPU_MODEL;
108
+ }
109
+
91
110
  /**
92
111
  * Load constitution.json — array of rule objects.
93
112
  */