@mcptoolshop/style-dataset-lab 3.1.0 → 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 (53) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.es.md +84 -62
  3. package/README.fr.md +85 -63
  4. package/README.hi.md +97 -77
  5. package/README.it.md +86 -64
  6. package/README.ja.md +94 -72
  7. package/README.md +22 -0
  8. package/README.pt-BR.md +83 -61
  9. package/README.zh.md +88 -66
  10. package/bin/sdlab.js +71 -0
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-workflows.js +133 -2
  13. package/lib/adapters/diffusers-lora.js +46 -66
  14. package/lib/adapters/generic-image-caption.js +22 -50
  15. package/lib/canon-build/build.js +450 -0
  16. package/lib/canon-build/emitters/base.js +122 -0
  17. package/lib/canon-build/emitters/character.js +167 -0
  18. package/lib/canon-build/emitters/deity.js +165 -0
  19. package/lib/canon-build/emitters/faction.js +161 -0
  20. package/lib/canon-build/emitters/index.js +38 -0
  21. package/lib/canon-build/emitters/location.js +153 -0
  22. package/lib/canon-build/emitters/monster.js +131 -0
  23. package/lib/canon-build/emitters/relic.js +142 -0
  24. package/lib/canon-build/emitters/ship.js +154 -0
  25. package/lib/canon-build/emitters/species.js +165 -0
  26. package/lib/canon-build/frontmatter.js +76 -0
  27. package/lib/canon-build/lane-resolve.js +64 -0
  28. package/lib/canon-build/load-config.js +117 -0
  29. package/lib/canon-build/load-entry.js +62 -0
  30. package/lib/canon-build/load-schema.js +46 -0
  31. package/lib/captions.js +197 -0
  32. package/lib/config.js +19 -0
  33. package/lib/freeze-events.js +109 -0
  34. package/lib/freeze-gate.js +165 -0
  35. package/lib/freeze-stamp.js +161 -0
  36. package/lib/rows.js +228 -0
  37. package/lib/training-adapters.js +5 -0
  38. package/lib/training-packages.js +24 -18
  39. package/lib/training-profiles.js +33 -0
  40. package/package.json +12 -3
  41. package/scripts/_batch_cut.py +71 -0
  42. package/scripts/_seam_cutout.py +74 -0
  43. package/scripts/canon-build.js +88 -0
  44. package/scripts/canon-drift.js +193 -0
  45. package/scripts/canon-freeze-status.js +85 -0
  46. package/scripts/canon-freeze.js +211 -0
  47. package/scripts/canon-unfreeze.js +137 -0
  48. package/scripts/curate.js +12 -0
  49. package/scripts/generate-controlnet.js +12 -0
  50. package/scripts/generate-identity.js +20 -4
  51. package/scripts/generate-ipadapter.js +11 -0
  52. package/scripts/generate.js +238 -102
  53. package/scripts/qwen_generate.py +124 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `sdlab canon freeze <entity_id> --reason "<text>"` — stamp a freeze block
