diff --git a/server/services/loraDatasetCaption.js b/server/services/loraDatasetCaption.js index 9636d97ec7..8dcc794d4d 100644 --- a/server/services/loraDatasetCaption.js +++ b/server/services/loraDatasetCaption.js @@ -33,7 +33,7 @@ import { getSettings } from './settings.js'; import { listVisionModels } from './localLlm.js'; import { isVisionModel } from '../lib/localModelHeuristics.js'; import { getDataset, updateDataset, datasetImagePath } from './loraDatasets.js'; -import { loadDatasetSubject, extractSubjectSignaturePhrases } from './loraDatasetGenerate.js'; +import { loadDatasetSubject, extractSubjectSignaturePhrases } from './loraDatasetSubject.js'; /** * Build the vision-captioning instruction. A LoRA binds whatever the captions diff --git a/server/services/loraDatasetGenerate.js b/server/services/loraDatasetGenerate.js index b3587d1112..580a5451f3 100644 --- a/server/services/loraDatasetGenerate.js +++ b/server/services/loraDatasetGenerate.js @@ -27,7 +27,12 @@ import { readSheetPointer, LEGACY_SHEET_VARIANT_ID } from '../lib/storyBible.js' import { describeImageDataUrlDetailed } from './visionTest.js'; import { resolveCaptionModel, withCaptionVisionLock } from './loraDatasetCaption.js'; import { getSettings } from './settings.js'; -import { getUniverse } from './universeBuilder.js'; +import { + normalizeEntryKind, + subjectLabel, + flattenValue, + loadDatasetSubject, +} from './loraDatasetSubject.js'; import { universeAestheticLine } from '../lib/universeVisualStyle.js'; import { extractCharacterPromptCommon, @@ -51,76 +56,6 @@ import { const DATASET_IMAGE_SIZE = 1024; const trim = (s) => (typeof s === 'string' ? s.trim() : ''); -const normalizeEntryKind = (entryKind) => ( - ['characters', 'objects', 'places'].includes(entryKind) ? entryKind : 'characters' -); -const subjectLabel = (entryKind) => { - switch (normalizeEntryKind(entryKind)) { - case 'objects': return 'Object'; - case 'places': return 'Place'; - default: return 'Character'; - } -}; - -const flattenValue = (value) => { - if (typeof value === 'string') return trim(value); - if (Array.isArray(value)) { - return value.map((v) => { - if (typeof v === 'string') return trim(v); - if (v && typeof v === 'object') return trim(v.name || v.label || v.description || v.prompt || ''); - return ''; - }).filter(Boolean).join(', '); - } - if (value && typeof value === 'object') { - return Object.entries(value) - .map(([key, v]) => `${key}: ${typeof v === 'string' ? trim(v) : trim(v?.name || v?.label || '')}`) - .filter((part) => !part.endsWith(': ')) - .join(', '); - } - return ''; -}; - -/** - * Pull the subject's INVARIANT signature features — the wardrobe pieces, props, - * and palette that are present in (nearly) every reference image and therefore - * belong to the trigger token, not the per-shot caption. Returned as discrete - * short phrases so the captioner can be told to omit them by name (issue #1320: - * captioning "red cloak, woven crown, leather armor" in every shot binds the - * look to those phrases instead of the trigger). Pure; tolerant of the bible's - * looser object/place shape. Capped + de-duped so the deny-list stays focused. - */ -export function extractSubjectSignaturePhrases(subject, entryKind = 'characters') { - if (!subject || typeof subject !== 'object') return []; - const out = []; - const pushNames = (arr) => { - if (!Array.isArray(arr)) return; - for (const item of arr) { - const name = trim(item?.name); - if (name) out.push(name); - } - }; - if (normalizeEntryKind(entryKind) === 'characters') { - pushNames(subject.wardrobes); - pushNames(subject.props); - pushNames(subject.colorPalette); - } else { - // Objects/places: the recurring details + palette are the invariants. In the - // looser object/place bible shape both can be a string OR an array, so route - // them through flattenValue (handles both) rather than the array-only - // pushNames — a string `palette` would otherwise be dropped entirely. - for (const raw of [subject.colorPalette || subject.palette, subject.recurringDetails]) { - const flat = flattenValue(raw); - if (flat) out.push(...flat.split(',').map((p) => p.trim())); - } - } - const visualIdentity = trim(subject.visualIdentity); - if (visualIdentity) out.push(visualIdentity); - const seen = new Set(); - return out - .map((s) => s.replace(/\s+/g, ' ').trim()) - .filter((s) => s && !seen.has(s.toLowerCase()) && seen.add(s.toLowerCase())) - .slice(0, 12); -} /** * Build the image-gen prompt + negative prompt for ONE dataset image. @@ -268,24 +203,8 @@ export async function getDatasetVariationAxes(datasetId) { return deriveVariationAxes(subject); } -// Load the dataset's live canon subject (generation + slicing both need -// the current canon, not the dataset's snapshot). 409 when the subject -// was deleted from the universe after the dataset was created. -// Exported so the captioner can derive the subject's signature-feature -// deny-list without duplicating the universe/entry lookup. -export async function loadDatasetSubject(dataset) { - const entryKind = normalizeEntryKind(dataset.character.entryKind); - const universe = await getUniverse(dataset.character.universeId); - const entries = Array.isArray(universe[entryKind]) ? universe[entryKind] : []; - const subject = entries.find((entry) => entry.id === dataset.character.entryId); - if (!subject) { - throw new ServerError( - `${subjectLabel(entryKind)} ${dataset.character.entryId} no longer exists in universe ${dataset.character.universeId}`, - { status: 409, code: 'UNIVERSE_CANON_NOT_FOUND' }, - ); - } - return { universe, subject: { ...subject, entryKind }, entryKind }; -} +// Live-subject lookup lives in `./loraDatasetSubject.js` (shared with the +// captioner) — imported above. // Single-dispatcher subscription on mediaJobEvents — same shape as // universeCharacterSheet's sheetSubscribers so N pending dataset renders diff --git a/server/services/loraDatasetGenerate.test.js b/server/services/loraDatasetGenerate.test.js index d00767180f..189fbef92a 100644 --- a/server/services/loraDatasetGenerate.test.js +++ b/server/services/loraDatasetGenerate.test.js @@ -34,8 +34,8 @@ vi.mock('sharp', () => ({ import { buildDatasetImagePrompt, deriveVariationAxes, getDatasetVariationAxes, normalizeCropProposals, proposeCropRegions, sliceReferenceSheet, - extractSubjectSignaturePhrases, } from './loraDatasetGenerate.js'; +import { extractSubjectSignaturePhrases } from './loraDatasetSubject.js'; import { getDataset, updateDataset } from './loraDatasets.js'; import { getUniverse } from './universeBuilder.js'; diff --git a/server/services/loraDatasetSubject.js b/server/services/loraDatasetSubject.js new file mode 100644 index 0000000000..fef896cd09 --- /dev/null +++ b/server/services/loraDatasetSubject.js @@ -0,0 +1,107 @@ +/** + * LoRA dataset subject derivation — the leaf both the captioner and the + * generator import (issue #5917). + * + * `loraDatasetCaption.js` and `loraDatasetGenerate.js` each need the dataset's + * live canon subject (generation/slicing render from current canon, captioning + * derives its signature-feature deny-list from it). Those helpers used to live + * in `loraDatasetGenerate.js`, which the captioner imported — while the + * generator imported caption-model resolution back from the captioner, closing + * a two-module static ESM cycle. They live here now: this module imports only + * `universeBuilder.js` (which imports no `loraDataset*` module), so both + * callers depend on the leaf and neither depends on the other. + */ + +import { ServerError } from '../lib/errorHandler.js'; +import { getUniverse } from './universeBuilder.js'; + +const trim = (s) => (typeof s === 'string' ? s.trim() : ''); +export const normalizeEntryKind = (entryKind) => ( + ['characters', 'objects', 'places'].includes(entryKind) ? entryKind : 'characters' +); +export const subjectLabel = (entryKind) => { + switch (normalizeEntryKind(entryKind)) { + case 'objects': return 'Object'; + case 'places': return 'Place'; + default: return 'Character'; + } +}; + +export const flattenValue = (value) => { + if (typeof value === 'string') return trim(value); + if (Array.isArray(value)) { + return value.map((v) => { + if (typeof v === 'string') return trim(v); + if (v && typeof v === 'object') return trim(v.name || v.label || v.description || v.prompt || ''); + return ''; + }).filter(Boolean).join(', '); + } + if (value && typeof value === 'object') { + return Object.entries(value) + .map(([key, v]) => `${key}: ${typeof v === 'string' ? trim(v) : trim(v?.name || v?.label || '')}`) + .filter((part) => !part.endsWith(': ')) + .join(', '); + } + return ''; +}; + +/** + * Pull the subject's INVARIANT signature features — the wardrobe pieces, props, + * and palette that are present in (nearly) every reference image and therefore + * belong to the trigger token, not the per-shot caption. Returned as discrete + * short phrases so the captioner can be told to omit them by name (issue #1320: + * captioning "red cloak, woven crown, leather armor" in every shot binds the + * look to those phrases instead of the trigger). Pure; tolerant of the bible's + * looser object/place shape. Capped + de-duped so the deny-list stays focused. + */ +export function extractSubjectSignaturePhrases(subject, entryKind = 'characters') { + if (!subject || typeof subject !== 'object') return []; + const out = []; + const pushNames = (arr) => { + if (!Array.isArray(arr)) return; + for (const item of arr) { + const name = trim(item?.name); + if (name) out.push(name); + } + }; + if (normalizeEntryKind(entryKind) === 'characters') { + pushNames(subject.wardrobes); + pushNames(subject.props); + pushNames(subject.colorPalette); + } else { + // Objects/places: the recurring details + palette are the invariants. In the + // looser object/place bible shape both can be a string OR an array, so route + // them through flattenValue (handles both) rather than the array-only + // pushNames — a string `palette` would otherwise be dropped entirely. + for (const raw of [subject.colorPalette || subject.palette, subject.recurringDetails]) { + const flat = flattenValue(raw); + if (flat) out.push(...flat.split(',').map((p) => p.trim())); + } + } + const visualIdentity = trim(subject.visualIdentity); + if (visualIdentity) out.push(visualIdentity); + const seen = new Set(); + return out + .map((s) => s.replace(/\s+/g, ' ').trim()) + .filter((s) => s && !seen.has(s.toLowerCase()) && seen.add(s.toLowerCase())) + .slice(0, 12); +} + +// Load the dataset's live canon subject (generation + slicing both need +// the current canon, not the dataset's snapshot). 409 when the subject +// was deleted from the universe after the dataset was created. +// Exported so the captioner can derive the subject's signature-feature +// deny-list without duplicating the universe/entry lookup. +export async function loadDatasetSubject(dataset) { + const entryKind = normalizeEntryKind(dataset.character.entryKind); + const universe = await getUniverse(dataset.character.universeId); + const entries = Array.isArray(universe[entryKind]) ? universe[entryKind] : []; + const subject = entries.find((entry) => entry.id === dataset.character.entryId); + if (!subject) { + throw new ServerError( + `${subjectLabel(entryKind)} ${dataset.character.entryId} no longer exists in universe ${dataset.character.universeId}`, + { status: 409, code: 'UNIVERSE_CANON_NOT_FOUND' }, + ); + } + return { universe, subject: { ...subject, entryKind }, entryKind }; +} diff --git a/server/services/serviceImportCycles.test.js b/server/services/serviceImportCycles.test.js index c216faef98..60cade65fb 100644 --- a/server/services/serviceImportCycles.test.js +++ b/server/services/serviceImportCycles.test.js @@ -48,8 +48,6 @@ const SERVICES_DIR = dirname(fileURLToPath(import.meta.url)); // assertion below fails while a fixed entry is still listed, which is what stops // this list from becoming a wish. const KNOWN_CYCLIC_COMPONENTS = [ - // #5917 — caption resolution and subject derivation import each other. - { issue: 5917, members: ['loraDatasetCaption.js', 'loraDatasetGenerate.js'] }, // #5919 — the receive path reaches subscription state back through the barrel. ];