@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.
- package/CHANGELOG.md +37 -0
- package/README.es.md +84 -62
- package/README.fr.md +85 -63
- package/README.hi.md +97 -77
- package/README.it.md +86 -64
- package/README.ja.md +94 -72
- package/README.md +22 -0
- package/README.pt-BR.md +83 -61
- package/README.zh.md +88 -66
- package/bin/sdlab.js +71 -0
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-workflows.js +133 -2
- package/lib/adapters/diffusers-lora.js +46 -66
- package/lib/adapters/generic-image-caption.js +22 -50
- package/lib/canon-build/build.js +450 -0
- package/lib/canon-build/emitters/base.js +122 -0
- package/lib/canon-build/emitters/character.js +167 -0
- package/lib/canon-build/emitters/deity.js +165 -0
- package/lib/canon-build/emitters/faction.js +161 -0
- package/lib/canon-build/emitters/index.js +38 -0
- package/lib/canon-build/emitters/location.js +153 -0
- package/lib/canon-build/emitters/monster.js +131 -0
- package/lib/canon-build/emitters/relic.js +142 -0
- package/lib/canon-build/emitters/ship.js +154 -0
- package/lib/canon-build/emitters/species.js +165 -0
- package/lib/canon-build/frontmatter.js +76 -0
- package/lib/canon-build/lane-resolve.js +64 -0
- package/lib/canon-build/load-config.js +117 -0
- package/lib/canon-build/load-entry.js +62 -0
- package/lib/canon-build/load-schema.js +46 -0
- package/lib/captions.js +197 -0
- package/lib/config.js +19 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +165 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/training-adapters.js +5 -0
- package/lib/training-packages.js +24 -18
- package/lib/training-profiles.js +33 -0
- package/package.json +12 -3
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/canon-build.js +88 -0
- package/scripts/canon-drift.js +193 -0
- package/scripts/canon-freeze-status.js +85 -0
- package/scripts/canon-freeze.js +211 -0
- package/scripts/canon-unfreeze.js +137 -0
- package/scripts/curate.js +12 -0
- package/scripts/generate-controlnet.js +12 -0
- package/scripts/generate-identity.js +20 -4
- package/scripts/generate-ipadapter.js +11 -0
- package/scripts/generate.js +238 -102
- 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
|
+
}
|
package/lib/captions.js
ADDED
|
@@ -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
|
+
}
|
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
|
*/
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only event log for freeze transitions (D4).
|
|
3
|
+
*
|
|
4
|
+
* Lives at `<project>/canon-build/freeze-events.jsonl`. One line per event,
|
|
5
|
+
* JSON, stable key order. The jsonl is the audit source of truth; the
|
|
6
|
+
* `freeze.overrides[]` denormalization on the entry is a glance cache.
|
|
7
|
+
*
|
|
8
|
+
* Atomic append: we write the new event as a line-append operation at the
|
|
9
|
+
* OS level. Node's `appendFile` is atomic per-line on POSIX but not
|
|
10
|
+
* guaranteed on Windows when two processes race. For single-operator
|
|
11
|
+
* workflow this is fine; for a paranoid hardening pass later we can add
|
|
12
|
+
* a lockfile. Documented as a known hazard.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, appendFile, mkdir } from 'node:fs/promises';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
export const FREEZE_EVENT_TYPES = Object.freeze(['freeze', 'unfreeze', 'override', 'bypass']);
|
|
20
|
+
|
|
21
|
+
export function eventsPath(projectRoot) {
|
|
22
|
+
return join(projectRoot, 'canon-build', 'freeze-events.jsonl');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Append one event to the log. Creates the parent dir + file on first write.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} projectRoot — absolute path to the sdlab project root
|
|
29
|
+
* @param {Object} event — at minimum: {type, entity_id, by, reason}
|
|
30
|
+
*/
|
|
31
|
+
export async function appendEvent(projectRoot, event) {
|
|
32
|
+
if (!FREEZE_EVENT_TYPES.includes(event.type)) {
|
|
33
|
+
throw new Error(`appendEvent: unknown event type "${event.type}" (allowed: ${FREEZE_EVENT_TYPES.join(', ')})`);
|
|
34
|
+
}
|
|
35
|
+
if (!event.entity_id) throw new Error('appendEvent: entity_id required');
|
|
36
|
+
if (!event.by) throw new Error('appendEvent: by required');
|
|
37
|
+
if (!event.reason) throw new Error('appendEvent: reason required');
|
|
38
|
+
|
|
39
|
+
const path = eventsPath(projectRoot);
|
|
40
|
+
await mkdir(dirname(path), { recursive: true });
|
|
41
|
+
|
|
42
|
+
// Serialize with a stable key order so `diff`-style reading stays readable.
|
|
43
|
+
const ordered = {
|
|
44
|
+
at: event.at || new Date().toISOString(),
|
|
45
|
+
build_hash: event.build_hash ?? null,
|
|
46
|
+
type: event.type,
|
|
47
|
+
entity_id: event.entity_id,
|
|
48
|
+
schema_kind: event.schema_kind ?? null,
|
|
49
|
+
by: event.by,
|
|
50
|
+
reason: event.reason,
|
|
51
|
+
prior_status: event.prior_status ?? null,
|
|
52
|
+
new_status: event.new_status ?? null,
|
|
53
|
+
watch_fields: event.watch_fields ?? null,
|
|
54
|
+
prior_build_hash: event.prior_build_hash ?? null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await appendFile(path, JSON.stringify(ordered) + '\n', 'utf-8');
|
|
58
|
+
return ordered;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read all events from the log, in append order.
|
|
63
|
+
*/
|
|
64
|
+
export async function readEvents(projectRoot) {
|
|
65
|
+
const path = eventsPath(projectRoot);
|
|
66
|
+
if (!existsSync(path)) return [];
|
|
67
|
+
const text = await readFile(path, 'utf-8');
|
|
68
|
+
const events = [];
|
|
69
|
+
const lines = text.split('\n');
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i].trim();
|
|
72
|
+
if (!line) continue;
|
|
73
|
+
try {
|
|
74
|
+
events.push(JSON.parse(line));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(`readEvents: line ${i + 1} of ${path} is not valid JSON (${err.message})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return events;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Return events for a specific entity, in append order.
|
|
84
|
+
*/
|
|
85
|
+
export async function readEventsFor(projectRoot, entityId) {
|
|
86
|
+
const all = await readEvents(projectRoot);
|
|
87
|
+
return all.filter((e) => e.entity_id === entityId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return events newer than a given build_hash. Used by `sdlab canon drift`
|
|
92
|
+
* to compute "overrides since last build."
|
|
93
|
+
*
|
|
94
|
+
* Semantics: events are "since X" when their `build_hash` field differs
|
|
95
|
+
* from X. Since the log is append-only, that approximates temporal ordering.
|
|
96
|
+
*/
|
|
97
|
+
export async function readEventsSince(projectRoot, buildHash) {
|
|
98
|
+
if (!buildHash) return readEvents(projectRoot);
|
|
99
|
+
const all = await readEvents(projectRoot);
|
|
100
|
+
// Find last event with matching build_hash; return everything after it.
|
|
101
|
+
let cutoff = -1;
|
|
102
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
103
|
+
if (all[i].build_hash === buildHash) {
|
|
104
|
+
cutoff = i;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return cutoff < 0 ? all : all.slice(cutoff + 1);
|
|
109
|
+
}
|