3
+ * on a canon entry (D7 command).
4
+ *
5
+ * Validates the entry exists, that a canon-build output exists to witness
6
+ * against (so the `locked_at_build` field has a real value — D1), rewrites
7
+ * the entry's frontmatter with the new `freeze` block, and appends a
8
+ * `freeze` event to freeze-events.jsonl (D4).
9
+ */
10
+
11
+ import { parseArgs } from 'node:util';
12
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { existsSync } from 'node:fs';
15
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
16
+ import { loadBuildConfig } from '../lib/canon-build/load-config.js';
17
+ import { loadCanonEntriesInDir, entryId } from '../lib/canon-build/load-entry.js';
18
+ import { buildFreezeBlock, FREEZE_STATUSES, resolveWatchFields } from '../lib/freeze-stamp.js';
19
+ import { appendEvent } from '../lib/freeze-events.js';
20
+ import { inputError } from '../lib/errors.js';
21
+
22
+ export async function run(argv) {
23
+ const parsed = parseArgs({
24
+ args: argv,
25
+ options: {
26
+ project: { type: 'string' },
27
+ game: { type: 'string' },
28
+ reason: { type: 'string' },
29
+ status: { type: 'string', default: 'frozen' },
30
+ watch: { type: 'string' },
31
+ build: { type: 'string' },
32
+ by: { type: 'string', default: 'mike' },
33
+ json: { type: 'boolean', default: false },
34
+ },
35
+ allowPositionals: true,
36
+ strict: false,
37
+ });
38
+
39
+ const positional = parsed.positionals || [];
40
+ const entityIdArg = positional[0];
41
+ if (!entityIdArg) {
42
+ throw inputError(
43
+ 'INPUT_MISSING_ARG',
44
+ 'sdlab canon freeze requires an entity id as the first positional argument.',
45
+ 'Example: sdlab canon freeze heracles --reason "Labor III reference plate approved"',
46
+ );
47
+ }
48
+
49
+ const projectName = parsed.values.project || parsed.values.game;
50
+ if (!projectName) {
51
+ throw inputError('INPUT_MISSING_FLAG', 'sdlab canon freeze requires --project <name>', 'Example: --project greek-rpg');
52
+ }
53
+
54
+ if (!parsed.values.reason) {
55
+ throw inputError(
56
+ 'FREEZE_REASON_REQUIRED',
57
+ 'sdlab canon freeze requires --reason "<short text>".',
58
+ 'The reason lands in both the entry frontmatter and canon-build/freeze-events.jsonl as the audit record. Future you (and the LLM crew) need it to understand why.',
59
+ );
60
+ }
61
+
62
+ if (!FREEZE_STATUSES.includes(parsed.values.status)) {
63
+ throw inputError(
64
+ 'INPUT_INVALID_VALUE',
65
+ `--status must be one of ${FREEZE_STATUSES.join(', ')} (got "${parsed.values.status}")`,
66
+ );
67
+ }
68
+ if (parsed.values.status === 'auto') {
69
+ throw inputError(
70
+ 'INPUT_INVALID_VALUE',
71
+ '--status auto means "no freeze" — use `sdlab canon unfreeze` instead of freezing with status=auto.',
72
+ );
73
+ }
74
+
75
+ const projectRoot = join(process.cwd(), 'projects', projectName);
76
+ const configPath = join(projectRoot, 'canon-build', 'config.json');
77
+ const config = await loadBuildConfig(configPath, projectRoot);
78
+
79
+ // Find the entry file
80
+ const resolved = await findEntryFile(config, entityIdArg);
81
+ if (!resolved) {
82
+ throw inputError(
83
+ 'CANON_ENTRY_NOT_FOUND',
84
+ `No canon entry with id "${entityIdArg}" found under ${config.canon_root}.`,
85
+ `Check the entry filename matches the id field, and that the schema-subdirectory mapping in canon-build/config.json covers this entity type.`,
86
+ );
87
+ }
88
+
89
+ // Resolve the witness build hash
90
+ const lockedAtBuild = parsed.values.build || await findLatestBuildHash(projectRoot);
91
+ if (!lockedAtBuild) {
92
+ throw inputError(
93
+ 'FREEZE_NO_BUILD_WITNESS',
94
+ 'No canon-build output exists to witness this freeze against.',
95
+ 'Run `sdlab canon build` first to produce a build artifact, then retry the freeze. The witness chain (D1) requires a real build hash to stamp.',
96
+ );
97
+ }
98
+
99
+ // Resolve watch-fields: --watch override > entry override > config default
100
+ const watchFields = resolveEffectiveWatchFields({
101
+ entry: resolved.entry,
102
+ schemaName: resolved.schemaName,
103
+ buildConfig: config,
104
+ cliOverride: parsed.values.watch,
105
+ });
106
+
107
+ // Build the new freeze block, preserving existing overrides[] if any
108
+ const existing = resolved.entry.frontmatter.freeze || {};
109
+ const priorStatus = existing.status || 'auto';
110
+ const newBlock = buildFreezeBlock({
111
+ status: parsed.values.status,
112
+ lockedAtBuild,
113
+ frozenBy: parsed.values.by,
114
+ frozenReason: parsed.values.reason,
115
+ watchFields,
116
+ overrides: existing.overrides,
117
+ });
118
+
119
+ // Rewrite the entry file
120
+ const updatedFm = { ...resolved.entry.frontmatter, freeze: newBlock };
121
+ await rewriteEntryFrontmatter(resolved.filePath, updatedFm, resolved.entry.body);
122
+
123
+ // Append audit event
124
+ const event = await appendEvent(projectRoot, {
125
+ type: 'freeze',
126
+ entity_id: entityIdArg,
127
+ schema_kind: resolved.schemaName.replace('.schema.json', ''),
128
+ by: parsed.values.by,
129
+ reason: parsed.values.reason,
130
+ prior_status: priorStatus,
131
+ new_status: parsed.values.status,
132
+ watch_fields: watchFields,
133
+ build_hash: lockedAtBuild,
134
+ });
135
+
136
+ const result = {
137
+ ok: true,
138
+ entity_id: entityIdArg,
139
+ status: parsed.values.status,
140
+ locked_at_build: lockedAtBuild,
141
+ watch_fields: watchFields,
142
+ event_at: event.at,
143
+ };
144
+
145
+ if (parsed.values.json) {
146
+ console.log(JSON.stringify(result, null, 2));
147
+ return;
148
+ }
149
+
150
+ console.log(`froze "${entityIdArg}" (${parsed.values.status}) witnessed at ${lockedAtBuild}`);
151
+ console.log(` watching: ${watchFields.length ? watchFields.join(', ') : '(no fields — build-config has no default for this schema and no --watch was passed)'}`);
152
+ console.log(` reason: ${parsed.values.reason}`);
153
+ }
154
+
155
+ // ─── Helpers ──────────────────────────────────────────────────────────
156
+
157
+ async function findEntryFile(config, entityId) {
158
+ for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
159
+ const dir = join(config.canon_root, subdir);
160
+ const entries = await loadCanonEntriesInDir(dir);
161
+ for (const entry of entries) {
162
+ const id = entry.frontmatter?.id;
163
+ if (id === entityId) {
164
+ return { entry, schemaName, filePath: entry.filePath };
165
+ }
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ async function findLatestBuildHash(projectRoot) {
172
+ const canonBuildDir = join(projectRoot, 'canon-build');
173
+ if (!existsSync(canonBuildDir)) return null;
174
+ const entries = await readdir(canonBuildDir, { withFileTypes: true });
175
+ const buildDirs = entries
176
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !e.name.endsWith('.tmp') && !e.name.includes('.tmp-'))
177
+ .map((e) => e.name);
178
+ if (buildDirs.length === 0) return null;
179
+
180
+ // Pick the most recent by mtime of its manifest.json
181
+ let latest = null;
182
+ let latestMtime = 0;
183
+ for (const name of buildDirs) {
184
+ const manifestPath = join(canonBuildDir, name, 'manifest.json');
185
+ if (!existsSync(manifestPath)) continue;
186
+ try {
187
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
188
+ const built = new Date(manifest.built_at || 0).getTime();
189
+ if (built > latestMtime) {
190
+ latestMtime = built;
191
+ latest = manifest.generated_from || name;
192
+ }
193
+ } catch {
194
+ continue;
195
+ }
196
+ }
197
+ return latest;
198
+ }
199
+
200
+ function resolveEffectiveWatchFields({ entry, schemaName, buildConfig, cliOverride }) {
201
+ if (cliOverride) {
202
+ return cliOverride.split(',').map((s) => s.trim()).filter(Boolean);
203
+ }
204
+ return resolveWatchFields(entry.frontmatter, schemaName, buildConfig);
205
+ }
206
+
207
+ async function rewriteEntryFrontmatter(filePath, frontmatter, body) {
208
+ const yaml = stringifyYaml(frontmatter).trimEnd();
209
+ const content = `---\n${yaml}\n---\n${body}`;
210
+ await writeFile(filePath, content, 'utf-8');
211
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `sdlab canon unfreeze <entity_id> --reason "<text>"` — lift a freeze (D7).
3
+ *
4
+ * Writes the entry's `freeze.status` to `auto`, appends an `unfreeze` event
5
+ * with the prior state captured. Preserves the `overrides[]` history on the
6
+ * entry (append-only) so re-freeze later shows the full lifecycle.
7
+ *
8
+ * --reason is REQUIRED — unfreezes are where shipping studios (Sabotage,
9
+ * Supergiant, Pentiment) most often wished they had captured intent.
10
+ */
11
+
12
+ import { parseArgs } from 'node:util';
13
+ import { readFile, writeFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { stringify as stringifyYaml } from 'yaml';
16
+ import { loadBuildConfig } from '../lib/canon-build/load-config.js';
17
+ import { loadCanonEntriesInDir } from '../lib/canon-build/load-entry.js';
18
+ import { appendOverrideToFreezeBlock, readFreezeStatus } from '../lib/freeze-stamp.js';
19
+ import { appendEvent } from '../lib/freeze-events.js';
20
+ import { inputError } from '../lib/errors.js';
21
+
22
+ export async function run(argv) {
23
+ const parsed = parseArgs({
24
+ args: argv,
25
+ options: {
26
+ project: { type: 'string' },
27
+ game: { type: 'string' },
28
+ reason: { type: 'string' },
29
+ by: { type: 'string', default: 'mike' },
30
+ build: { type: 'string' },
31
+ json: { type: 'boolean', default: false },
32
+ },
33
+ allowPositionals: true,
34
+ strict: false,
35
+ });
36
+
37
+ const entityIdArg = (parsed.positionals || [])[0];
38
+ if (!entityIdArg) {
39
+ throw inputError('INPUT_MISSING_ARG', 'sdlab canon unfreeze requires an entity id.');
40
+ }
41
+ const projectName = parsed.values.project || parsed.values.game;
42
+ if (!projectName) throw inputError('INPUT_MISSING_FLAG', '--project required');
43
+ if (!parsed.values.reason) {
44
+ throw inputError(
45
+ 'FREEZE_REASON_REQUIRED',
46
+ 'sdlab canon unfreeze requires --reason "<text>".',
47
+ 'Unfreezes are where shipping studios most often regret not capturing intent. The reason lands in canon-build/freeze-events.jsonl and in the entry frontmatter\'s overrides[] append-only log.',
48
+ );
49
+ }
50
+
51
+ const projectRoot = join(process.cwd(), 'projects', projectName);
52
+ const config = await loadBuildConfig(join(projectRoot, 'canon-build', 'config.json'), projectRoot);
53
+ const resolved = await findEntryFile(config, entityIdArg);
54
+ if (!resolved) {
55
+ throw inputError('CANON_ENTRY_NOT_FOUND', `No canon entry with id "${entityIdArg}".`);
56
+ }
57
+
58
+ const fm = resolved.entry.frontmatter;
59
+ const priorStatus = readFreezeStatus(fm);
60
+ if (priorStatus === 'auto') {
61
+ throw inputError(
62
+ 'FREEZE_ALREADY_AUTO',
63
+ `Canon entry "${entityIdArg}" is already at status=auto; nothing to unfreeze.`,
64
+ );
65
+ }
66
+
67
+ const priorFreeze = fm.freeze || {};
68
+ const buildHash = parsed.values.build || priorFreeze.locked_at_build || null;
69
+
70
+ // Append override record to the entry's overrides[] log
71
+ const overrideRecord = {
72
+ at: buildHash || new Date().toISOString(),
73
+ by: parsed.values.by,
74
+ reason: parsed.values.reason,
75
+ prior_status: priorStatus,
76
+ prior_build_hash: priorFreeze.locked_at_build || null,
77
+ };
78
+ const updatedBlock = appendOverrideToFreezeBlock(
79
+ { ...priorFreeze, status: 'auto' },
80
+ overrideRecord,
81
+ );
82
+ // Drop fields that only make sense on an active freeze
83
+ delete updatedBlock.locked_at_build;
84
+ delete updatedBlock.locked_at_canon_version;
85
+ delete updatedBlock.frozen_by;
86
+ delete updatedBlock.frozen_reason;
87
+ delete updatedBlock.watch_fields;
88
+
89
+ const updatedFm = { ...fm, freeze: updatedBlock };
90
+ await rewriteEntryFrontmatter(resolved.filePath, updatedFm, resolved.entry.body);
91
+
92
+ const event = await appendEvent(projectRoot, {
93
+ type: 'unfreeze',
94
+ entity_id: entityIdArg,
95
+ schema_kind: resolved.schemaName.replace('.schema.json', ''),
96
+ by: parsed.values.by,
97
+ reason: parsed.values.reason,
98
+ prior_status: priorStatus,
99
+ new_status: 'auto',
100
+ prior_build_hash: priorFreeze.locked_at_build || null,
101
+ build_hash: buildHash,
102
+ });
103
+
104
+ const result = {
105
+ ok: true,
106
+ entity_id: entityIdArg,
107
+ prior_status: priorStatus,
108
+ new_status: 'auto',
109
+ event_at: event.at,
110
+ };
111
+
112
+ if (parsed.values.json) {
113
+ console.log(JSON.stringify(result, null, 2));
114
+ return;
115
+ }
116
+ console.log(`unfroze "${entityIdArg}" (was ${priorStatus})`);
117
+ console.log(` reason: ${parsed.values.reason}`);
118
+ console.log(` overrides[] history preserved with ${updatedBlock.overrides.length} entries`);
119
+ }
120
+
121
+ async function findEntryFile(config, entityId) {
122
+ for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
123
+ const dir = join(config.canon_root, subdir);
124
+ const entries = await loadCanonEntriesInDir(dir);
125
+ for (const entry of entries) {
126
+ if (entry.frontmatter?.id === entityId) {
127
+ return { entry, schemaName, filePath: entry.filePath };
128
+ }
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ async function rewriteEntryFrontmatter(filePath, frontmatter, body) {
135
+ const yaml = stringifyYaml(frontmatter).trimEnd();
136
+ await writeFile(filePath, `---\n${yaml}\n---\n${body}`, 'utf-8');
137
+ }
package/scripts/curate.js CHANGED
@@ -17,6 +17,7 @@ import { REPO_ROOT } from "../lib/paths.js";
17
17
  import { readJsonFile } from "../lib/config.js";
18
18
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
19
19
  import { result } from "../lib/log.js";
20
+ import { assertNotFrozenByAssetPath } from "../lib/freeze-gate.js";
20
21
 
21
22
  export async function run(argv = process.argv.slice(2)) {
22
23
  const { flags, positionals } = parseArgs(argv, {
@@ -27,6 +28,7 @@ export async function run(argv = process.argv.slice(2)) {
27
28
  notes: { type: 'string' },
28
29
  list: { type: 'boolean', default: false },
29
30
  'dry-run': { type: 'boolean', default: false },
31
+ 'i-know': { type: 'boolean', default: false },
30
32
  },
31
33
  deprecated: { game: 'project' },
32
34
  });
@@ -106,6 +108,16 @@ export async function run(argv = process.argv.slice(2)) {
106
108
  return;
107
109
  }
108
110
 
111
+ // Freeze gate: refuse to recurate to a path that a frozen canon entry owns.
112
+ // No-op when the project has no canon-build config. The explanation doubles
113
+ // as the bypass reason when the target entry is soft-advisory.
114
+ await assertNotFrozenByAssetPath(GAME_ROOT, newPath, {
115
+ action: 'curate',
116
+ allowSoftAdvisoryBypass: flags['i-know'],
117
+ bypassReason: explanation,
118
+ by: 'mike',
119
+ });
120
+
109
121
  await mkdir(join(GAME_ROOT, newDir), { recursive: true });
110
122
 
111
123
  // Move image FIRST — if the rename fails we still have a clean record.
@@ -19,6 +19,7 @@ import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
19
19
  import { readJsonFile } from "../lib/config.js";
20
20
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
21
21
  import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
22
+ import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
22
23
 
23
24
  const DEFAULTS = {
24
25
  checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
@@ -177,6 +178,17 @@ export async function run(argv = process.argv.slice(2)) {
177
178
  throw inputError('INPUT_MISSING_FLAG', "--guide <path> is required");
178
179
  }
179
180
 
181
+ // Freeze gate (advisory): refuse if the subject's canon entry is frozen.
182
+ // Supports --i-know + --reason bypass for soft-advisory entries.
183
+ const reasonFlagIdx = argv.indexOf('--reason');
184
+ const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
185
+ await assertNotFrozenBySubject(GAME_ROOT, opts.subject, {
186
+ action: 'generate:controlnet',
187
+ allowSoftAdvisoryBypass: argv.includes('--i-know'),
188
+ bypassReason,
189
+ by: 'mike',
190
+ });
191
+
180
192
  // Validate guide stays inside the project tree.
181
193
  const safeGuide = resolveSafeProjectPath(GAME_ROOT, opts.guidePath, { flagName: 'guide' });
182
194
 
@@ -24,9 +24,10 @@ import { writeFile, mkdir } from "node:fs/promises";
24
24
  import { join } from "node:path";
25
25
  import { getProjectName, parseNumberFlag } from "../lib/args.js";
26
26
  import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
27
- import { readJsonFile } from "../lib/config.js";
27
+ import { readJsonFile, loadProjectMeta, resolveGpuModel } from "../lib/config.js";
28
28
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
29
29
  import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
30
+ import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
30
31
 
31
32
  // ── Defaults (match existing lab pipeline) ──
32
33
 
@@ -309,7 +310,7 @@ function buildImg2ImgWorkflow(prompt, negativePrompt, seed, imagePath, denoise)
309
310
 
310
311
  // ── Record Builder ──
311
312
 
312
- function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, elapsedMs, phase, anchorInfo) {
313
+ function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, elapsedMs, phase, anchorInfo, gpuModel) {
313
314
  const assetId = `${shot.id}_s${seedIndex}`;
314
315
  const fullPrompt = `${pack.style_prefix}, ${shot.prompt}`;
315
316
  const negativePrompt = [
@@ -343,7 +344,7 @@ function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, el
343
344
  width: DEFAULTS.width,
344
345
  height: DEFAULTS.height,
345
346
  generation_time_ms: elapsedMs,
346
- gpu_model: "RTX 5080",
347
+ gpu_model: gpuModel,
347
348
  denoise: phase === "follow_on" ? (anchorInfo?.denoise || 0.38) : 1.0,
348
349
  },
349
350
  identity: {
@@ -379,6 +380,7 @@ export async function run(argv = process.argv.slice(2)) {
379
380
  const projectName = getProjectName(argv);
380
381
  const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
381
382
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
383
+ const gpuModel = resolveGpuModel(loadProjectMeta(GAME_ROOT)?.defaults);
382
384
 
383
385
  // Collect positionals, skipping values that belong to known flags
384
386
  const knownFlagsWithValue = new Set(['--subject', '--seeds', '--phase', '--anchor', '--denoise', '--project', '--game']);
@@ -396,6 +398,20 @@ export async function run(argv = process.argv.slice(2)) {
396
398
  const subjectFilter = argv.includes("--subject")
397
399
  ? argv[argv.indexOf("--subject") + 1]
398
400
  : null;
401
+
402
+ // Freeze gate (advisory): when --subject is given, refuse if that subject's
403
+ // canon entry is frozen. No-op when --subject is absent (pack-driven) or
404
+ // when the project has no canon-build config.
405
+ if (subjectFilter) {
406
+ const reasonFlagIdx = argv.indexOf('--reason');
407
+ const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
408
+ await assertNotFrozenBySubject(GAME_ROOT, subjectFilter, {
409
+ action: 'generate:identity',
410
+ allowSoftAdvisoryBypass: argv.includes('--i-know'),
411
+ bypassReason,
412
+ by: 'mike',
413
+ });
414
+ }
399
415
  const seedCount = argv.includes("--seeds")
400
416
  ? parseNumberFlag('seeds', argv[argv.indexOf("--seeds") + 1], { int: true, min: 1 })
401
417
  : 3;
@@ -536,7 +552,7 @@ export async function run(argv = process.argv.slice(2)) {
536
552
 
537
553
  const record = buildRecord(
538
554
  pack, subject, shot, seed, si,
539
- imgData, destPath, elapsed, phase, anchorInfo
555
+ imgData, destPath, elapsed, phase, anchorInfo, gpuModel
540
556
  );
541
557
 
542
558
  await writeFile(
@@ -17,6 +17,7 @@ import { getProjectName, parseNumberFlag } from "../lib/args.js";
17
17
  import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
18
18
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
19
19
  import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
20
+ import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
20
21
 
21
22
  const DEFAULTS = {
22
23
  checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
@@ -186,6 +187,16 @@ export async function run(argv = process.argv.slice(2)) {
186
187
  throw inputError('INPUT_MISSING_FLAG', "--prompt is required");
187
188
  }
188
189
 
190
+ // Freeze gate (advisory): refuse if the subject's canon entry is frozen.
191
+ const reasonFlagIdx = argv.indexOf('--reason');
192
+ const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
193
+ await assertNotFrozenBySubject(GAME_ROOT, opts.subject, {
194
+ action: 'generate:ipadapter',
195
+ allowSoftAdvisoryBypass: argv.includes('--i-know'),
196
+ bypassReason,
197
+ by: 'mike',
198
+ });
199
+
189
200
  // Validate --ref path stays inside project tree.
190
201
  const safeRefPath = resolveSafeProjectPath(GAME_ROOT, opts.refPath, { flagName: 'ref' });
191
202