@nodaro/prompts 1.16.0 → 1.17.1
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/dist/index.cjs +181 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +121 -2
- package/dist/index.d.ts +121 -2
- package/dist/index.js +178 -50
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +30 -0
- package/src/__tests__/described-references-video.test.ts +250 -0
- package/src/__tests__/described-references.test.ts +595 -0
- package/src/__tests__/factory-presets.test.ts +8 -2
- package/src/__tests__/node-prompt-fields.test.ts +4 -2
- package/src/assemble-image-input.ts +11 -1
- package/src/described-references.ts +138 -0
- package/src/index.ts +1 -0
- package/src/node-prompt-fields.ts +2 -0
- package/src/prompt-builder.ts +196 -19
- package/src/video-reference-resolver.ts +85 -10
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Described references + per-reference description overrides + rail captions —
|
|
3
|
+
* the ONE place either lane phrases "what this reference IS".
|
|
4
|
+
*
|
|
5
|
+
* Three shapes, one grammar (`<subject> — <description>.`) plus the caption's
|
|
6
|
+
* `<binding>: <caption>.`:
|
|
7
|
+
*
|
|
8
|
+
* - a DESCRIBED reference (`DescribedReference`, no media) → `Natalie — a
|
|
9
|
+
* tall woman in a red coat.` The subject is the NAME, because a described
|
|
10
|
+
* reference has no seat to bind to: correlation with the prose is by name,
|
|
11
|
+
* which is why the caller leaves the name in the prompt rather than an
|
|
12
|
+
* indexed `@slug:N` mention (that grammar is url-gated).
|
|
13
|
+
* - a per-use `descriptionOverride` on a reference the HYBRID format gives no
|
|
14
|
+
* description slot (a mention / canonical-fallback / location / object role
|
|
15
|
+
* phrase carries none) → `reference image A — a tall woman in a red coat.`
|
|
16
|
+
* Same grammar, the BINDING as the subject. Where a description slot DOES
|
|
17
|
+
* exist (every legacy bullet, and the hybrid extras' `, <desc>` clause) the
|
|
18
|
+
* override fills THAT slot instead, so the model is never told twice.
|
|
19
|
+
* - a rail CAPTION for a video / audio reference → `@video_1: the establishing
|
|
20
|
+
* drone shot.` Index-aligned with the caller's url array.
|
|
21
|
+
*
|
|
22
|
+
* Rendering is separate from JOINING on purpose: the lines are format-agnostic
|
|
23
|
+
* text, and each lane already owns where a trailing directive goes (the image
|
|
24
|
+
* hybrid's `trailingLines`, the video core's `trailingLines` /
|
|
25
|
+
* `allFallbackLines`). `appendReferenceLines` is the joiner for the call sites
|
|
26
|
+
* that have no such array to push into — it bullets the lines into the legacy
|
|
27
|
+
* "Use these characters:" block or lands them ahead of the `[style]` section in
|
|
28
|
+
* hybrid, matching what each lane's own assembly does.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { DescribedReference } from "@nodaro/shared"
|
|
32
|
+
import { insertBeforeStyleSection } from "./prompt-style-section.js"
|
|
33
|
+
|
|
34
|
+
/** Which reference-prompt format the lines are being rendered for. */
|
|
35
|
+
export type ReferenceLineFormat = "legacy" | "hybrid"
|
|
36
|
+
|
|
37
|
+
/** The legacy directive block every lane consolidates its bullets into. */
|
|
38
|
+
const CHARACTER_BLOCK_HEADER = "Use these characters:"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The ONE phrasing for "this subject is described as …":
|
|
42
|
+
* `<subject> — <description>.`
|
|
43
|
+
*
|
|
44
|
+
* `subject` is the name for a described reference and the reference's binding
|
|
45
|
+
* (`reference image A` / `@image_2`) for a per-use description override. Both
|
|
46
|
+
* halves are trimmed; an empty half yields `""` (the caller drops the line).
|
|
47
|
+
*/
|
|
48
|
+
export function referenceDescriptionLine(
|
|
49
|
+
subject: string,
|
|
50
|
+
description: string | null | undefined,
|
|
51
|
+
): string {
|
|
52
|
+
const s = subject.trim()
|
|
53
|
+
const d = description?.trim()
|
|
54
|
+
if (!s || !d) return ""
|
|
55
|
+
return `${s} — ${d}.`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the described references (name + description, no media) as trailing
|
|
60
|
+
* lines. Entries missing either half contribute nothing; entries repeating a
|
|
61
|
+
* name (case-insensitively) render once — a description a caller sent twice
|
|
62
|
+
* says nothing new to the model, and every sibling renderer in both lanes
|
|
63
|
+
* dedups the same way.
|
|
64
|
+
*/
|
|
65
|
+
export function renderDescribedReferenceLines(
|
|
66
|
+
refs: readonly DescribedReference[] | undefined,
|
|
67
|
+
): string[] {
|
|
68
|
+
if (!refs || refs.length === 0) return []
|
|
69
|
+
const lines: string[] = []
|
|
70
|
+
const seen = new Set<string>()
|
|
71
|
+
for (const r of refs) {
|
|
72
|
+
const key = r.name?.trim().toLowerCase()
|
|
73
|
+
if (!key || seen.has(key)) continue
|
|
74
|
+
const line = referenceDescriptionLine(r.name, r.description)
|
|
75
|
+
if (!line) continue
|
|
76
|
+
seen.add(key)
|
|
77
|
+
lines.push(line)
|
|
78
|
+
}
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the video / audio rail captions as `@video_N: <caption>.` /
|
|
84
|
+
* `@audio_N: <caption>.` lines, index-aligned with the caller's url arrays.
|
|
85
|
+
*
|
|
86
|
+
* Each list is bounded by the count of references of that kind that actually
|
|
87
|
+
* ship, so a caption for a url the provider cap dropped never binds a phantom
|
|
88
|
+
* `@video_N`. Blank captions are holes in an aligned array, not lines.
|
|
89
|
+
*/
|
|
90
|
+
export function renderReferenceCaptionLines(
|
|
91
|
+
videoCaptions: readonly string[] | undefined,
|
|
92
|
+
audioCaptions: readonly string[] | undefined,
|
|
93
|
+
counts: { readonly video: number; readonly audio: number },
|
|
94
|
+
): string[] {
|
|
95
|
+
const lines: string[] = []
|
|
96
|
+
const render = (captions: readonly string[] | undefined, kind: "video" | "audio", cap: number) => {
|
|
97
|
+
if (!captions) return
|
|
98
|
+
for (let i = 0; i < captions.length && i < cap; i++) {
|
|
99
|
+
const text = captions[i]?.trim()
|
|
100
|
+
if (!text) continue
|
|
101
|
+
lines.push(`@${kind}_${i + 1}: ${text}.`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
render(videoCaptions, "video", counts.video)
|
|
105
|
+
render(audioCaptions, "audio", counts.audio)
|
|
106
|
+
return lines
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Append reference lines to an assembled prompt the way the surrounding lane
|
|
111
|
+
* would have.
|
|
112
|
+
*
|
|
113
|
+
* - hybrid → trailing scene directives ahead of the `[style]` section (the
|
|
114
|
+
* section has no terminator, so a flat append would read as one more look
|
|
115
|
+
* clause).
|
|
116
|
+
* - legacy → `- ` bullets consolidated into the existing
|
|
117
|
+
* "Use these characters:" block, or a new block prepended ahead of the
|
|
118
|
+
* body — the same splice-or-create both lanes already do for their
|
|
119
|
+
* canonical-fallback and extra-ref bullets.
|
|
120
|
+
*
|
|
121
|
+
* No lines → the prompt is returned unchanged, byte-for-byte.
|
|
122
|
+
*/
|
|
123
|
+
export function appendReferenceLines(
|
|
124
|
+
prompt: string,
|
|
125
|
+
lines: readonly string[],
|
|
126
|
+
format: ReferenceLineFormat,
|
|
127
|
+
): string {
|
|
128
|
+
if (lines.length === 0) return prompt
|
|
129
|
+
if (format === "hybrid") return insertBeforeStyleSection(prompt, lines)
|
|
130
|
+
const bullets = lines.map((l) => `- ${l}`).join("\n")
|
|
131
|
+
if (prompt.startsWith(`${CHARACTER_BLOCK_HEADER}\n`)) {
|
|
132
|
+
const splitIdx = prompt.indexOf("\n\n")
|
|
133
|
+
if (splitIdx === -1) return `${prompt}\n${bullets}`
|
|
134
|
+
return `${prompt.slice(0, splitIdx)}\n${bullets}${prompt.slice(splitIdx)}`
|
|
135
|
+
}
|
|
136
|
+
const block = `${CHARACTER_BLOCK_HEADER}\n${bullets}`
|
|
137
|
+
return prompt ? `${block}\n\n${prompt}` : block
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./term.js"
|
|
|
16
16
|
export * from "./parameter-prompt-hint.js"
|
|
17
17
|
export * from "./entity-prompts.js"
|
|
18
18
|
export * from "./brand-tokens.js"
|
|
19
|
+
export * from "./described-references.js"
|
|
19
20
|
export * from "./prompt-builder.js"
|
|
20
21
|
export * from "./prompt-builder-structured-fields.js"
|
|
21
22
|
export * from "./direction-registry.js"
|
|
@@ -118,6 +118,8 @@ export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
|
|
|
118
118
|
// ── Composition / FX (compact, no media-result preview → no inline editor) ──
|
|
119
119
|
"image-critic": { prompt: "prompt", promptLabel: "Criteria", media: "image", inline: false },
|
|
120
120
|
"motion-graphics": { prompt: "motionPrompt", promptLabel: "Motion prompt", media: "video", inline: false },
|
|
121
|
+
"generate-3d-scene": { prompt: "scenePrompt", promptLabel: "Scene", media: "text", inline: false },
|
|
122
|
+
"edit-3d-scene": { prompt: "editPrompt", promptLabel: "Edit instruction", media: "text", inline: false },
|
|
121
123
|
"3d-title": { prompt: "titlePrompt", promptLabel: "Title", media: "text", inline: false },
|
|
122
124
|
// ── Script / alignment (their primary text field) ──
|
|
123
125
|
"generate-script": { prompt: "styleGuide", promptLabel: "Style guide", media: "text", inline: false },
|
package/src/prompt-builder.ts
CHANGED
|
@@ -21,7 +21,8 @@ import { buildIdentityLockLine, withForcedIdentityLock } from "./identity-lock.j
|
|
|
21
21
|
import { findLocationMentionTokens, DEFAULT_LOCATION_USAGE_MODE, type LocationMentionTokenInfo, type LocationUsageMode } from "@nodaro/shared"
|
|
22
22
|
import { findImageMentionTokens, imageMentionSlugForRef, knownImageSlugsFromRefs, type ImageMentionTokenInfo } from "@nodaro/shared"
|
|
23
23
|
import { findEntityMentionTokens, entityMentionSlugForRef, knownEntitySlugsFromRefs, type EntityMentionTokenInfo } from "@nodaro/shared"
|
|
24
|
-
import type { CharacterDef, ConnectedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
|
|
24
|
+
import type { CharacterDef, ConnectedReference, DescribedReference, IdentityFidelity, IdentityMeta, ReferenceSource, SceneData } from "@nodaro/shared"
|
|
25
|
+
import { appendReferenceLines, referenceDescriptionLine, renderDescribedReferenceLines } from "./described-references.js"
|
|
25
26
|
import { locationReferencePhotoKindLabel, type LocationReferencePhotoKind } from "@nodaro/shared"
|
|
26
27
|
|
|
27
28
|
export interface ResolveCharacterMentionsResult {
|
|
@@ -62,6 +63,30 @@ function composeIdentityDescPart(
|
|
|
62
63
|
return parts.length > 0 ? `${subject} — ${parts.join(". ")}` : subject
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The trailing line a per-use `descriptionOverride` contributes in HYBRID
|
|
68
|
+
* format. The hybrid role phrase ("the person from reference image A") carries
|
|
69
|
+
* no description slot, so the override is surfaced as its own binding-subject
|
|
70
|
+
* line via the ONE phrasing helper in `described-references.ts`. It is pushed
|
|
71
|
+
* onto the reference's `elementDirectives` — the array every hybrid renderer
|
|
72
|
+
* already uses for "the trailing scene directives this reference contributes" —
|
|
73
|
+
* so it lands with that reference's other lines instead of threading a ninth
|
|
74
|
+
* channel through eight call sites.
|
|
75
|
+
*
|
|
76
|
+
* The hybrid EXTRAS path does not call this: it has a real `, <desc>` clause,
|
|
77
|
+
* which the override fills instead (so the model is never told twice). Nothing
|
|
78
|
+
* is pushed without an override, which is what keeps every existing hybrid
|
|
79
|
+
* output byte-identical.
|
|
80
|
+
*/
|
|
81
|
+
function pushOverrideDirective(
|
|
82
|
+
out: string[],
|
|
83
|
+
ref: Pick<ConnectedReference, "descriptionOverride">,
|
|
84
|
+
binding: string,
|
|
85
|
+
): void {
|
|
86
|
+
const line = referenceDescriptionLine(binding, ref.descriptionOverride)
|
|
87
|
+
if (line) out.push(line)
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
/**
|
|
66
91
|
* Resolve @-mention tokens in a prompt against connected references.
|
|
67
92
|
* Returns: augmented prompt (with directives prepended + tokens replaced
|
|
@@ -176,9 +201,15 @@ export function resolveCharacterMentions(
|
|
|
176
201
|
// (held-prop / styling / text) ride the bullet in every mode that emits
|
|
177
202
|
// one. Byte-identical to the old `${subject} — ${canonical}` form when no
|
|
178
203
|
// injection is present.
|
|
204
|
+
// A per-use `descriptionOverride` IS the caller describing this subject
|
|
205
|
+
// for this run, so it fills the identity slot ahead of the entity's stored
|
|
206
|
+
// canonical description — and rides every mode that emits a bullet at all
|
|
207
|
+
// (the mode gate exists to suppress STORED identity noise, not the
|
|
208
|
+
// caller's own words). "none" still emits nothing: it returned above.
|
|
179
209
|
const descPart = composeIdentityDescPart(
|
|
180
210
|
subject,
|
|
181
|
-
|
|
211
|
+
match.descriptionOverride?.trim()
|
|
212
|
+
|| (includeCanonicalDesc ? match.characterCanonicalDescription : undefined),
|
|
182
213
|
match.elementInjection,
|
|
183
214
|
)
|
|
184
215
|
// `directive` is non-null here because usageModeDirective only returns
|
|
@@ -407,6 +438,7 @@ function resolveCharacterMentionsHybrid(
|
|
|
407
438
|
const binding = bindingFor(m.url)
|
|
408
439
|
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
409
440
|
if (lock) lockLines.push(lock)
|
|
441
|
+
pushOverrideDirective(elementDirectives, ref, binding)
|
|
410
442
|
const inject = ref.elementInjection?.trim()
|
|
411
443
|
if (inject) elementDirectives.push(inject)
|
|
412
444
|
}
|
|
@@ -670,9 +702,13 @@ export function resolveLocationMentions(
|
|
|
670
702
|
// description noise.
|
|
671
703
|
const includeCanonicalDesc = effectiveMode === "identical"
|
|
672
704
|
const canonicalDesc = match.locationCanonicalDescription?.trim()
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
705
|
+
// A per-use `descriptionOverride` IS the caller describing this place for
|
|
706
|
+
// this run, so it fills the identity slot ahead of the location's stored
|
|
707
|
+
// canonical description — and rides every mode that emits a bullet at all,
|
|
708
|
+
// the same rule the character resolvers apply.
|
|
709
|
+
const shownDesc = match.descriptionOverride?.trim()
|
|
710
|
+
|| (includeCanonicalDesc ? canonicalDesc : undefined)
|
|
711
|
+
const descPart = shownDesc ? `${subject} — ${shownDesc}` : subject
|
|
676
712
|
directiveLines.push(`- ${descPart}.${directive ? ` ${directive}` : ""}`)
|
|
677
713
|
|
|
678
714
|
// Variant display-name sub-line: only when the user pinned a specific
|
|
@@ -830,6 +866,7 @@ function resolveLocationMentionsHybrid(
|
|
|
830
866
|
const binding = bindingFor(m.url)
|
|
831
867
|
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
832
868
|
if (lock) lockLines.push(lock)
|
|
869
|
+
pushOverrideDirective(elementDirectives, ref, binding)
|
|
833
870
|
const inject = ref.elementInjection?.trim()
|
|
834
871
|
if (inject) elementDirectives.push(inject)
|
|
835
872
|
}
|
|
@@ -848,6 +885,14 @@ interface ResolveImageMentionsHybridResult {
|
|
|
848
885
|
* mentioned refs out of `connectedReferences`, and this pass deliberately
|
|
849
886
|
* does no such filtering (see the caller's NOTE). Carrying the set anyway
|
|
850
887
|
* would advertise a filter that does not exist. */
|
|
888
|
+
/**
|
|
889
|
+
* The URLs a mention BOUND (deduped). This pass already surfaced each bound
|
|
890
|
+
* ref's per-use `descriptionOverride` as its own trailing line, so the
|
|
891
|
+
* caller's untold-override sweep skips them — the same by-URL suppression
|
|
892
|
+
* contract `resolveEntityMentionsHybrid` carries, for the same reason: told
|
|
893
|
+
* once, never twice.
|
|
894
|
+
*/
|
|
895
|
+
mentionedUrls: Set<string>
|
|
851
896
|
/** Per-reference identity-lock lines (deduped per URL). Caller prepends them
|
|
852
897
|
* as ONE block, merged with the character/location lock lines. */
|
|
853
898
|
lockLines: string[]
|
|
@@ -955,11 +1000,12 @@ function resolveImageMentionsHybrid(
|
|
|
955
1000
|
const binding = bindingFor(m.url)
|
|
956
1001
|
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
957
1002
|
if (lock) lockLines.push(lock)
|
|
1003
|
+
pushOverrideDirective(elementDirectives, ref, binding)
|
|
958
1004
|
const inject = ref.elementInjection?.trim()
|
|
959
1005
|
if (inject) elementDirectives.push(inject)
|
|
960
1006
|
}
|
|
961
1007
|
|
|
962
|
-
return { prompt: resolvedPrompt, additionalUrls, lockLines, elementDirectives }
|
|
1008
|
+
return { prompt: resolvedPrompt, additionalUrls, mentionedUrls: seenUrls, lockLines, elementDirectives }
|
|
963
1009
|
}
|
|
964
1010
|
|
|
965
1011
|
interface ResolveEntityMentionsHybridResult {
|
|
@@ -1137,6 +1183,7 @@ function resolveEntityMentionsHybrid(
|
|
|
1137
1183
|
const binding = bindingFor(m.url)
|
|
1138
1184
|
const lock = buildIdentityLockLine(withForcedIdentityLock(ref, lockOverrideByUrl.get(m.url)), binding)
|
|
1139
1185
|
if (lock) lockLines.push(lock)
|
|
1186
|
+
pushOverrideDirective(elementDirectives, ref, binding)
|
|
1140
1187
|
const inject = ref.elementInjection?.trim()
|
|
1141
1188
|
if (inject) elementDirectives.push(inject)
|
|
1142
1189
|
}
|
|
@@ -1221,9 +1268,12 @@ function buildCanonicalFallback(
|
|
|
1221
1268
|
// the (mode-gated) canonical description. This is the reported path — a
|
|
1222
1269
|
// character wired with no @-mention — so a composed character surfaces its
|
|
1223
1270
|
// elements wherever it's used downstream.
|
|
1271
|
+
// Per-use override ahead of the stored canonical description, mode-gate
|
|
1272
|
+
// included — same rule as the mention path above.
|
|
1224
1273
|
const descPart = composeIdentityDescPart(
|
|
1225
1274
|
subject,
|
|
1226
|
-
|
|
1275
|
+
r.descriptionOverride?.trim()
|
|
1276
|
+
|| (includeCanonicalDesc ? r.characterCanonicalDescription : undefined),
|
|
1227
1277
|
r.elementInjection,
|
|
1228
1278
|
)
|
|
1229
1279
|
// `directive` is non-null here ("none"/"name" already short-circuited).
|
|
@@ -1283,7 +1333,12 @@ function buildExtraRefDirectives(
|
|
|
1283
1333
|
for (const r of refs) {
|
|
1284
1334
|
if (!r.isExtraRef) continue
|
|
1285
1335
|
if (!r.url) continue
|
|
1286
|
-
|
|
1336
|
+
// A per-use `descriptionOverride` fills THIS extra's description slot —
|
|
1337
|
+
// every branch below reads one `description`, so the override lands in the
|
|
1338
|
+
// pair-back tail, the name-mode bullet and the first-sight descriptor alike
|
|
1339
|
+
// (and, being the caller's own words, outranks the stored canonical too).
|
|
1340
|
+
const description = (r.descriptionOverride ?? "").trim()
|
|
1341
|
+
|| (r.description ?? r.variantDescription ?? "").trim()
|
|
1287
1342
|
// Character extra
|
|
1288
1343
|
if (r.source === "wired-character" && r.characterSlug) {
|
|
1289
1344
|
const effectiveMode: UsageMode = r.defaultUsageMode ?? DEFAULT_USAGE_MODE
|
|
@@ -1421,6 +1476,7 @@ function renderCanonicalFallbackHybrid(
|
|
|
1421
1476
|
phrases.push(roleToPhrase(resolveDefaultRole(r.defaultRole, r.defaultUsageMode, r.source), binding))
|
|
1422
1477
|
const lock = buildIdentityLockLine(r, binding)
|
|
1423
1478
|
if (lock) lockLines.push(lock)
|
|
1479
|
+
pushOverrideDirective(elementDirectives, r, binding)
|
|
1424
1480
|
const inject = r.elementInjection?.trim()
|
|
1425
1481
|
if (inject) elementDirectives.push(inject)
|
|
1426
1482
|
}
|
|
@@ -1464,7 +1520,10 @@ function renderExtraRefsHybrid(
|
|
|
1464
1520
|
if (!r.url) continue
|
|
1465
1521
|
const letter = letterForUrl(r.url)
|
|
1466
1522
|
const binding = `reference image ${letter}`
|
|
1467
|
-
|
|
1523
|
+
// Per-use override fills the extras' own `, <desc>` clause — the one
|
|
1524
|
+
// description slot the hybrid format has (see `described-references.ts`).
|
|
1525
|
+
const description = (r.descriptionOverride ?? "").trim()
|
|
1526
|
+
|| (r.description ?? r.variantDescription ?? "").trim()
|
|
1468
1527
|
if (r.source === "wired-character" && r.characterSlug) {
|
|
1469
1528
|
const earlier = firstLetterByChar.get(r.characterSlug)
|
|
1470
1529
|
if (earlier !== undefined && earlier !== letter) {
|
|
@@ -1523,12 +1582,16 @@ function renderExtraRefsHybrid(
|
|
|
1523
1582
|
* the call site). A location that is BOTH unmentioned AND `{image:N}`-token-
|
|
1524
1583
|
* referenced is rendered ONCE (inline, via the scene); we `continue` here so it
|
|
1525
1584
|
* is not ALSO emitted as a trailing canonical phrase (the C1 review Minor).
|
|
1585
|
+
*
|
|
1586
|
+
* `renderedUrls` reports the URLs that DID render here — and therefore already
|
|
1587
|
+
* carry their per-use `descriptionOverride` line — so the caller's untold-
|
|
1588
|
+
* override sweep skips them instead of re-deriving this loop's predicate.
|
|
1526
1589
|
*/
|
|
1527
1590
|
function renderLocationCanonicalHybrid(
|
|
1528
1591
|
nonCharacterRefs: readonly ConnectedReference[],
|
|
1529
1592
|
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
1530
1593
|
coveredUrls: ReadonlySet<string>,
|
|
1531
|
-
): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
|
|
1594
|
+
): { phrases: string[]; lockLines: string[]; elementDirectives: string[]; renderedUrls: Set<string> } {
|
|
1532
1595
|
const phrases: string[] = []
|
|
1533
1596
|
const lockLines: string[] = []
|
|
1534
1597
|
const elementDirectives: string[] = []
|
|
@@ -1546,10 +1609,11 @@ function renderLocationCanonicalHybrid(
|
|
|
1546
1609
|
phrases.push(roleToPhrase(resolveLocationRole(null, null, r), binding))
|
|
1547
1610
|
const lock = buildIdentityLockLine(r, binding)
|
|
1548
1611
|
if (lock) lockLines.push(lock)
|
|
1612
|
+
pushOverrideDirective(elementDirectives, r, binding)
|
|
1549
1613
|
const inject = r.elementInjection?.trim()
|
|
1550
1614
|
if (inject) elementDirectives.push(inject)
|
|
1551
1615
|
}
|
|
1552
|
-
return { phrases, lockLines, elementDirectives }
|
|
1616
|
+
return { phrases, lockLines, elementDirectives, renderedUrls: seenUrls }
|
|
1553
1617
|
}
|
|
1554
1618
|
|
|
1555
1619
|
/**
|
|
@@ -1576,13 +1640,15 @@ function renderLocationCanonicalHybrid(
|
|
|
1576
1640
|
* has to happen HERE, per URL. The mention pass emits the same lock line and
|
|
1577
1641
|
* `elementInjection` this loop would have, so nothing is lost by skipping it.
|
|
1578
1642
|
*
|
|
1579
|
-
* Deduped per URL (an object wired twice → one phrase).
|
|
1643
|
+
* Deduped per URL (an object wired twice → one phrase). `renderedUrls` reports
|
|
1644
|
+
* the URLs that DID render — already carrying their per-use
|
|
1645
|
+
* `descriptionOverride` line — for the caller's untold-override sweep.
|
|
1580
1646
|
*/
|
|
1581
1647
|
function renderObjectCreatureCanonicalHybrid(
|
|
1582
1648
|
nonCharacterRefs: readonly ConnectedReference[],
|
|
1583
1649
|
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
1584
1650
|
coveredUrls: ReadonlySet<string>,
|
|
1585
|
-
): { phrases: string[]; lockLines: string[]; elementDirectives: string[] } {
|
|
1651
|
+
): { phrases: string[]; lockLines: string[]; elementDirectives: string[]; renderedUrls: Set<string> } {
|
|
1586
1652
|
const phrases: string[] = []
|
|
1587
1653
|
const lockLines: string[] = []
|
|
1588
1654
|
const elementDirectives: string[] = []
|
|
@@ -1597,10 +1663,49 @@ function renderObjectCreatureCanonicalHybrid(
|
|
|
1597
1663
|
phrases.push(roleToPhrase(defaultRoleForSource(r.source), binding))
|
|
1598
1664
|
const lock = buildIdentityLockLine(r, binding)
|
|
1599
1665
|
if (lock) lockLines.push(lock)
|
|
1666
|
+
pushOverrideDirective(elementDirectives, r, binding)
|
|
1600
1667
|
const inject = r.elementInjection?.trim()
|
|
1601
1668
|
if (inject) elementDirectives.push(inject)
|
|
1602
1669
|
}
|
|
1603
|
-
return { phrases, lockLines, elementDirectives }
|
|
1670
|
+
return { phrases, lockLines, elementDirectives, renderedUrls: seenUrls }
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* The per-use `descriptionOverride` of every non-character reference the hybrid
|
|
1675
|
+
* format renders NO role phrase for — the last leg of "the override is honoured
|
|
1676
|
+
* wherever the reference ships".
|
|
1677
|
+
*
|
|
1678
|
+
* Three kinds land here: a ref a `{image:N:label}` token expanded INLINE (the
|
|
1679
|
+
* expansion carries no description slot, and the covered set suppresses both
|
|
1680
|
+
* canonical renders), an unmentioned `wired-image` / `manual` / `wired-face` ref
|
|
1681
|
+
* (no canonical render exists for those sources at all), and any other seated
|
|
1682
|
+
* ref the passes above left unspoken. Each gets ONE
|
|
1683
|
+
* `reference image <LETTER> — <override>.` line, from the same helper every
|
|
1684
|
+
* other site uses, numbered against `finalIndexByUrl`.
|
|
1685
|
+
*
|
|
1686
|
+
* `toldUrls` is every URL a pass above ALREADY gave an override line — the two
|
|
1687
|
+
* canonical renders' `renderedUrls` plus the image / entity mention passes'
|
|
1688
|
+
* bound URLs — so a reference that is both @-mentioned and token-covered is told
|
|
1689
|
+
* once, never twice. A URL is marked swept only when it actually emitted a line,
|
|
1690
|
+
* so two refs sharing one URL where only the later carries an override still
|
|
1691
|
+
* speak.
|
|
1692
|
+
*/
|
|
1693
|
+
function renderUntoldOverridesHybrid(
|
|
1694
|
+
nonCharacterRefs: readonly ConnectedReference[],
|
|
1695
|
+
finalIndexByUrl: ReadonlyMap<string, number>,
|
|
1696
|
+
toldUrls: ReadonlySet<string>,
|
|
1697
|
+
): string[] {
|
|
1698
|
+
const lines: string[] = []
|
|
1699
|
+
const sweptUrls = new Set<string>()
|
|
1700
|
+
for (const r of nonCharacterRefs) {
|
|
1701
|
+
if (!r.url || toldUrls.has(r.url) || sweptUrls.has(r.url)) continue
|
|
1702
|
+
const slot = finalIndexByUrl.get(r.url)
|
|
1703
|
+
if (!slot) continue
|
|
1704
|
+
const before = lines.length
|
|
1705
|
+
pushOverrideDirective(lines, r, `reference image ${slotToLetter(slot)}`)
|
|
1706
|
+
if (lines.length > before) sweptUrls.add(r.url)
|
|
1707
|
+
}
|
|
1708
|
+
return lines
|
|
1604
1709
|
}
|
|
1605
1710
|
|
|
1606
1711
|
/**
|
|
@@ -1874,6 +1979,16 @@ export interface BuildImagePromptConfig {
|
|
|
1874
1979
|
* in order, and tokens expand to "the {label} from image {N}".
|
|
1875
1980
|
*/
|
|
1876
1981
|
connectedReferences?: ConnectedReference[]
|
|
1982
|
+
/**
|
|
1983
|
+
* References the caller can NAME and DESCRIBE but has no media for (an
|
|
1984
|
+
* un-bound cast role, an analysis slot). They attach no URL and claim no
|
|
1985
|
+
* `Image N` / lettered slot — they reach the model as prose, rendered once by
|
|
1986
|
+
* `renderDescribedReferenceLines` and joined the way the active format joins
|
|
1987
|
+
* its trailing directives. Present with NO `connectedReferences` is the normal
|
|
1988
|
+
* case (a story landing before any entity exists), so they enter the
|
|
1989
|
+
* connected-reference path on their own.
|
|
1990
|
+
*/
|
|
1991
|
+
describedReferences?: readonly DescribedReference[]
|
|
1877
1992
|
/** Per-identity (imageIndex+label) user overrides for fidelity / custom text. */
|
|
1878
1993
|
identityMeta?: readonly IdentityMeta[]
|
|
1879
1994
|
/**
|
|
@@ -2141,6 +2256,13 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2141
2256
|
// reassignment, so computing this once up front is stable. Gates the hybrid
|
|
2142
2257
|
// character convergence (Phase 0) and the non-capitalizing scene render below.
|
|
2143
2258
|
const isHybrid = config.referenceFormat === "hybrid"
|
|
2259
|
+
// Described references (name + description, no media) — rendered ONCE, up
|
|
2260
|
+
// front, so the reassignments below can't lose them. They attach no URL, so
|
|
2261
|
+
// they take no part in numbering; they are joined onto the assembled prompt at
|
|
2262
|
+
// the single site in the connected-reference path below, ahead of the provider
|
|
2263
|
+
// cap so a shed sees them like any other body text. Empty for every caller
|
|
2264
|
+
// that doesn't send them → nothing downstream changes.
|
|
2265
|
+
const describedLines = renderDescribedReferenceLines(config.describedReferences)
|
|
2144
2266
|
// Set when Phase 0 converged character OR location @-mentions into inline
|
|
2145
2267
|
// hybrid role phrases (+ identity-lock + element directives). Tells the hybrid
|
|
2146
2268
|
// scene render to expand any remaining {image:N} tokens WITHOUT capitalizing
|
|
@@ -2155,6 +2277,12 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2155
2277
|
// every prompt without an entity mention, which is what keeps those outputs
|
|
2156
2278
|
// byte-identical.
|
|
2157
2279
|
const mentionedEntityUrls = new Set<string>()
|
|
2280
|
+
// URLs bound by a Phase-0 named-image (`@<image-name>`) mention. Bridges the
|
|
2281
|
+
// same two blocks for the OTHER suppression the New path owes: that pass
|
|
2282
|
+
// already emitted each bound ref's per-use `descriptionOverride` line, so the
|
|
2283
|
+
// untold-override sweep must not say it a second time. Empty for every prompt
|
|
2284
|
+
// without an image mention.
|
|
2285
|
+
const mentionedImageUrls = new Set<string>()
|
|
2158
2286
|
|
|
2159
2287
|
// Character LoRA inference path: trigger word + LoRA model carry identity,
|
|
2160
2288
|
// so we strip raw `@slug[:V[:variant]]` tokens from the prompt AND drop the
|
|
@@ -2410,6 +2538,10 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2410
2538
|
resolved.additionalUrls = [...resolved.additionalUrls, ...hi.additionalUrls]
|
|
2411
2539
|
hybridImageLockLines = hi.lockLines
|
|
2412
2540
|
hybridImageElementDirectives = hi.elementDirectives
|
|
2541
|
+
// The override handoff to the New path: a bound URL was already told
|
|
2542
|
+
// its per-use description here, inline with the mention, so the
|
|
2543
|
+
// untold-override sweep below skips it.
|
|
2544
|
+
for (const u of hi.mentionedUrls) mentionedImageUrls.add(u)
|
|
2413
2545
|
// Inline role phrases now live in the body → skip line-initial
|
|
2414
2546
|
// capitalization, which would otherwise corrupt a mid-sentence
|
|
2415
2547
|
// "the background from reference image C".
|
|
@@ -2646,9 +2778,25 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2646
2778
|
// wired-image, wired-face, wired-object, wired-location) still auto-
|
|
2647
2779
|
// attach so unchanged behavior for them.
|
|
2648
2780
|
// -------------------------------------------------------------------------
|
|
2649
|
-
|
|
2781
|
+
// Entered by `connectedReferences` OR by described references alone: a story
|
|
2782
|
+
// landing whose cast has no entities yet sends only names + descriptions, and
|
|
2783
|
+
// the legacy path below has no directive seam to join them onto.
|
|
2784
|
+
if (connectedReferences || describedLines.length > 0) {
|
|
2785
|
+
const structuredRefs = connectedReferences ?? []
|
|
2650
2786
|
let prompt = config.prompt
|
|
2651
2787
|
|
|
2788
|
+
// Described references, LEGACY half — joined BEFORE the directive assembly
|
|
2789
|
+
// below, because the block it creates is the block that assembly consolidates
|
|
2790
|
+
// into. Joined AFTER instead, a request whose references produce the
|
|
2791
|
+
// "Use these references for the output image:" wrap (any non-character ref,
|
|
2792
|
+
// no wired character) would get a SECOND "Use these characters:" header
|
|
2793
|
+
// prepended above it. Prepending here also keeps `marks.directivesPrefix`
|
|
2794
|
+
// honest: the prepend branch that records it is only reachable when the
|
|
2795
|
+
// prompt does NOT already open with the character block.
|
|
2796
|
+
if (!isHybrid && describedLines.length > 0) {
|
|
2797
|
+
prompt = appendReferenceLines(prompt, describedLines, "legacy")
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2652
2800
|
// Non-character refs are still emitted as per-identity directives + URLs.
|
|
2653
2801
|
// Character refs are filtered out here — Phase 0 has already added their
|
|
2654
2802
|
// URLs to `referenceImageUrls` (via mention resolution) and prepended the
|
|
@@ -2658,7 +2806,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2658
2806
|
// subject as Image M, …") and merged their URLs into `referenceImageUrls`.
|
|
2659
2807
|
// Letting them through here would double-emit the URLs (and append a
|
|
2660
2808
|
// second positional directive via the {image:N:label} path).
|
|
2661
|
-
const nonCharacterRefs =
|
|
2809
|
+
const nonCharacterRefs = structuredRefs.filter(
|
|
2662
2810
|
(r) => r.source !== "wired-character" && r.isExtraRef !== true,
|
|
2663
2811
|
)
|
|
2664
2812
|
|
|
@@ -2761,9 +2909,27 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2761
2909
|
const objCanon = renderObjectCreatureCanonicalHybrid(nonCharacterRefs, finalIndexByUrl, objCoveredUrls)
|
|
2762
2910
|
const canonLockLines = [...locCanon.lockLines, ...objCanon.lockLines]
|
|
2763
2911
|
const canonLockBlock = canonLockLines.length > 0 ? `${canonLockLines.join("\n")}\n\n` : ""
|
|
2912
|
+
// Per-use descriptions nothing above spoke for: a `{image:N}`-covered ref
|
|
2913
|
+
// (expanded inline, suppressed from both canonical renders) and a seated
|
|
2914
|
+
// `wired-image` / `manual` ref (which has no canonical render at all)
|
|
2915
|
+
// would otherwise drop their `descriptionOverride` silently — the one
|
|
2916
|
+
// place the hybrid format renders neither a description slot nor a role
|
|
2917
|
+
// phrase. Deduped against every URL a pass above already told.
|
|
2918
|
+
const toldOverrideUrls = new Set<string>([
|
|
2919
|
+
...locCanon.renderedUrls,
|
|
2920
|
+
...objCanon.renderedUrls,
|
|
2921
|
+
...mentionedEntityUrls,
|
|
2922
|
+
...mentionedImageUrls,
|
|
2923
|
+
])
|
|
2924
|
+
const untoldOverrides = renderUntoldOverridesHybrid(
|
|
2925
|
+
nonCharacterRefs,
|
|
2926
|
+
finalIndexByUrl,
|
|
2927
|
+
toldOverrideUrls,
|
|
2928
|
+
)
|
|
2764
2929
|
const canonTrailingLines = [
|
|
2765
2930
|
...locCanon.phrases, ...objCanon.phrases,
|
|
2766
2931
|
...locCanon.elementDirectives, ...objCanon.elementDirectives,
|
|
2932
|
+
...untoldOverrides,
|
|
2767
2933
|
]
|
|
2768
2934
|
// Role phrases and element injections are scene content → they extend the
|
|
2769
2935
|
// BODY, ahead of the `[style]` section (which has no terminator, so a flat
|
|
@@ -2811,6 +2977,14 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2811
2977
|
}
|
|
2812
2978
|
}
|
|
2813
2979
|
|
|
2980
|
+
// Described references, HYBRID half: trailing scene directives, so they land
|
|
2981
|
+
// behind the reference role phrases the branch above appended (the legacy
|
|
2982
|
+
// half ran before it — see the comment at that call). Still ahead of the
|
|
2983
|
+
// provider cap below, so the lines are body text like any other.
|
|
2984
|
+
if (isHybrid && describedLines.length > 0) {
|
|
2985
|
+
prompt = appendReferenceLines(prompt, describedLines, "hybrid")
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2814
2988
|
const styleText = style?.trim()
|
|
2815
2989
|
const styleLine = styleText && !isDeniedStyleId(styleText) ? `Style: ${getStylePromptHint(styleText) || styleText}` : ""
|
|
2816
2990
|
|
|
@@ -2883,7 +3057,7 @@ function buildImagePromptInternal(config: BuildImagePromptConfig, marks?: Assemb
|
|
|
2883
3057
|
const reordered = applyReferenceOrder(
|
|
2884
3058
|
assembledUrls,
|
|
2885
3059
|
prompt,
|
|
2886
|
-
|
|
3060
|
+
structuredRefs,
|
|
2887
3061
|
referenceOrder,
|
|
2888
3062
|
sourceNodeIdById,
|
|
2889
3063
|
)
|
|
@@ -3110,7 +3284,10 @@ function collectIdentities(
|
|
|
3110
3284
|
label,
|
|
3111
3285
|
fidelity: m?.fidelity ?? defaultFidelityForSource(ref?.source),
|
|
3112
3286
|
customText: m?.customText?.trim() || undefined,
|
|
3113
|
-
|
|
3287
|
+
// Per-use override ahead of the ref's own description; the directive
|
|
3288
|
+
// builder's location-canonical fallback only fires when BOTH are absent,
|
|
3289
|
+
// so an override outranks the stored location wording too.
|
|
3290
|
+
description: ref?.descriptionOverride?.trim() || ref?.description,
|
|
3114
3291
|
source: ref?.source,
|
|
3115
3292
|
locationCanonicalDescription: ref?.locationCanonicalDescription,
|
|
3116
3293
|
locationSlug: ref?.locationSlug,
|
|
@@ -3303,7 +3480,7 @@ function buildNonCharacterDirectives(
|
|
|
3303
3480
|
? "creature"
|
|
3304
3481
|
: "object",
|
|
3305
3482
|
fidelity: defaultFidelityForSource(ref.source),
|
|
3306
|
-
description: ref.description?.trim() || undefined,
|
|
3483
|
+
description: ref.descriptionOverride?.trim() || ref.description?.trim() || undefined,
|
|
3307
3484
|
source: ref.source,
|
|
3308
3485
|
locationCanonicalDescription: ref.locationCanonicalDescription,
|
|
3309
3486
|
locationSlug: ref.locationSlug,
|